From 8c89b01dac5e32cf6f4f332e50228e407311128e Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 21 Oct 2026 02:57:58 -0700 Subject: [PATCH 1/4] refactor(warp-core): implement ContentAddressV1 in one shared module warp_core::content_address_v1 now holds identity law 1: attachment digests, content node ids, content edge ids and slot edge ids, with every domain byte, field order, width and endianness documented. Effect admission and read evidence use it, replacing derivation's inline hashing and edict_effects::value_digest. Echo's pure identity intrinsics will call the same module. A content node's outgoing edges are a set: a duplicate (edge type, target) would share one content edge id, which admission already refuses, so content_node_id refuses the duplicate instead of hashing or dropping it. The law carries no edge-count ceiling. Identities are unchanged. A regression test admits a content node id and a slot edge id captured from the pre-extraction runtime. Independent vectors assemble each preimage field by field without the module, freeze the results as hex, and pair each vector with mutation postcards (missing terminators, swapped type roles, big-endian framing, unsorted edges, another warp, dropped or retargeted edges, duplicates) whose outcome the law requires. Test-side diagnostics explain a mismatch field by field and show raw atom bytes only on request. Admission refuses an id computed from another atom or edge set. --- CHANGELOG.md | 9 + crates/warp-core/src/content_address_v1.rs | 153 ++++++++++ crates/warp-core/src/edict_effects.rs | 1 - .../src/edict_effects/derive/checks.rs | 47 ++- .../src/edict_effects/read_image/encode.rs | 8 +- .../src/edict_effects/value_digest.rs | 12 - crates/warp-core/src/lib.rs | 2 + .../tests/common/content_address_preimage.rs | 151 ++++++++++ .../tests/content_address_v1_tests.rs | 284 ++++++++++++++++++ .../edict_effect_package_tests/derivation.rs | 86 ++++++ .../authored-operation-effects.md | 10 + 11 files changed, 718 insertions(+), 45 deletions(-) create mode 100644 crates/warp-core/src/content_address_v1.rs delete mode 100644 crates/warp-core/src/edict_effects/value_digest.rs create mode 100644 crates/warp-core/tests/common/content_address_preimage.rs create mode 100644 crates/warp-core/tests/content_address_v1_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 25543374..d571235d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,15 @@ ### Changed +- `ContentAddressV1` now lives in one module, `warp_core::content_address_v1`, + with content node ids, content and slot edge ids, and attachment digests. + Effect admission and read evidence use it, replacing the inline hashing in + derivation and `edict_effects::value_digest`. A duplicate outgoing + (edge type, target) is refused rather than hashed. Identities are unchanged: + a regression test admits ids captured before the move, and independent + vectors, frozen hex and mutation postcards check the law without calling + the module. + - Make current effect publication preparation available as a data-only check without trusted-host features, sharing the existing runtime checker while retaining private host helpers and gated authority constructors. diff --git a/crates/warp-core/src/content_address_v1.rs b/crates/warp-core/src/content_address_v1.rs new file mode 100644 index 00000000..ecdea991 --- /dev/null +++ b/crates/warp-core/src/content_address_v1.rs @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! `ContentAddressV1`: the identities Echo admits for authored graph effects. +//! +//! This module is the single implementation of identity law `1`. Effect +//! admission recomputes every id a proposal creates with it, and authored +//! programs will compute the same ids through Echo's pure identity intrinsics. +//! Every value below is part of the law: changing any of them is a new law. +//! +//! Every hash is BLAKE3 over the listed fields, concatenated with no separators. +//! Ids, warps and type ids are their 32 raw bytes; lengths and counts are +//! unsigned 64-bit little-endian. +//! +//! - **Attachment digest** of an atom: [`ATTACHMENT_VALUE_DOMAIN`] (with its NUL +//! terminator), the tag byte [`ATOM_ATTACHMENT_TAG`], the atom type id, the +//! atom's byte length, then the atom bytes. +//! - **Content node id:** [`NODE_ID_DOMAIN`], the warp id, the node type id, the +//! attachment digest of its atom, the number of outgoing edges, then for each +//! outgoing edge in ascending byte order of (edge type id, target node id) +//! the edge type id followed by the target node id. +//! - **Content edge id** (an edge whose source is a content node): +//! [`CONTENT_EDGE_ID_DOMAIN`], the warp id, the source node id, the edge type +//! id, the target node id. +//! - **Slot edge id** (an edge whose source is a mutable node): +//! [`SLOT_EDGE_ID_DOMAIN`], the warp id, the source node id, the edge type id. +//! The target is not part of it, so retargeting the slot keeps its id. +//! +//! **Duplicate outgoing edges.** A content node's outgoing edges are a set: +//! two edges with the same (edge type, target) from one content node would +//! have the same content edge id, which admission refuses as a duplicate +//! effect target. [`content_node_id`] therefore refuses a duplicate tuple +//! instead of hashing or silently dropping it, so it never names an edge set +//! admission could not accept. One edge type to several targets, or several +//! edge types to one target, are distinct edges. +//! +//! The law has no edge-count ceiling. Authoring interfaces may impose their +//! own; this module does not. +use crate::{EdgeId, Hash, NodeId, TypeId, WarpId}; + +/// Domain of an attachment value digest. +pub const ATTACHMENT_VALUE_DOMAIN: &[u8] = b"echo.bridge.attachment-value.v1\0"; +/// Attachment tag of an atom value. +pub const ATOM_ATTACHMENT_TAG: u8 = 0; +/// Attachment tag of a descended-warp value, used by read evidence. +pub const DESCEND_ATTACHMENT_TAG: u8 = 1; +/// Domain of a content node id. +pub const NODE_ID_DOMAIN: &[u8] = b"echo.bridge.node-id.v1\0"; +/// Domain of an edge id whose source is a content node. +pub const CONTENT_EDGE_ID_DOMAIN: &[u8] = b"echo.bridge.edge-id.v1\0"; +/// Domain of an edge id whose source is a mutable node. +pub const SLOT_EDGE_ID_DOMAIN: &[u8] = b"echo.bridge.slot-edge-id.v1\0"; + +/// One outgoing edge of a content node, as it enters the node's identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OutgoingEdge { + /// The edge's type id. + pub edge_type: TypeId, + /// The edge's target node id. + pub to: NodeId, +} + +/// Inputs that name no content node admission could accept. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContentAddressError { + /// The same (edge type, target) appears more than once. + DuplicateOutgoingEdge { + /// The repeated edge type id. + edge_type: TypeId, + /// The repeated target node id. + to: NodeId, + }, +} + +/// The attachment value digest of an atom. +#[must_use] +pub fn attachment_digest(atom_type: TypeId, atom_bytes: &[u8]) -> Hash { + let mut hash = blake3::Hasher::new(); + hash.update(ATTACHMENT_VALUE_DOMAIN); + hash.update(&[ATOM_ATTACHMENT_TAG]); + hash.update(&atom_type.0); + hash.update(&(atom_bytes.len() as u64).to_le_bytes()); + hash.update(atom_bytes); + hash.finalize().into() +} + +/// The attachment value digest of a descended warp, used by read evidence. +#[must_use] +pub fn descend_attachment_digest(warp: WarpId) -> Hash { + let mut hash = blake3::Hasher::new(); + hash.update(ATTACHMENT_VALUE_DOMAIN); + hash.update(&[DESCEND_ATTACHMENT_TAG]); + hash.update(&warp.0); + hash.finalize().into() +} + +/// The content address of a node with this atom and complete outgoing edge +/// set, in any order. +/// +/// # Errors +/// +/// [`ContentAddressError::DuplicateOutgoingEdge`] when two edges share an +/// (edge type, target). +pub fn content_node_id( + warp: WarpId, + node_type: TypeId, + atom_type: TypeId, + atom_bytes: &[u8], + outgoing: &[OutgoingEdge], +) -> Result { + let mut edges = outgoing.to_vec(); + edges.sort_unstable(); + if let Some(pair) = edges.windows(2).find(|pair| pair[0] == pair[1]) { + return Err(ContentAddressError::DuplicateOutgoingEdge { + edge_type: pair[0].edge_type, + to: pair[0].to, + }); + } + let mut hash = blake3::Hasher::new(); + hash.update(NODE_ID_DOMAIN); + hash.update(&warp.0); + hash.update(&node_type.0); + hash.update(&attachment_digest(atom_type, atom_bytes)); + hash.update(&(edges.len() as u64).to_le_bytes()); + for edge in edges { + hash.update(&edge.edge_type.0); + hash.update(&edge.to.0); + } + Ok(NodeId(hash.finalize().into())) +} + +/// The id of an edge whose source is a content node. +#[must_use] +pub fn content_edge_id(warp: WarpId, from: NodeId, edge_type: TypeId, to: NodeId) -> EdgeId { + let mut hash = blake3::Hasher::new(); + hash.update(CONTENT_EDGE_ID_DOMAIN); + hash.update(&warp.0); + hash.update(&from.0); + hash.update(&edge_type.0); + hash.update(&to.0); + EdgeId(hash.finalize().into()) +} + +/// The id of an edge whose source is a mutable node; it names the slot, not +/// the target. +#[must_use] +pub fn slot_edge_id(warp: WarpId, from: NodeId, edge_type: TypeId) -> EdgeId { + let mut hash = blake3::Hasher::new(); + hash.update(SLOT_EDGE_ID_DOMAIN); + hash.update(&warp.0); + hash.update(&from.0); + hash.update(&edge_type.0); + EdgeId(hash.finalize().into()) +} diff --git a/crates/warp-core/src/edict_effects.rs b/crates/warp-core/src/edict_effects.rs index d9ef1393..1826fac0 100644 --- a/crates/warp-core/src/edict_effects.rs +++ b/crates/warp-core/src/edict_effects.rs @@ -26,7 +26,6 @@ pub use package::{EffectPackageError, EffectProgram}; mod completion; #[cfg(feature = "native_rule_bootstrap")] pub(crate) mod derive; -mod value_digest; #[cfg(feature = "native_rule_bootstrap")] pub use derive::{DerivedEffectPatch, EffectDerivationError, EffectDerivationKind}; mod frame; diff --git a/crates/warp-core/src/edict_effects/derive/checks.rs b/crates/warp-core/src/edict_effects/derive/checks.rs index de9a219b..e3559963 100644 --- a/crates/warp-core/src/edict_effects/derive/checks.rs +++ b/crates/warp-core/src/edict_effects/derive/checks.rs @@ -8,6 +8,7 @@ use super::{ EffectDerivationKind, Hash, NodeId, NodeKey, NodeRecord, OutgoingEdgeRange, SlotId, SlotReading, TypeId, WarpId, }; +use crate::content_address_v1; pub(super) struct Resolved<'a> { effect: &'a M, node: Option, @@ -291,19 +292,22 @@ pub(super) fn identities( if schema.node_addressing(*ty) != Some(Addressing::Content) { return Err(bad()); } - let mut edges = edges_for(node, effects); - edges.sort_by_key(|e| (e.ty, e.to)); - let mut hash = blake3::Hasher::new(); - hash.update(b"echo.bridge.node-id.v1\0"); - hash.update(&node.warp_id.0); - hash.update(&ty.0); - hash.update(&super::super::value_digest::atom(atom)); - hash.update(&(edges.len() as u64).to_le_bytes()); - for edge in edges { - hash.update(&edge.ty.0); - hash.update(&edge.to.0); - } - if hash.finalize().as_bytes() != &node.local_id.0 { + let edges = edges_for(node, effects) + .iter() + .map(|e| content_address_v1::OutgoingEdge { + edge_type: e.ty, + to: e.to, + }) + .collect::>(); + let id = content_address_v1::content_node_id( + node.warp_id, + *ty, + atom.type_id, + &atom.bytes, + &edges, + ) + .map_err(|_| bad())?; + if id != node.local_id { return Err(bad()); } } @@ -358,19 +362,10 @@ pub(super) fn identities( Ok(()) } fn edge_id(warp: WarpId, from: NodeId, ty: TypeId, to: NodeId, kind: Addressing) -> Hash { - let mut hash = blake3::Hasher::new(); - hash.update(if kind == Addressing::Content { - b"echo.bridge.edge-id.v1\0" - } else { - b"echo.bridge.slot-edge-id.v1\0" - }); - hash.update(&warp.0); - hash.update(&from.0); - hash.update(&ty.0); - if kind == Addressing::Content { - hash.update(&to.0); + match kind { + Addressing::Content => content_address_v1::content_edge_id(warp, from, ty, to).0, + Addressing::Mutable => content_address_v1::slot_edge_id(warp, from, ty).0, } - hash.finalize().into() } pub(super) fn writes(resolved: &[Resolved<'_>]) -> BTreeSet { let mut slots = BTreeSet::new(); @@ -485,7 +480,7 @@ pub(super) fn preconditions( { Some(EffectDerivationKind::ContentIdentityConflict) } - M::ReplaceAtom { expected, .. } if !matches!(&r.atom,Some(AttachmentValue::Atom(atom)) if super::super::value_digest::atom(atom)==*expected) => { + M::ReplaceAtom { expected, .. } if !matches!(&r.atom,Some(AttachmentValue::Atom(atom)) if content_address_v1::attachment_digest(atom.type_id, &atom.bytes)==*expected) => { Some(EffectDerivationKind::PreconditionMismatch) } M::EnsureEdge { .. } if r.edge.is_some() && !r.noop => { diff --git a/crates/warp-core/src/edict_effects/read_image/encode.rs b/crates/warp-core/src/edict_effects/read_image/encode.rs index e21528f9..9ce6ddc8 100644 --- a/crates/warp-core/src/edict_effects/read_image/encode.rs +++ b/crates/warp-core/src/edict_effects/read_image/encode.rs @@ -132,7 +132,7 @@ fn prepare<'a>( (SlotId::Attachment(_), SlotReading::Attachment(Some(AttachmentValue::Atom(atom)))) if atom.type_id == ty => { - row.subject = super::super::value_digest::atom(atom); + row.subject = crate::content_address_v1::attachment_digest(atom.type_id, &atom.bytes); } ( SlotId::Attachment(key), @@ -147,11 +147,7 @@ fn prepare<'a>( return Err(Error::InconsistentObservation); } row.outcome = 3; - let mut hash = blake3::Hasher::new(); - hash.update(b"echo.bridge.attachment-value.v1\0"); - hash.update(&[1]); - hash.update(&warp.0); - row.subject = hash.finalize().into(); + row.subject = crate::content_address_v1::descend_attachment_digest(*warp); } (SlotId::OutgoingEdges(range), SlotReading::OutgoingEdges(edges)) if ty.0 == [0; 32] => { let owner = source.readings.get(&SlotId::Node(range.source)); diff --git a/crates/warp-core/src/edict_effects/value_digest.rs b/crates/warp-core/src/edict_effects/value_digest.rs deleted file mode 100644 index 5a77cd6a..00000000 --- a/crates/warp-core/src/edict_effects/value_digest.rs +++ /dev/null @@ -1,12 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// © James Ross Ω FLYING•ROBOTS -//! One bridge atom-value digest recipe for read evidence and mutation comparisons. -pub(super) fn atom(atom: &crate::AtomPayload) -> crate::Hash { - let mut hash = blake3::Hasher::new(); - hash.update(b"echo.bridge.attachment-value.v1\0"); - hash.update(&[0]); - hash.update(&atom.type_id.0); - hash.update(&(atom.bytes.len() as u64).to_le_bytes()); - hash.update(&atom.bytes); - hash.finalize().into() -} diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index efcf1056..d716582f 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -39,6 +39,8 @@ pub mod math { /// WSC (Write-Streaming Columnar) snapshot format for deterministic serialization. pub mod wsc; +pub mod content_address_v1; + mod actual_footprint; mod admission; #[cfg(feature = "application_root_shadow")] diff --git a/crates/warp-core/tests/common/content_address_preimage.rs b/crates/warp-core/tests/common/content_address_preimage.rs new file mode 100644 index 00000000..de750f11 --- /dev/null +++ b/crates/warp-core/tests/common/content_address_preimage.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Test-side ContentAddressV1 preimages, assembled field by field without the +//! production module, with readable diagnostics when an identity disagrees. +//! +//! Diagnostics show field names, lengths, type ids and digests. Raw atom bytes +//! appear only when a test explicitly opts in with [`Preimage::show_atom`]. +#![allow(dead_code)] + +/// One labelled field of a preimage. +pub struct Field { + pub name: &'static str, + pub bytes: Vec, + /// Atom contents are summarized unless the preimage opts in. + pub atom: bool, +} + +/// A labelled preimage whose BLAKE3 hash is an identity. +pub struct Preimage { + pub fields: Vec, + show_atom: bool, +} + +pub fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +impl Preimage { + pub fn new() -> Self { + Self { + fields: Vec::new(), + show_atom: false, + } + } + pub fn field(mut self, name: &'static str, bytes: impl Into>) -> Self { + self.fields.push(Field { + name, + bytes: bytes.into(), + atom: false, + }); + self + } + fn atom_field(mut self, name: &'static str, bytes: &[u8]) -> Self { + self.fields.push(Field { + name, + bytes: bytes.to_vec(), + atom: true, + }); + self + } + /// Opt in to raw atom bytes in diagnostics. + #[must_use] + pub fn show_atom(mut self) -> Self { + self.show_atom = true; + self + } + pub fn bytes(&self) -> Vec { + self.fields.iter().flat_map(|f| f.bytes.clone()).collect() + } + pub fn hash(&self) -> [u8; 32] { + blake3::hash(&self.bytes()).into() + } + /// `domain | warp | ...` with each field's length and value or digest. + pub fn describe(&self) -> String { + self.fields + .iter() + .map(|f| { + let value = if f.atom && !self.show_atom { + format!("blake3 {}", hex(blake3::hash(&f.bytes).as_bytes())) + } else if f.name.contains("domain") { + format!("{:?}", String::from_utf8_lossy(&f.bytes)) + } else { + hex(&f.bytes) + }; + format!("{} [{} bytes] {}", f.name, f.bytes.len(), value) + }) + .collect::>() + .join(" | ") + } + /// Panics with a field-by-field explanation unless `actual` is this + /// preimage's hash. + pub fn assert_identity(&self, actual: [u8; 32], what: &str) { + assert!( + actual == self.hash(), + "{what}: identity {} does not match the expected preimage {}\n {}", + hex(&actual), + hex(&self.hash()), + self.describe() + ); + } +} + +/// `echo.bridge.attachment-value.v1\0`, atom tag 0, atom type, length u64 LE, bytes. +pub fn attachment(atom_type: [u8; 32], bytes: &[u8]) -> Preimage { + Preimage::new() + .field( + "attachment domain", + b"echo.bridge.attachment-value.v1\0".to_vec(), + ) + .field("atom tag", vec![0]) + .field("atom type", atom_type.to_vec()) + .field( + "atom length (u64 LE)", + (bytes.len() as u64).to_le_bytes().to_vec(), + ) + .atom_field("atom bytes", bytes) +} + +/// Edges are listed as given; callers sort by (edge type, to) to match the law. +pub fn node( + warp: [u8; 32], + node_type: [u8; 32], + attachment_digest: [u8; 32], + edges: &[([u8; 32], [u8; 32])], +) -> Preimage { + let mut preimage = Preimage::new() + .field("node domain", b"echo.bridge.node-id.v1\0".to_vec()) + .field("warp", warp.to_vec()) + .field("node type", node_type.to_vec()) + .field("attachment digest", attachment_digest.to_vec()) + .field( + "edge count (u64 LE)", + (edges.len() as u64).to_le_bytes().to_vec(), + ); + for (edge_type, to) in edges { + preimage = preimage + .field("edge type", edge_type.to_vec()) + .field("edge to", to.to_vec()); + } + preimage +} + +pub fn content_edge(warp: [u8; 32], from: [u8; 32], edge_type: [u8; 32], to: [u8; 32]) -> Preimage { + Preimage::new() + .field("content edge domain", b"echo.bridge.edge-id.v1\0".to_vec()) + .field("warp", warp.to_vec()) + .field("from", from.to_vec()) + .field("edge type", edge_type.to_vec()) + .field("to", to.to_vec()) +} + +pub fn slot_edge(warp: [u8; 32], from: [u8; 32], edge_type: [u8; 32]) -> Preimage { + Preimage::new() + .field( + "slot edge domain", + b"echo.bridge.slot-edge-id.v1\0".to_vec(), + ) + .field("warp", warp.to_vec()) + .field("from", from.to_vec()) + .field("edge type", edge_type.to_vec()) +} diff --git a/crates/warp-core/tests/content_address_v1_tests.rs b/crates/warp-core/tests/content_address_v1_tests.rs new file mode 100644 index 00000000..d8c50106 --- /dev/null +++ b/crates/warp-core/tests/content_address_v1_tests.rs @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! ContentAddressV1 conformance: independent vectors and mutation postcards. +//! +//! Expected identities come from preimages assembled field by field in +//! `common/content_address_preimage.rs`, never from the production module, and +//! the main vectors are also frozen as hex. Each postcard is a deliberate +//! mutation of a vector with the outcome the law requires: the same identity, +//! a different identity, or a refusal. +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +#[path = "common/content_address_preimage.rs"] +mod preimage; + +use preimage::{attachment, content_edge, hex, node, slot_edge, Preimage}; +use warp_core::content_address_v1::{ + attachment_digest, content_edge_id, content_node_id, slot_edge_id, ContentAddressError, + OutgoingEdge, +}; +use warp_core::{NodeId, TypeId, WarpId}; + +const WARP: [u8; 32] = [3; 32]; +const NODE_TYPE: [u8; 32] = [7; 32]; +const ATOM_TYPE: [u8; 32] = [2; 32]; +const EDGE_A: [u8; 32] = [6; 32]; +const EDGE_B: [u8; 32] = [8; 32]; +const TO_LOW: [u8; 32] = [4; 32]; +const TO_HIGH: [u8; 32] = [5; 32]; + +fn edge(edge_type: [u8; 32], to: [u8; 32]) -> OutgoingEdge { + OutgoingEdge { + edge_type: TypeId(edge_type), + to: NodeId(to), + } +} + +fn node_id(atom: &[u8], edges: &[OutgoingEdge]) -> Result<[u8; 32], ContentAddressError> { + content_node_id( + WarpId(WARP), + TypeId(NODE_TYPE), + TypeId(ATOM_TYPE), + atom, + edges, + ) + .map(|id| id.0) +} + +fn expected_node(atom: &[u8], sorted_edges: &[([u8; 32], [u8; 32])]) -> Preimage { + node( + WARP, + NODE_TYPE, + attachment(ATOM_TYPE, atom).hash(), + sorted_edges, + ) +} + +fn unhex(text: &str) -> [u8; 32] { + let mut out = [0; 32]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).expect("hex"); + } + out +} + +// Frozen vectors, derived from the field-by-field preimages above. The +// zero-edge node and the slot edge equal identities Echo admitted before the law +// moved into its own module (see the derivation regression test). +const ATTACHMENT_NEW: &str = "a658effc54ff1c915e9544c327aafaeb65d00a83e99d0c0c9848f3c2aa3bbafc"; +const NODE_ZERO_EDGES: &str = "127c0d47351ee88620bec24603a1b8949610d792f06f913227308ee5c7e47f90"; +const NODE_ONE_EDGE: &str = "322afec5e32a48d3ab373c0b7efbf614bda7cf391d01b726a72c64c753fc5e10"; +const NODE_TWO_EDGES: &str = "b64b6a4157a7a819c217a9e39a927ce71d13aeeb6d161b4ad266e3489036c6af"; +const CONTENT_EDGE: &str = "76c81ad538b8c6e18a6a93039137a13ad7dc6608d6ba3ce895634fc004787117"; +const SLOT_EDGE: &str = "bd913d145bb0da61dc572cf56a5a4abd442bf295b7020c32aa1de85a3dd70aa2"; + +#[test] +fn attachment_digest_matches_its_independent_preimage_and_frozen_vector() { + let expected = attachment(ATOM_TYPE, b"new"); + expected.assert_identity(attachment_digest(TypeId(ATOM_TYPE), b"new"), "attachment"); + assert_eq!(hex(&expected.hash()), ATTACHMENT_NEW); + // The empty atom still frames its zero length. + attachment(ATOM_TYPE, b"") + .assert_identity(attachment_digest(TypeId(ATOM_TYPE), b""), "empty atom"); +} + +#[test] +fn node_ids_with_zero_one_and_two_edges_match_independent_preimages() { + let cases: [(&[OutgoingEdge], &[([u8; 32], [u8; 32])], &str); 3] = [ + (&[], &[], NODE_ZERO_EDGES), + ( + &[edge(EDGE_A, TO_HIGH)], + &[(EDGE_A, TO_HIGH)], + NODE_ONE_EDGE, + ), + ( + &[edge(EDGE_B, TO_LOW), edge(EDGE_A, TO_HIGH)], + &[(EDGE_A, TO_HIGH), (EDGE_B, TO_LOW)], + NODE_TWO_EDGES, + ), + ]; + for (edges, sorted, frozen) in cases { + let expected = expected_node(b"new", sorted); + expected.assert_identity(node_id(b"new", edges).expect("node"), "node"); + assert_eq!(hex(&expected.hash()), frozen, "{}", expected.describe()); + } +} + +#[test] +fn edge_ids_match_independent_preimages_and_frozen_vectors() { + let content = content_edge(WARP, TO_LOW, EDGE_A, TO_HIGH); + content.assert_identity( + content_edge_id( + WarpId(WARP), + NodeId(TO_LOW), + TypeId(EDGE_A), + NodeId(TO_HIGH), + ) + .0, + "content edge", + ); + assert_eq!(hex(&content.hash()), CONTENT_EDGE); + let slot = slot_edge(WARP, TO_LOW, EDGE_A); + slot.assert_identity( + slot_edge_id(WarpId(WARP), NodeId(TO_LOW), TypeId(EDGE_A)).0, + "slot edge", + ); + assert_eq!(hex(&slot.hash()), SLOT_EDGE); +} + +#[test] +fn retargeting_a_slot_keeps_its_id_while_a_content_edge_id_follows_its_target() { + let slot = |to: [u8; 32]| { + let _ = to; // A slot edge's id never reads its target. + slot_edge_id(WarpId(WARP), NodeId(TO_LOW), TypeId(EDGE_A)) + }; + assert_eq!(slot(TO_HIGH), slot([9; 32])); + let content = |to| content_edge_id(WarpId(WARP), NodeId(TO_LOW), TypeId(EDGE_A), NodeId(to)); + assert_ne!(content(TO_HIGH), content([9; 32])); + // The two laws never coincide for the same source and type. + assert_ne!(slot(TO_HIGH).0, content(TO_HIGH).0); +} + +/// Postcards for one node vector: each mutation and the outcome it must have. +#[test] +fn node_identity_postcards() { + let edges = [edge(EDGE_A, TO_HIGH), edge(EDGE_B, TO_LOW)]; + let sorted = [(EDGE_A, TO_HIGH), (EDGE_B, TO_LOW)]; + let base = node_id(b"new", &edges).expect("base"); + expected_node(b"new", &sorted).assert_identity(base, "base"); + + // Same identity: edges are canonicalized, so input order does not matter. + assert_eq!( + node_id(b"new", &[edge(EDGE_B, TO_LOW), edge(EDGE_A, TO_HIGH)]).expect("reordered"), + base, + "reordering outgoing edges before canonicalization keeps the identity" + ); + + // Different identity: each preimage mutation below must not reproduce it. + let mut no_terminator = expected_node(b"new", &sorted); + no_terminator.fields[0].bytes = b"echo.bridge.node-id.v1".to_vec(); + let mut big_endian_count = expected_node(b"new", &sorted); + big_endian_count.fields[4].bytes = 2u64.to_be_bytes().to_vec(); + let unsorted = expected_node(b"new", &[(EDGE_B, TO_LOW), (EDGE_A, TO_HIGH)]); + let mut attachment_no_terminator = attachment(ATOM_TYPE, b"new"); + attachment_no_terminator.fields[0].bytes = b"echo.bridge.attachment-value.v1".to_vec(); + let mut attachment_descend_tag = attachment(ATOM_TYPE, b"new"); + attachment_descend_tag.fields[1].bytes = vec![1]; + let mut attachment_big_endian = attachment(ATOM_TYPE, b"new"); + attachment_big_endian.fields[3].bytes = 3u64.to_be_bytes().to_vec(); + let mutated = [ + ( + "node domain without its NUL terminator", + no_terminator.hash(), + ), + ("edge count big-endian", big_endian_count.hash()), + ("edges hashed in input order", unsorted.hash()), + ( + "attachment domain without its NUL terminator", + node(WARP, NODE_TYPE, attachment_no_terminator.hash(), &sorted).hash(), + ), + ( + "attachment tag 1 (descend) instead of 0 (atom)", + node(WARP, NODE_TYPE, attachment_descend_tag.hash(), &sorted).hash(), + ), + ( + "atom length big-endian", + node(WARP, NODE_TYPE, attachment_big_endian.hash(), &sorted).hash(), + ), + ( + "node type and atom type swapped", + content_node_id( + WarpId(WARP), + TypeId(ATOM_TYPE), + TypeId(NODE_TYPE), + b"new", + &edges, + ) + .expect("swapped") + .0, + ), + ( + "another warp", + content_node_id( + WarpId([9; 32]), + TypeId(NODE_TYPE), + TypeId(ATOM_TYPE), + b"new", + &edges, + ) + .expect("warp") + .0, + ), + ( + "one more atom byte", + node_id(b"new\0", &edges).expect("longer"), + ), + ( + "an edge dropped", + node_id(b"new", &edges[..1]).expect("fewer"), + ), + ( + "an edge retargeted", + node_id(b"new", &[edge(EDGE_A, [9; 32]), edge(EDGE_B, TO_LOW)]).expect("retarget"), + ), + ( + "edge type and target roles swapped", + node_id(b"new", &[edge(TO_HIGH, EDGE_A), edge(EDGE_B, TO_LOW)]).expect("roles"), + ), + ]; + for (mutation, id) in mutated { + assert_ne!(id, base, "{mutation} must change the identity"); + } + + // Refused: the same (edge type, to) twice. Admission can never accept a + // content node with a duplicate outgoing tuple, so the constructor refuses + // instead of deduplicating into an identity that names another edge set. + assert_eq!( + node_id(b"new", &[edge(EDGE_A, TO_HIGH), edge(EDGE_A, TO_HIGH)]), + Err(ContentAddressError::DuplicateOutgoingEdge { + edge_type: TypeId(EDGE_A), + to: NodeId(TO_HIGH), + }) + ); + // Not duplicates: one type to two targets, or two types to one target. + assert!(node_id(b"new", &[edge(EDGE_A, TO_HIGH), edge(EDGE_A, TO_LOW)]).is_ok()); + assert!(node_id(b"new", &[edge(EDGE_A, TO_HIGH), edge(EDGE_B, TO_HIGH)]).is_ok()); +} + +#[test] +fn diagnostics_name_fields_and_hide_atom_bytes_unless_asked() { + let secret = b"private document text"; + let summary = attachment(ATOM_TYPE, secret).describe(); + assert!(summary.contains("atom bytes [21 bytes] blake3 ")); + assert!(!summary.contains(&hex(secret))); + assert!(summary.contains("atom type [32 bytes] 0202")); + let shown = attachment(ATOM_TYPE, secret).show_atom().describe(); + assert!(shown.contains(&hex(secret))); + let report = std::panic::catch_unwind(|| { + expected_node(secret, &[]).assert_identity([0; 32], "deliberate mismatch"); + }) + .expect_err("mismatch panics"); + let message = report.downcast_ref::().expect("message"); + for name in [ + "node domain", + "warp", + "node type", + "attachment digest", + "edge count (u64 LE)", + ] { + assert!(message.contains(name), "{name} in {message}"); + } +} + +#[test] +fn frozen_vectors_are_well_formed() { + for frozen in [ + ATTACHMENT_NEW, + NODE_ZERO_EDGES, + NODE_ONE_EDGE, + NODE_TWO_EDGES, + CONTENT_EDGE, + SLOT_EDGE, + ] { + assert_eq!(hex(&unhex(frozen)), frozen); + } +} diff --git a/crates/warp-core/tests/edict_effect_package_tests/derivation.rs b/crates/warp-core/tests/edict_effect_package_tests/derivation.rs index 64f6414b..184bfeea 100644 --- a/crates/warp-core/tests/edict_effect_package_tests/derivation.rs +++ b/crates/warp-core/tests/edict_effect_package_tests/derivation.rs @@ -995,3 +995,89 @@ fn competing_mutation_failures_follow_the_validation_phase_order() { assert_eq!(error.kind(), want, "{name}: {input_field}"); } } +// Identities admission accepted before ContentAddressV1 moved into its own +// module, captured from the pre-extraction runtime. Moving the law must not +// change an identity Echo has already admitted. +const ADMITTED_LEAF_ID: &str = "127c0d47351ee88620bec24603a1b8949610d792f06f913227308ee5c7e47f90"; +const ADMITTED_SLOT_EDGE_ID: &str = + "bd913d145bb0da61dc572cf56a5a4abd442bf295b7020c32aa1de85a3dd70aa2"; +fn hex_id(hex: &str) -> V { + V::Bytes( + (0..32) + .map(|i| u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).expect("hex")) + .collect(), + ) +} +#[test] +fn identities_admitted_before_the_law_moved_are_still_admitted() { + let mut body = input("ensure-node"); + set(&mut body, "nodeId", hex_id(ADMITTED_LEAF_ID)); + derive_case( + "ensure-node", + body, + publication_for("ensure-node"), + false, + false, + false, + ) + .expect("pre-extraction content node id"); + let mut body = input("ensure-edge"); + set(&mut body, "edgeId", hex_id(ADMITTED_SLOT_EDGE_ID)); + derive_case( + "ensure-edge", + body, + publication_for("ensure-edge"), + false, + false, + false, + ) + .expect("pre-extraction slot edge id"); +} +// An id is bound to the exact atom and edge set it was computed from. +// The surrounding package stays valid so refusal comes from the identity law. +#[test] +fn a_node_id_is_refused_when_the_submitted_atom_or_edge_set_differs() { + use warp_core::edict_effects::EffectDerivationKind as K; + for (field, value) in [ + ("atomBytes", V::Bytes(b"newer".to_vec())), + ("atomBytes", V::Bytes(b"ne".to_vec())), + ("atomBytes", V::Bytes(b"nex".to_vec())), + ] { + let mut body = input("ensure-node"); + set(&mut body, field, value); + let error = derive_case( + "ensure-node", + body, + publication_for("ensure-node"), + false, + false, + false, + ) + .expect_err("different atom"); + assert_eq!(error.kind(), K::IdentityLawViolation); + } + // Computed with one outgoing edge, submitted with none. + let with_edge = warp_core::content_address_v1::content_node_id( + warp_core::WarpId([3; 32]), + TypeId([7; 32]), + TypeId([2; 32]), + b"new", + &[warp_core::content_address_v1::OutgoingEdge { + edge_type: TypeId([6; 32]), + to: NodeId([5; 32]), + }], + ) + .expect("one edge"); + let mut body = input("ensure-node"); + set(&mut body, "nodeId", V::Bytes(with_edge.0.to_vec())); + let error = derive_case( + "ensure-node", + body, + publication_for("ensure-node"), + false, + false, + false, + ) + .expect_err("different edge set"); + assert_eq!(error.kind(), K::IdentityLawViolation); +} diff --git a/docs/architecture/authored-operation-effects.md b/docs/architecture/authored-operation-effects.md index 1d838ac8..63b82b57 100644 --- a/docs/architecture/authored-operation-effects.md +++ b/docs/architecture/authored-operation-effects.md @@ -504,6 +504,16 @@ a digest alone could not be executed. One law is defined: reads](#basis-budget-retries)) for every `EnsureEdge` and `ReplaceEdge` from a mutable source. Any other edge of the same type from that source is an alias, and the effect obstructs with `IdentityLawViolation`. + - The law is implemented once, in `warp_core::content_address_v1` + (`crates/warp-core/src/content_address_v1.rs`), which admission uses and + which documents every domain byte, field order, width and endianness. + A content node's outgoing edges are a set: the same edge type and `to` + twice would share one content edge id, which admission refuses as a + duplicate effect target, so `content_node_id` refuses a duplicate tuple + rather than hashing or dropping it. The law has no edge-count ceiling; + authoring interfaces may set their own. Independent vectors, frozen hex + and mutation postcards in `crates/warp-core/tests/content_address_v1_tests.rs` + check it without calling the module. An `EnsureNode` whose content-typed target already exists adopts it only if the existing node's record, atom, and complete outgoing edge set equal From 3b52f3c4a4a1392b82e0fe6a8d3708a3f4ad70f6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 21 Oct 2026 03:04:21 -0700 Subject: [PATCH 2/4] test(warp-core): satisfy clippy in ContentAddressV1 conformance tests --- crates/warp-core/tests/common/content_address_preimage.rs | 6 +++++- crates/warp-core/tests/content_address_v1_tests.rs | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/warp-core/tests/common/content_address_preimage.rs b/crates/warp-core/tests/common/content_address_preimage.rs index de750f11..cd2afb70 100644 --- a/crates/warp-core/tests/common/content_address_preimage.rs +++ b/crates/warp-core/tests/common/content_address_preimage.rs @@ -22,7 +22,11 @@ pub struct Preimage { } pub fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() + use std::fmt::Write as _; + bytes.iter().fold(String::new(), |mut out, b| { + let _ = write!(out, "{b:02x}"); + out + }) } impl Preimage { diff --git a/crates/warp-core/tests/content_address_v1_tests.rs b/crates/warp-core/tests/content_address_v1_tests.rs index d8c50106..d726ea67 100644 --- a/crates/warp-core/tests/content_address_v1_tests.rs +++ b/crates/warp-core/tests/content_address_v1_tests.rs @@ -18,6 +18,9 @@ use warp_core::content_address_v1::{ }; use warp_core::{NodeId, TypeId, WarpId}; +/// Outgoing edges in input order, the same edges sorted, and the frozen id. +type NodeCase<'a> = (&'a [OutgoingEdge], &'a [([u8; 32], [u8; 32])], &'a str); + const WARP: [u8; 32] = [3; 32]; const NODE_TYPE: [u8; 32] = [7; 32]; const ATOM_TYPE: [u8; 32] = [2; 32]; @@ -83,7 +86,7 @@ fn attachment_digest_matches_its_independent_preimage_and_frozen_vector() { #[test] fn node_ids_with_zero_one_and_two_edges_match_independent_preimages() { - let cases: [(&[OutgoingEdge], &[([u8; 32], [u8; 32])], &str); 3] = [ + let cases: [NodeCase<'_>; 3] = [ (&[], &[], NODE_ZERO_EDGES), ( &[edge(EDGE_A, TO_HIGH)], From 355cfa7ef785e40815f1106de6d62d724c22ca8c Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 21 Oct 2026 06:59:12 -0700 Subject: [PATCH 3/4] perf(warp-core): hash ascending outgoing edges without copying them content_node_id copied its edges on every call. A strictly ascending input is already canonical and duplicate-free, so it is now hashed in place; any other order is still copied, sorted and checked for duplicates. Admission sorts the vector it builds, so it never copies. A new test pins the same id across input orders and refusal of duplicates in any order. --- crates/warp-core/src/content_address_v1.rs | 25 +++++++++++++------ .../src/edict_effects/derive/checks.rs | 4 ++- .../tests/content_address_v1_tests.rs | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/crates/warp-core/src/content_address_v1.rs b/crates/warp-core/src/content_address_v1.rs index ecdea991..f1f402d6 100644 --- a/crates/warp-core/src/content_address_v1.rs +++ b/crates/warp-core/src/content_address_v1.rs @@ -107,14 +107,23 @@ pub fn content_node_id( atom_bytes: &[u8], outgoing: &[OutgoingEdge], ) -> Result { - let mut edges = outgoing.to_vec(); - edges.sort_unstable(); - if let Some(pair) = edges.windows(2).find(|pair| pair[0] == pair[1]) { - return Err(ContentAddressError::DuplicateOutgoingEdge { - edge_type: pair[0].edge_type, - to: pair[0].to, - }); - } + // A strictly ascending input is already canonical and duplicate-free, so + // it is hashed in place; only an unordered input is copied and sorted. + let sorted; + let edges: &[OutgoingEdge] = if outgoing.windows(2).all(|pair| pair[0] < pair[1]) { + outgoing + } else { + let mut copy = outgoing.to_vec(); + copy.sort_unstable(); + if let Some(pair) = copy.windows(2).find(|pair| pair[0] == pair[1]) { + return Err(ContentAddressError::DuplicateOutgoingEdge { + edge_type: pair[0].edge_type, + to: pair[0].to, + }); + } + sorted = copy; + &sorted + }; let mut hash = blake3::Hasher::new(); hash.update(NODE_ID_DOMAIN); hash.update(&warp.0); diff --git a/crates/warp-core/src/edict_effects/derive/checks.rs b/crates/warp-core/src/edict_effects/derive/checks.rs index e3559963..105e2327 100644 --- a/crates/warp-core/src/edict_effects/derive/checks.rs +++ b/crates/warp-core/src/edict_effects/derive/checks.rs @@ -292,13 +292,15 @@ pub(super) fn identities( if schema.node_addressing(*ty) != Some(Addressing::Content) { return Err(bad()); } - let edges = edges_for(node, effects) + let mut edges = edges_for(node, effects) .iter() .map(|e| content_address_v1::OutgoingEdge { edge_type: e.ty, to: e.to, }) .collect::>(); + // Sorted here, the law hashes this vector without copying it. + edges.sort_unstable(); let id = content_address_v1::content_node_id( node.warp_id, *ty, diff --git a/crates/warp-core/tests/content_address_v1_tests.rs b/crates/warp-core/tests/content_address_v1_tests.rs index d726ea67..c7a8fa38 100644 --- a/crates/warp-core/tests/content_address_v1_tests.rs +++ b/crates/warp-core/tests/content_address_v1_tests.rs @@ -285,3 +285,28 @@ fn frozen_vectors_are_well_formed() { assert_eq!(hex(&unhex(frozen)), frozen); } } + +#[test] +fn edge_input_order_never_changes_the_id_and_duplicates_refuse_in_any_order() { + // Ascending input is hashed in place; any other order is sorted first. + let a = edge(EDGE_A, TO_LOW); + let b = edge(EDGE_A, TO_HIGH); + let c = edge(EDGE_B, TO_LOW); + let expected = expected_node( + b"new", + &[(EDGE_A, TO_LOW), (EDGE_A, TO_HIGH), (EDGE_B, TO_LOW)], + ) + .hash(); + for order in [[a, b, c], [c, b, a], [b, a, c], [a, c, b]] { + assert_eq!(node_id(b"new", &order).expect("distinct edges"), expected); + } + for duplicated in [vec![a, a], vec![a, a, b], vec![a, b, a], vec![b, a, a]] { + assert_eq!( + node_id(b"new", &duplicated), + Err(ContentAddressError::DuplicateOutgoingEdge { + edge_type: TypeId(EDGE_A), + to: NodeId(TO_LOW), + }) + ); + } +} From 62ec338c22bcea2a5f7abb5829d0432cd641f320 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 21 Oct 2026 06:59:26 -0700 Subject: [PATCH 4/4] test(warp-core): admit a content node with its content edge in one proposal The derivation harness built single-effect programs only, so no test ran an EnsureEdge from a content source through admission. fixture_pair emits two mutations through two exported effects with every pin re-derived. Admission accepts a content node and its edge under ids assembled from their preimages, and refuses a content edge id computed for another target, a slot id from a content source, and a node id that omits an edge the same proposal adds. --- CHANGELOG.md | 6 +- .../edict_effect_package_tests/derivation.rs | 111 ++++++++++- .../edict_effect_package_tests/mutations.rs | 181 ++++++++++++++++++ 3 files changed, 296 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e552a4..15656a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,7 +102,11 @@ (edge type, target) is refused rather than hashed. Identities are unchanged: a regression test admits ids captured before the move, and independent vectors, frozen hex and mutation postcards check the law without calling - the module. + the module. A two-effect proposal, a content node with an edge from it, is + admitted under independently computed ids, and admission refuses a content + edge id computed for another target, a slot id from a content source, and a + node id that omits an edge the same proposal adds. Edges already in + ascending order are hashed without a copy, and admission sorts its own. - An authored effect package no longer carries a single `obstruction_coordinate`. Its embedded exports declare every obstruction the program can return, and diff --git a/crates/warp-core/tests/edict_effect_package_tests/derivation.rs b/crates/warp-core/tests/edict_effect_package_tests/derivation.rs index 184bfeea..5567fc58 100644 --- a/crates/warp-core/tests/edict_effect_package_tests/derivation.rs +++ b/crates/warp-core/tests/edict_effect_package_tests/derivation.rs @@ -12,7 +12,11 @@ fn publication() -> V { publication_for("create-node") } fn publication_for(name: &str) -> V { - let (mut publication, _) = fixture(name, "continuum.profile.graph-effects/v1"); + let (publication, _) = fixture(name, "continuum.profile.graph-effects/v1"); + with_graph_law(publication) +} +/// The graph schema and footprint law every derivation case runs under. +fn with_graph_law(mut publication: V) -> V { let V::Bytes(raw) = get(&publication, "program") else { panic!("program") }; @@ -1081,3 +1085,108 @@ fn a_node_id_is_refused_when_the_submitted_atom_or_edge_set_differs() { .expect_err("different edge set"); assert_eq!(error.kind(), K::IdentityLawViolation); } +// A content node together with an outgoing edge in one proposal. Every id +// below is assembled from its preimage here, never through the production law. +fn content_leaf_with_edge(edge_type: [u8; 32], to: [u8; 32]) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(b"echo.bridge.node-id.v1\0"); + h.update(&[3; 32]); + h.update(&[7; 32]); + h.update(&atom_hash(2, b"new")); + h.update(&1u64.to_le_bytes()); + h.update(&edge_type); + h.update(&to); + h.finalize().into() +} +fn content_edge(from: [u8; 32], to: [u8; 32]) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(b"echo.bridge.edge-id.v1\0"); + h.update(&[3; 32]); + h.update(&from); + h.update(&[6; 32]); + h.update(&to); + h.finalize().into() +} +/// The admitted ids of the leaf with one edge to node 5, and of that edge. +const ADMITTED_LEAF_WITH_EDGE_ID: &str = + "322afec5e32a48d3ab373c0b7efbf614bda7cf391d01b726a72c64c753fc5e10"; +const ADMITTED_CONTENT_EDGE_ID: &str = + "3ff5f345ec93b3eeca43553ab0f02c33c68926cf88e692ef53fa9973ddb04147"; +fn node_and_edge(node_id: [u8; 32], edge_id: [u8; 32], to: [u8; 32]) -> V { + let mut node = input("ensure-node"); + set(&mut node, "nodeId", V::Bytes(node_id.to_vec())); + let edge = map([ + ("warpId", id(3)), + ("edgeId", V::Bytes(edge_id.to_vec())), + ("fromId", V::Bytes(node_id.to_vec())), + ("toId", V::Bytes(to.to_vec())), + ("edgeType", id(6)), + ]); + map([("first", node), ("second", edge)]) +} +fn derive_pair( + body: V, +) -> Result< + warp_core::edict_effects::DerivedEffectPatch, + warp_core::edict_effects::EffectDerivationError, +> { + derive_case( + "ensure-node+ensure-edge", + body, + with_graph_law(super::mutations::fixture_pair( + "ensure-node", + "ensure-edge", + "continuum.profile.graph-effects/v1", + )), + false, + false, + false, + ) +} +#[test] +fn a_content_node_and_its_content_edge_are_admitted_together() { + let leaf = content_leaf_with_edge([6; 32], [5; 32]); + let edge = content_edge(leaf, [5; 32]); + assert_eq!(hex_id(ADMITTED_LEAF_WITH_EDGE_ID), V::Bytes(leaf.to_vec())); + assert_eq!(hex_id(ADMITTED_CONTENT_EDGE_ID), V::Bytes(edge.to_vec())); + let derived = derive_pair(node_and_edge(leaf, edge, [5; 32])).expect("content node and edge"); + let ops = derived.patch().ops(); + assert!(ops + .iter() + .any(|op| matches!(op, WarpOp::UpsertNode { node, record } + if node.local_id.0 == leaf && record.ty == TypeId([7; 32])))); + assert!(ops + .iter() + .any(|op| matches!(op, WarpOp::UpsertEdge { warp_id, record } + if warp_id.0 == [3; 32] && record.id.0 == edge && record.from.0 == leaf + && record.to.0 == [5; 32] && record.ty.0 == [6; 32]))); +} +#[test] +fn a_content_edge_id_computed_for_another_target_is_refused() { + use warp_core::edict_effects::EffectDerivationKind as K; + let leaf = content_leaf_with_edge([6; 32], [5; 32]); + let error = derive_pair(node_and_edge(leaf, content_edge(leaf, [4; 32]), [5; 32])) + .expect_err("edge id for node 4, submitted toward node 5"); + assert_eq!(error.kind(), K::IdentityLawViolation); + // The slot form of the same edge is wrong for a content source too. + let mut h = blake3::Hasher::new(); + h.update(b"echo.bridge.slot-edge-id.v1\0"); + h.update(&[3; 32]); + h.update(&leaf); + h.update(&[6; 32]); + let slot: [u8; 32] = h.finalize().into(); + let error = derive_pair(node_and_edge(leaf, slot, [5; 32])).expect_err("slot id from content"); + assert_eq!(error.kind(), K::IdentityLawViolation); +} +#[test] +fn a_node_id_that_omits_an_edge_the_proposal_adds_is_refused() { + use warp_core::edict_effects::EffectDerivationKind as K; + let without_edge = leaf_id(); + let error = derive_pair(node_and_edge( + without_edge, + content_edge(without_edge, [5; 32]), + [5; 32], + )) + .expect_err("node id computed without the proposal's edge"); + assert_eq!(error.kind(), K::IdentityLawViolation); +} diff --git a/crates/warp-core/tests/edict_effect_package_tests/mutations.rs b/crates/warp-core/tests/edict_effect_package_tests/mutations.rs index dcd12200..61179046 100644 --- a/crates/warp-core/tests/edict_effect_package_tests/mutations.rs +++ b/crates/warp-core/tests/edict_effect_package_tests/mutations.rs @@ -243,6 +243,187 @@ pub(super) fn fixture_with(name: &str, profile: &str, shape: &Shape) -> (V, Vec< ); (publication, encoded(&input)) } +/// A program emitting two mutations in order: `first` from the intent input's +/// `first` field through `jedit.text@1.readFact`, then `second` from its `second` +/// field through a second exported effect, `jedit.text@1.linkFact`. Both use +/// their conforming runtime-ABI shapes; every pin is re-derived. +pub(super) fn fixture_pair(first: &str, second: &str, profile: &str) -> V { + const SECOND: &str = "jedit.text@1.linkFact"; + let artifacts = repinned_lowering_artifacts(); + let decode = |key| { + let V::Bytes(raw) = get(&artifacts, key) else { + panic!("artifact") + }; + echo_edict_canonical::decode_canonical_cbor_v1(raw).expect("artifact") + }; + let shapes = [ + ("FirstInput", Shape::conforming(first)), + ("SecondInput", Shape::conforming(second)), + ]; + let (_, _, mut exports) = compiler_fixture(&artifacts); + let V::Array(effects) = value_field_mut(&mut exports, "effects") else { + panic!("effects") + }; + let mut declared = effects[0].clone(); + set(&mut declared, "outputType", text("MutationUnit")); + set(&mut declared, "effectFailures", map([])); + let mut linked = declared.clone(); + set(&mut declared, "inputType", text(shapes[0].0)); + set(&mut linked, "inputType", text(shapes[1].0)); + set(&mut linked, "coordinate", text(SECOND)); + *effects = vec![declared, linked]; + let mut adapter = decode("adapter_artifact"); + let implementations = value_field_mut(&mut adapter, "effectImplementations"); + let mut read = get(implementations, "jedit.text@1.readFact").clone(); + set(&mut read, "failureMappings", map([])); + let mut link = read.clone(); + for (implementation, (name, shape)) in [ + (&mut read, (first, &shapes[0].1)), + (&mut link, (second, &shapes[1].1)), + ] { + set( + implementation, + "targetIntrinsic", + text(&format!("echo.dpo@1.{name}")), + ); + set(implementation, "writeClass", text(&shape.write_class)); + } + *implementations = map([("jedit.text@1.readFact", read), (SECOND, link)]); + let V::Map(profiles) = value_field_mut(&mut adapter, "operationProfiles") else { + panic!("profiles") + }; + set(&mut profiles[0].1, "core", text(profile)); + let V::Array(authorized) = value_field_mut(&mut profiles[0].1, "semanticEffects") else { + panic!("semantic effects") + }; + authorized.push(text(SECOND)); + let mut catalog = catalog_fixture(); + let template = get(get(&catalog, "intrinsics"), "echo.dpo@1.node-atom-read").clone(); + let mut intrinsics = Vec::new(); + for (name, shape) in [(first, &shapes[0].1), (second, &shapes[1].1)] { + let mut intrinsic = template.clone(); + set( + &mut intrinsic, + "argumentTypes", + V::Array(vec![text(&shape.catalog_argument)]), + ); + set(&mut intrinsic, "returnType", text(&shape.catalog_return)); + set(&mut intrinsic, "effectKind", text(&shape.effect_kind)); + set(&mut intrinsic, "writeClass", text(&shape.write_class)); + set(&mut intrinsic, "effectFailures", map([])); + intrinsics.push((text(&format!("echo.dpo@1.{name}")), intrinsic)); + } + set(&mut catalog, "intrinsics", V::Map(intrinsics)); + let lowering = lowering_fixture_inputs(Some(catalog), Some(exports.clone()), Some(adapter)); + let (mut core, mut target, _) = compiler_fixture(&lowering); + let types = value_field_mut(&mut core, "types"); + for (type_name, shape) in &shapes { + let record = map([ + ("kind", text("Record")), + ( + "fields", + V::Map( + shape + .fields + .iter() + .map(|(k, t)| (text(k), text(t))) + .collect(), + ), + ), + ]); + add(types, type_name, record); + } + add( + types, + "PairInput", + map([ + ("kind", text("Record")), + ( + "fields", + map([("first", text(shapes[0].0)), ("second", text(shapes[1].0))]), + ), + ]), + ); + add(types, "MutationUnit", shapes[0].1.output.clone()); + let arg = map([ + ("id", text("arg.0")), + ("type", text("PairInput")), + ("alphaName", text("$arg0")), + ]); + let locals = [0, 1].map(|index| { + map([ + ("id", text(&format!("local.{index}"))), + ("type", text("MutationUnit")), + ("alphaName", text(&format!("$local{index}"))), + ]) + }); + let argument = |field_name: &str| { + map([ + ("kind", text("field")), + ("base", map([("kind", text("local")), ("ref", arg.clone())])), + ("field", text(field_name)), + ]) + }; + let effects = [ + ("text.readFact", first, "first"), + ("text.linkFact", second, "second"), + ]; + let mut nodes = Vec::new(); + let mut steps = Vec::new(); + for (index, (effect, name, field_name)) in effects.into_iter().enumerate() { + nodes.push(map([ + ("kind", text("effect")), + ("binding", locals[index].clone()), + ("effect", text(effect)), + ("input", argument(field_name)), + ("obstructionMap", map([])), + ])); + steps.push(map([ + ("id", text(&format!("effect.{index}"))), + ("binding", locals[index].clone()), + ("effect", text(effect)), + ("input", argument(field_name)), + ("targetIntrinsic", text(&format!("echo.dpo@1.{name}"))), + ("obstructionArms", map([])), + ("obstructionFailures", V::Array(vec![])), + ])); + } + let result = map([("kind", text("local")), ("ref", locals[1].clone())]); + let intent = value_field_mut(value_field_mut(&mut core, "intents"), "replaceRange"); + set(intent, "input", text("PairInput")); + set(intent, "output", text("MutationUnit")); + set(intent, "requiredOperationProfile", text(profile)); + if let V::Map(fields) = intent { + fields.retain(|(k, _)| k != &text("basis")); + } + set( + intent, + "body", + map([ + ( + "locals", + V::Array(vec![arg, locals[0].clone(), locals[1].clone()]), + ), + ("nodes", V::Array(nodes)), + ("result", result.clone()), + ]), + ); + let selected = selected_target_mut(&mut target); + set(selected, "operationProfile", text(profile)); + if let V::Map(fields) = selected { + fields.retain(|(k, _)| k != &text("basis")); + } + set(selected, "steps", V::Array(steps)); + set(selected, "requirements", V::Array(vec![])); + set(selected, "result", result); + set( + selected, + "executionOrder", + V::Array(vec![text("effect.0"), text("effect.1")]), + ); + repin_core(&core, &mut target); + publication_from_parts(&core, &target, &exports, &lowering) +} #[test] fn all_five_compiled_mutation_emitters_prepare_and_run_without_writing_the_frontier() { for name in NAMES {