diff --git a/CHANGELOG.md b/CHANGELOG.md index e7eb41f1..15656a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,19 @@ ### 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. 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 admission checks each arm against them. The effect-package shape exists only 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..f1f402d6 --- /dev/null +++ b/crates/warp-core/src/content_address_v1.rs @@ -0,0 +1,162 @@ +// 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 { + // 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); + 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 3072660a..619bc44a 100644 --- a/crates/warp-core/src/edict_effects.rs +++ b/crates/warp-core/src/edict_effects.rs @@ -27,7 +27,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..105e2327 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,24 @@ 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 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, + atom.type_id, + &atom.bytes, + &edges, + ) + .map_err(|_| bad())?; + if id != node.local_id { return Err(bad()); } } @@ -358,19 +364,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 +482,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..cd2afb70 --- /dev/null +++ b/crates/warp-core/tests/common/content_address_preimage.rs @@ -0,0 +1,155 @@ +// 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 { + use std::fmt::Write as _; + bytes.iter().fold(String::new(), |mut out, b| { + let _ = write!(out, "{b:02x}"); + out + }) +} + +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..c7a8fa38 --- /dev/null +++ b/crates/warp-core/tests/content_address_v1_tests.rs @@ -0,0 +1,312 @@ +// 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}; + +/// 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]; +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: [NodeCase<'_>; 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); + } +} + +#[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), + }) + ); + } +} 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..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") }; @@ -995,3 +999,194 @@ 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); +} +// 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 { diff --git a/docs/architecture/authored-operation-effects.md b/docs/architecture/authored-operation-effects.md index a79c07e0..106fdfd6 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