304 lines
12 KiB
Rust
304 lines
12 KiB
Rust
//! Operation envelopes, their canonical hash, and the well-formedness contract
|
|
//! (Chapter 6 §"Operation Envelopes", §"Envelope Acceptance").
|
|
//!
|
|
//! An operation is transmitted, stored, and reduced as an *envelope*: the
|
|
//! payload together with its identity, ordering stamp, causal context, and
|
|
//! optional transaction grouping. The [`EnvelopeHash`] is the BLAKE3-256 of the
|
|
//! envelope's canonical serialization under the `MUSCENVH` domain tag; it is
|
|
//! what the [`OperationSlot`](crate::OperationSlot) compares to detect
|
|
//! equivocation (two distinct canonical envelopes under one
|
|
//! [`OperationId`](epiphany_core::OperationId)).
|
|
//!
|
|
//! [`well_formed`] is the reception gate (Chapter 6 §6.4): an envelope that is
|
|
//! not well-formed is rejected and never enters the canonical operation set.
|
|
//! Crucially, acceptance does **not** trust stamp *content* — a peer may emit
|
|
//! implausible future times — it only checks the structural invariants,
|
|
//! foremost `stamp.id == id`.
|
|
|
|
use epiphany_core::{OperationId, TransactionId};
|
|
use epiphany_determinism::{CanonicalDecode, CanonicalEncode, DomainTag, Preimage};
|
|
|
|
use crate::causal::CausalContext;
|
|
use crate::encode::{push_canon, push_tag};
|
|
use crate::payload::OperationPayload;
|
|
use crate::stamp::OperationStamp;
|
|
use crate::support::AuthorId;
|
|
|
|
/// The BLAKE3-256 hash of a canonical envelope serialization under the
|
|
/// `MUSCENVH` domain tag (Chapter 6 §6.5). Ordered lexicographically on the 32
|
|
/// bytes — the order an `Equivocated` slot's `candidates` set enumerates in.
|
|
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
|
pub struct EnvelopeHash(pub [u8; 32]);
|
|
|
|
impl EnvelopeHash {
|
|
/// The raw 32 bytes.
|
|
#[inline]
|
|
pub const fn as_bytes(&self) -> &[u8; 32] {
|
|
&self.0
|
|
}
|
|
|
|
/// Lowercase hex (64 chars), for diagnostics.
|
|
pub fn to_hex(&self) -> String {
|
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
let mut s = String::with_capacity(64);
|
|
for &b in &self.0 {
|
|
s.push(HEX[(b >> 4) as usize] as char);
|
|
s.push(HEX[(b & 0x0f) as usize] as char);
|
|
}
|
|
s
|
|
}
|
|
}
|
|
|
|
impl core::fmt::Debug for EnvelopeHash {
|
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
write!(f, "EnvelopeHash({}…)", &self.to_hex()[..8])
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for EnvelopeHash {
|
|
#[inline]
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
out.extend_from_slice(&self.0);
|
|
}
|
|
}
|
|
|
|
/// An operation envelope (Chapter 6 §"Operation Envelopes").
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub struct OperationEnvelope {
|
|
/// Stable identifier of this operation.
|
|
pub id: OperationId,
|
|
/// Author (may differ from the replica in shared authoring sessions).
|
|
pub author: AuthorId,
|
|
/// Ordering stamp. For canonical reduction order; never for identity.
|
|
pub stamp: OperationStamp,
|
|
/// Compact causal context (dotted version vector).
|
|
pub causal_context: CausalContext,
|
|
/// Optional transaction grouping; members sharing a `TransactionId` reduce
|
|
/// atomically.
|
|
pub transaction: Option<TransactionId>,
|
|
/// The mutation itself.
|
|
pub payload: OperationPayload,
|
|
}
|
|
|
|
impl OperationEnvelope {
|
|
/// The canonical [`EnvelopeHash`] (`BLAKE3(MUSCENVH || canonical_bytes)`).
|
|
pub fn envelope_hash(&self) -> EnvelopeHash {
|
|
let mut p = Preimage::new(DomainTag::ENVELOPE);
|
|
p.push_bytes(&self.to_canonical_bytes());
|
|
EnvelopeHash(*p.finish().as_bytes())
|
|
}
|
|
|
|
/// The binary-format schema major this envelope's canonical bytes require
|
|
/// ([`OperationPayload::schema_major`]). An op-envelope block's schema major
|
|
/// is the maximum over the envelopes it carries.
|
|
pub fn schema_major(&self) -> u16 {
|
|
self.payload.schema_major()
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for OperationEnvelope {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
push_canon(out, &self.id);
|
|
push_canon(out, &self.author);
|
|
push_canon(out, &self.stamp);
|
|
push_canon(out, &self.causal_context);
|
|
match &self.transaction {
|
|
None => push_tag(out, 0),
|
|
Some(t) => {
|
|
push_tag(out, 1);
|
|
push_canon(out, t);
|
|
}
|
|
}
|
|
push_canon(out, &self.payload);
|
|
}
|
|
}
|
|
|
|
/// Reads the [`OperationId`] a canonically encoded envelope declares from its
|
|
/// **leading 16 bytes** alone, without decoding the rest of the envelope.
|
|
///
|
|
/// The canonical envelope encoding leads with the id
|
|
/// ([`OperationEnvelope::encode_canonical`] pushes `id` first, and an
|
|
/// `OperationId`'s canonical form is exactly 16 big-endian bytes), so this is a
|
|
/// total, allocation-free peek. Returns `None` when fewer than 16 bytes are
|
|
/// present. It deliberately performs **no** validation of the remaining bytes —
|
|
/// full decoding stays the caller's job — which is exactly the contract the
|
|
/// bundle's operation index (Chapter 8 §"The Operation Index") needs: the
|
|
/// bundle layer keys index entries on the raw 16 id bytes without ever
|
|
/// interpreting an envelope, and this helper is the one place the ops layer
|
|
/// vouches that those bytes really are the id.
|
|
pub fn peek_operation_id(envelope_bytes: &[u8]) -> Option<OperationId> {
|
|
let head = envelope_bytes.get(..16)?;
|
|
OperationId::decode_canonical(head).ok()
|
|
}
|
|
|
|
/// Why an envelope failed the well-formedness check (Chapter 6 §6.4). A
|
|
/// rejected envelope is recorded in the local diagnostic log but does not enter
|
|
/// the canonical operation set.
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub enum WellFormednessError {
|
|
/// The operation id's replica is the reserved `SYSTEM_DERIVED` namespace;
|
|
/// operations are not authored by the system namespace.
|
|
SystemDerivedReplica,
|
|
/// `stamp.id` is not byte-identical to the top-level `id`: the stamp
|
|
/// addresses a different operation than the envelope's identity declares.
|
|
StampIdMismatch,
|
|
/// `stamp.hlc.physical_time` is negative (it must be finite and
|
|
/// non-negative; the integer encoding is always finite).
|
|
NegativePhysicalTime,
|
|
/// The causal context's vector references the reserved `SYSTEM_DERIVED`
|
|
/// replica namespace, which never authors operations.
|
|
MalformedCausalReplica,
|
|
/// A causal dot is not a well-formed `OperationId` (its replica is the
|
|
/// reserved `SYSTEM_DERIVED` namespace).
|
|
MalformedDot,
|
|
}
|
|
|
|
impl core::fmt::Display for WellFormednessError {
|
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
let msg = match self {
|
|
WellFormednessError::SystemDerivedReplica => {
|
|
"operation id uses the reserved SYSTEM_DERIVED replica namespace"
|
|
}
|
|
WellFormednessError::StampIdMismatch => "stamp.id does not equal the envelope id",
|
|
WellFormednessError::NegativePhysicalTime => "stamp physical_time is negative",
|
|
WellFormednessError::MalformedCausalReplica => {
|
|
"causal context references the SYSTEM_DERIVED replica namespace"
|
|
}
|
|
WellFormednessError::MalformedDot => {
|
|
"a causal dot uses the SYSTEM_DERIVED replica namespace"
|
|
}
|
|
};
|
|
f.write_str(msg)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for WellFormednessError {}
|
|
|
|
/// Checks an incoming envelope for well-formedness (Chapter 6 §6.4). Returns
|
|
/// `Ok(())` if it may enter the operation set, or the first structural failure.
|
|
///
|
|
/// The check is *structural only*: it never validates the plausibility of the
|
|
/// HLC content (a peer may legitimately emit implausible future times), and it
|
|
/// never depends on arrival order — two replicas observing the same envelope
|
|
/// reach the same verdict.
|
|
pub fn well_formed(env: &OperationEnvelope) -> Result<(), WellFormednessError> {
|
|
// OperationId replica is not the system namespace.
|
|
if env.id.replica.is_system_derived() {
|
|
return Err(WellFormednessError::SystemDerivedReplica);
|
|
}
|
|
// stamp.id is byte-identical to the top-level id.
|
|
if env.stamp.id != env.id {
|
|
return Err(WellFormednessError::StampIdMismatch);
|
|
}
|
|
// physical_time is finite (always, for the integer encoding) and non-negative.
|
|
if env.stamp.hlc.physical_time.0 < 0 {
|
|
return Err(WellFormednessError::NegativePhysicalTime);
|
|
}
|
|
// Causal context references only well-formed replica identifiers …
|
|
if env
|
|
.causal_context
|
|
.vector
|
|
.keys()
|
|
.any(|r| r.is_system_derived())
|
|
{
|
|
return Err(WellFormednessError::MalformedCausalReplica);
|
|
}
|
|
// … and its dots are well-formed OperationIds.
|
|
if env
|
|
.causal_context
|
|
.dots()
|
|
.any(|d| d.replica.is_system_derived())
|
|
{
|
|
return Err(WellFormednessError::MalformedDot);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::payload::{OperationKind, RespellPitchOp};
|
|
use crate::stamp::HybridLogicalClock;
|
|
use epiphany_core::{PitchId, ReplicaId, WallClockTime};
|
|
|
|
fn env(id: OperationId, stamp_id: OperationId) -> OperationEnvelope {
|
|
OperationEnvelope {
|
|
id,
|
|
author: AuthorId(1),
|
|
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(10), 0), stamp_id),
|
|
causal_context: CausalContext::new(),
|
|
transaction: None,
|
|
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
|
pitch: PitchId::new(ReplicaId(1), 1),
|
|
spelling: crate::valuegen::spelling(7),
|
|
})),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn well_formed_requires_stamp_id_equals_id() {
|
|
let good = env(
|
|
OperationId::new(ReplicaId(1), 5),
|
|
OperationId::new(ReplicaId(1), 5),
|
|
);
|
|
assert_eq!(well_formed(&good), Ok(()));
|
|
let bad = env(
|
|
OperationId::new(ReplicaId(1), 5),
|
|
OperationId::new(ReplicaId(1), 6),
|
|
);
|
|
assert_eq!(well_formed(&bad), Err(WellFormednessError::StampIdMismatch));
|
|
}
|
|
|
|
#[test]
|
|
fn well_formed_rejects_system_derived_author_replica() {
|
|
let id = OperationId::new(ReplicaId::SYSTEM_DERIVED, 1);
|
|
let e = env(id, id);
|
|
assert_eq!(
|
|
well_formed(&e),
|
|
Err(WellFormednessError::SystemDerivedReplica)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn well_formed_rejects_negative_physical_time() {
|
|
let mut e = env(
|
|
OperationId::new(ReplicaId(1), 5),
|
|
OperationId::new(ReplicaId(1), 5),
|
|
);
|
|
e.stamp.hlc.physical_time = WallClockTime(-1);
|
|
assert_eq!(
|
|
well_formed(&e),
|
|
Err(WellFormednessError::NegativePhysicalTime)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn peek_operation_id_reads_the_leading_canonical_bytes() {
|
|
let id = OperationId::new(ReplicaId(0x0102_0304_0506_0708), 0x1122_3344_5566_7788);
|
|
let e = env(id, id);
|
|
let bytes = e.to_canonical_bytes();
|
|
// The canonical envelope encoding truly *leads* with the id's 16
|
|
// canonical bytes — the invariant `peek_operation_id` (and the bundle's
|
|
// operation index built on it) depends on.
|
|
assert_eq!(&bytes[..16], &id.canonical_bytes());
|
|
assert_eq!(peek_operation_id(&bytes), Some(id));
|
|
// A short buffer has no id to peek.
|
|
assert_eq!(peek_operation_id(&bytes[..15]), None);
|
|
assert_eq!(peek_operation_id(&[]), None);
|
|
}
|
|
|
|
#[test]
|
|
fn envelope_hash_changes_with_payload_bytes() {
|
|
let mut a = env(
|
|
OperationId::new(ReplicaId(1), 5),
|
|
OperationId::new(ReplicaId(1), 5),
|
|
);
|
|
let ha = a.envelope_hash();
|
|
a.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
|
pitch: PitchId::new(ReplicaId(1), 1),
|
|
spelling: crate::valuegen::spelling(9), // different spelling
|
|
}));
|
|
assert_ne!(ha, a.envelope_hash());
|
|
}
|
|
}
|