diff --git a/Cargo.lock b/Cargo.lock
index c59f330e..4ff7e9fd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5016,6 +5016,7 @@ dependencies = [
  "warp-core",
  "wasm-bindgen",
  "wasm-bindgen-test",
+ "web-sys",
 ]
 
 [[package]]
diff --git a/README.md b/README.md
index 65e3a53a..ed6e98f4 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ Instead of treating a game/simulation as a pile of mutable objects, Echo treats
 
 Start here:
 
+- WARP primer: [`docs/guide/warp-primer.md`](docs/guide/warp-primer.md)
 - Project tour: [`docs/notes/project-tour-2025-12-28.md`](docs/notes/project-tour-2025-12-28.md)
 - AIΩN bridge doc: [`docs/aion-papers-bridge.md`](docs/aion-papers-bridge.md)
 - Architecture outline: [`docs/architecture-outline.md`](docs/architecture-outline.md)
diff --git a/crates/warp-benches/benches/motion_throughput.rs b/crates/warp-benches/benches/motion_throughput.rs
index 34261cbd..cf5856f7 100644
--- a/crates/warp-benches/benches/motion_throughput.rs
+++ b/crates/warp-benches/benches/motion_throughput.rs
@@ -4,10 +4,28 @@
 use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion, Throughput};
 use std::{hint::black_box, time::Duration};
 use warp_core::{
-    decode_motion_payload, encode_motion_payload, make_node_id, make_type_id, ApplyResult, Engine,
-    NodeId, NodeRecord, MOTION_RULE_NAME,
+    decode_motion_atom_payload, encode_motion_atom_payload, make_node_id, make_type_id,
+    ApplyResult, AttachmentValue, Engine, NodeId, NodeRecord, MOTION_RULE_NAME,
 };
 
+fn extract_motion_payload(engine: &Engine, id: &NodeId) -> ([f32; 3], [f32; 3]) {
+    let payload = engine
+        .node_attachment(id)
+        .unwrap_or_else(|err| panic!("node_attachment failed for node {id:?}: {err:?}"))
+        .unwrap_or_else(|| panic!("missing attachment for node {id:?}"));
+    let AttachmentValue::Atom(payload) = payload else {
+        panic!("expected Atom attachment for node {id:?}, found {payload:?}");
+    };
+    decode_motion_atom_payload(payload).unwrap_or_else(|| {
+        panic!(
+            "failed to decode motion atom payload for node {id:?} (type_id={:?}, len={}): expected type_id={:?}",
+            payload.type_id,
+            payload.bytes.len(),
+            warp_core::motion_payload_type_id(),
+        )
+    })
+}
+
 fn build_engine_with_n_entities(n: usize) -> (Engine, Vec<NodeId>) {
     // Start from the demo engine (root + motion rule registered).
     let mut engine = warp_core::build_motion_demo_engine();
@@ -20,14 +38,15 @@ fn build_engine_with_n_entities(n: usize) -> (Engine, Vec<NodeId>) {
         let id = make_node_id(&label);
         let pos = [i as f32, 0.0, 0.0];
         let vel = [1.0, 0.0, 0.0];
-        let payload = encode_motion_payload(pos, vel);
-        engine.insert_node(
-            id,
-            NodeRecord {
-                ty,
-                payload: Some(payload),
-            },
-        );
+        let payload = encode_motion_atom_payload(pos, vel);
+        engine
+            .insert_node(id, NodeRecord { ty })
+            .unwrap_or_else(|err| panic!("insert_node failed for node {id:?}: {err:?}"));
+        engine
+            .set_node_attachment(id, Some(AttachmentValue::Atom(payload)))
+            .unwrap_or_else(|err| {
+                panic!("set_node_attachment failed for node {id:?}: {err:?}");
+            });
         ids.push(id);
     }
     (engine, ids)
@@ -47,9 +66,7 @@ fn bench_motion_apply(c: &mut Criterion) {
             b.iter(|| {
                 let (engine, ids) = build_engine_with_n_entities(n);
                 // Optional quick sanity on the first entity to keep side effects visible.
-                let node = engine.node(&ids[0]).expect("node exists");
-                let decoded =
-                    decode_motion_payload(node.payload.as_ref().expect("payload")).expect("decode");
+                let decoded = extract_motion_payload(&engine, &ids[0]);
                 debug_assert!(decoded.0.iter().all(|v| v.is_finite()));
                 debug_assert!(decoded.1.iter().all(|v| v.is_finite()));
                 black_box(engine);
@@ -81,9 +98,7 @@ fn bench_motion_apply(c: &mut Criterion) {
                     engine.commit(tx).expect("commit");
 
                     // Decode and validate the first entity's payload and black_box the result.
-                    let node = engine.node(&ids[0]).expect("node exists");
-                    let decoded = decode_motion_payload(node.payload.as_ref().expect("payload"))
-                        .expect("decode");
+                    let decoded = extract_motion_payload(&engine, &ids[0]);
                     debug_assert!(decoded.0.iter().all(|v| v.is_finite()));
                     debug_assert!(decoded.1.iter().all(|v| v.is_finite()));
                     black_box(decoded);
diff --git a/crates/warp-benches/benches/scheduler_drain.rs b/crates/warp-benches/benches/scheduler_drain.rs
index f2535dd2..dea2b16b 100644
--- a/crates/warp-benches/benches/scheduler_drain.rs
+++ b/crates/warp-benches/benches/scheduler_drain.rs
@@ -65,7 +65,9 @@ fn build_engine_with_entities(n: usize) -> (Engine, Vec<NodeId>) {
     for i in 0..n {
         let label = format!("{}{}", ENT_LABEL_PREFIX, i);
         let id = make_node_id(&label);
-        engine.insert_node(id, NodeRecord { ty, payload: None });
+        engine
+            .insert_node(id, NodeRecord { ty })
+            .unwrap_or_else(|err| panic!("insert_node failed for node {id:?}: {err:?}"));
         ids.push(id);
     }
     (engine, ids)
diff --git a/crates/warp-benches/benches/snapshot_hash.rs b/crates/warp-benches/benches/snapshot_hash.rs
index 16efcafa..915d8efd 100644
--- a/crates/warp-benches/benches/snapshot_hash.rs
+++ b/crates/warp-benches/benches/snapshot_hash.rs
@@ -29,13 +29,7 @@ fn build_chain_engine(n: usize) -> Engine {
     let mut store = GraphStore::default();
     let root = make_node_id(ROOT_ID_STR);
     let world = make_type_id(WORLD_TYPE_STR);
-    store.insert_node(
-        root,
-        NodeRecord {
-            ty: world,
-            payload: None,
-        },
-    );
+    store.insert_node(root, NodeRecord { ty: world });
     // Insert N nodes and connect them in a chain so all are reachable.
     let entity_ty = make_type_id(ENTITY_TYPE_STR);
     let link_ty = make_type_id(LINK_TYPE_STR);
@@ -43,13 +37,7 @@ fn build_chain_engine(n: usize) -> Engine {
     for i in 0..n {
         let to_label = format!("{}{}", ENT_LABEL_PREFIX, i);
         let id = make_node_id(&to_label);
-        store.insert_node(
-            id,
-            NodeRecord {
-                ty: entity_ty,
-                payload: None,
-            },
-        );
+        store.insert_node(id, NodeRecord { ty: entity_ty });
         // Human-friendly edge id: <from>-to-<to>.
         let from_label = if i == 0 {
             ROOT_ID_STR.to_string()
@@ -64,7 +52,6 @@ fn build_chain_engine(n: usize) -> Engine {
                 from: chain_tail,
                 to: id,
                 ty: link_ty,
-                payload: None,
             },
         );
         chain_tail = id;
diff --git a/crates/warp-core/src/attachment.rs b/crates/warp-core/src/attachment.rs
new file mode 100644
index 00000000..217158a1
--- /dev/null
+++ b/crates/warp-core/src/attachment.rs
@@ -0,0 +1,398 @@
+// SPDX-License-Identifier: Apache-2.0
+// © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
+//! Attachment-plane payloads and codec boundaries.
+//!
+//! Echo models Paper I/II “two-plane” WARP semantics:
+//! - The **skeleton plane** is the explicit graph structure (nodes + edges +
+//!   boundary ports). This is what matching, scheduling, rewriting, hashing,
+//!   and slicing operate on.
+//! - The **attachment plane** carries payloads attached to skeleton vertices
+//!   and edges.
+//!
+//! Stage B0 (this module) implements **depth-0 attachments** only: typed atoms.
+//! In Paper I terms, these are `Atom(p)` payloads for some opaque `p`.
+//! In Echo, `p` is represented as the pair `(TypeId, Bytes)` to avoid “same
+//! bytes, different meaning” collisions at the deterministic boundary.
+//!
+//! Stage B1 extends this with **descended attachments** via flattened
+//! indirection:
+//! - Attachments remain opaque atoms by default (`AtomPayload`).
+//! - Attachments may also be `Descend(WarpId)` to refer to another instance.
+//! - Descend links are explicit (not encoded inside bytes), satisfying the
+//!   “no hidden edges” law.
+
+use std::any::Any;
+use std::collections::HashMap;
+use std::marker::PhantomData;
+
+use bytes::Bytes;
+use thiserror::Error;
+
+use crate::ident::{EdgeKey, NodeKey, TypeId, WarpId};
+
+/// Attachment plane selector.
+///
+/// In Paper I notation, vertex attachments are `α` and edge attachments are `β`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum AttachmentPlane {
+    /// Vertex/node attachment plane (`α`).
+    Alpha,
+    /// Edge attachment plane (`β`).
+    Beta,
+}
+
+impl AttachmentPlane {
+    const fn tag(self) -> u8 {
+        match self {
+            Self::Alpha => 1,
+            Self::Beta => 2,
+        }
+    }
+}
+
+/// Owner identity for an attachment slot.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum AttachmentOwner {
+    /// Attachment owned by a node.
+    Node(NodeKey),
+    /// Attachment owned by an edge.
+    Edge(EdgeKey),
+}
+
+impl AttachmentOwner {
+    const fn tag(self) -> u8 {
+        match self {
+            Self::Node(_) => 1,
+            Self::Edge(_) => 2,
+        }
+    }
+}
+
+/// First-class identity for an attachment slot.
+///
+/// This is the key used for Stage B1 “descent chain” footprinting and slicing:
+/// changes to an attachment slot (especially `Descend`) must invalidate matches
+/// inside descendant instances deterministically.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct AttachmentKey {
+    /// Owner of the slot.
+    pub owner: AttachmentOwner,
+    /// Attachment plane selector.
+    pub plane: AttachmentPlane,
+}
+
+impl AttachmentKey {
+    /// Constructs a node-owned attachment key in the vertex/α plane.
+    #[must_use]
+    pub const fn node_alpha(node: NodeKey) -> Self {
+        Self {
+            owner: AttachmentOwner::Node(node),
+            plane: AttachmentPlane::Alpha,
+        }
+    }
+
+    /// Constructs an edge-owned attachment key in the edge/β plane.
+    #[must_use]
+    pub const fn edge_beta(edge: EdgeKey) -> Self {
+        Self {
+            owner: AttachmentOwner::Edge(edge),
+            plane: AttachmentPlane::Beta,
+        }
+    }
+
+    pub(crate) const fn tag(self) -> (u8, u8) {
+        (self.owner.tag(), self.plane.tag())
+    }
+}
+
+/// Attachment value stored in the attachment plane.
+///
+/// Depth-0 attachments are always [`AttachmentValue::Atom`].
+/// Stage B1 introduces [`AttachmentValue::Descend`] to model recursive WARPs as
+/// flattened indirection.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum AttachmentValue {
+    /// Depth-0 atom payload.
+    Atom(AtomPayload),
+    /// Flattened indirection to another WARP instance.
+    Descend(WarpId),
+}
+
+/// Typed, opaque payload attached to a node or edge.
+///
+/// This is the depth-0 “atom” payload for the attachment plane:
+/// `AtomPayload = Atom(TypeId, Bytes)`.
+///
+/// Laws / invariants:
+/// - `type_id` is part of the deterministic boundary and must participate in
+///   canonical encodings and digests.
+/// - `bytes` are opaque to the core store and must not be treated as hidden
+///   skeleton structure. Any dependency that matters for matching, causality,
+///   slicing, or rewrite applicability must be expressed as explicit skeleton
+///   nodes/edges/ports.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct AtomPayload {
+    /// Type identifier describing how to interpret `bytes`.
+    pub type_id: TypeId,
+    /// Opaque payload bytes.
+    pub bytes: Bytes,
+}
+
+impl AtomPayload {
+    /// Constructs a new typed atom payload.
+    #[must_use]
+    pub fn new(type_id: TypeId, bytes: Bytes) -> Self {
+        Self { type_id, bytes }
+    }
+
+    /// Attempts to decode the payload as `T` using codec `C`.
+    ///
+    /// # Errors
+    /// Returns [`DecodeError::TypeMismatch`] when the `type_id` does not match
+    /// `C::TYPE_ID`, or forwards the codec's strict decode error.
+    ///
+    /// # Determinism
+    /// This helper is deterministic: it has no side effects and depends only on
+    /// the payload bytes and `type_id`.
+    pub fn decode_with<C, T>(&self) -> Result<T, DecodeError>
+    where
+        C: Codec<T>,
+    {
+        if self.type_id != C::TYPE_ID {
+            return Err(DecodeError::TypeMismatch {
+                expected: C::TYPE_ID,
+                found: self.type_id,
+            });
+        }
+        C::decode_strict(&self.bytes)
+    }
+
+    /// Attempts to decode the payload for use in a rule matcher.
+    ///
+    /// This encodes Echo’s v0 decode failure semantics:
+    /// - type mismatch or decode failure ⇒ “rule does not apply” (`None`)
+    ///
+    /// Use this helper in matchers to ensure that decode failures never trigger
+    /// partial effects; if a matcher returns `true`, the executor should be able
+    /// to decode the same payload deterministically.
+    #[must_use]
+    pub fn decode_for_match<C, T>(&self) -> Option<T>
+    where
+        C: Codec<T>,
+    {
+        self.decode_with::<C, T>().ok()
+    }
+}
+
+/// Error returned by strict payload decoding.
+#[derive(Debug, Clone, PartialEq, Eq, Error)]
+pub enum DecodeError {
+    /// The payload `type_id` did not match the codec's expected type id.
+    #[error("payload type mismatch: expected {expected:?}, found {found:?}")]
+    TypeMismatch {
+        /// Expected type identifier.
+        expected: TypeId,
+        /// Found type identifier.
+        found: TypeId,
+    },
+    /// No codec was registered for the payload `TypeId`.
+    #[error("no codec registered for payload type id: {0:?}")]
+    UnknownTypeId(TypeId),
+    /// The byte content was invalid for the expected type.
+    #[error("invalid payload bytes")]
+    InvalidBytes,
+}
+
+/// Canonical codec for a typed payload `T`.
+///
+/// This trait defines the boundary between opaque attachment bytes and typed
+/// application data. The core rewrite engine does not depend on decoding; rules
+/// and views may decode payloads explicitly when needed.
+///
+/// Contract:
+/// - `encode_canon` must produce a stable, canonical byte representation.
+/// - `decode_strict` must either return the unique decoded `T` or a deterministic
+///   [`DecodeError`].
+pub trait Codec<T> {
+    /// Type id committed into the attachment atom.
+    const TYPE_ID: TypeId;
+
+    /// Encodes `value` into a canonical byte representation.
+    fn encode_canon(value: &T) -> Bytes;
+
+    /// Decodes bytes strictly into `T`.
+    ///
+    /// Implementations must be deterministic and must not consult ambient state
+    /// (time, randomness, global mutable config, etc).
+    ///
+    /// # Errors
+    /// Returns an error if `bytes` is not a valid canonical encoding of `T`.
+    fn decode_strict(bytes: &Bytes) -> Result<T, DecodeError>;
+}
+
+/// Object-safe codec wrapper for dynamic decoding based on `TypeId`.
+///
+/// This exists primarily for tooling/view layers that need to decode arbitrary
+/// payloads at runtime (e.g., inspectors). The core rewrite engine does not use
+/// this registry on hot paths.
+pub trait ErasedCodec: Send + Sync {
+    /// Type id handled by this codec.
+    fn type_id(&self) -> TypeId;
+    /// Human-readable type name for debugging and tooling.
+    fn type_name(&self) -> &'static str;
+    /// Strictly decode bytes into a type-erased value.
+    ///
+    /// # Errors
+    /// Returns an error if `bytes` is not a valid canonical encoding for this
+    /// codec's type.
+    fn decode_any(&self, bytes: &Bytes) -> Result<Box<dyn Any>, DecodeError>;
+}
+
+struct ErasedCodecImpl<T, C> {
+    type_name: &'static str,
+    _marker: PhantomData<(T, C)>,
+}
+
+impl<T, C> ErasedCodecImpl<T, C> {
+    const fn new(type_name: &'static str) -> Self {
+        Self {
+            type_name,
+            _marker: PhantomData,
+        }
+    }
+}
+
+impl<T, C> ErasedCodec for ErasedCodecImpl<T, C>
+where
+    T: Any + Send + Sync,
+    C: Codec<T> + Send + Sync,
+{
+    fn type_id(&self) -> TypeId {
+        C::TYPE_ID
+    }
+
+    fn type_name(&self) -> &'static str {
+        self.type_name
+    }
+
+    fn decode_any(&self, bytes: &Bytes) -> Result<Box<dyn Any>, DecodeError> {
+        let value = C::decode_strict(bytes)?;
+        Ok(Box::new(value))
+    }
+}
+
+/// Minimal codec registry keyed by payload `TypeId`.
+///
+/// The registry supports dynamic decode for tooling layers. It is intentionally
+/// small and is not used by the core scheduler/matcher unless an application
+/// layer explicitly consults it.
+#[derive(Default)]
+pub struct CodecRegistry {
+    codecs: HashMap<TypeId, Box<dyn ErasedCodec>>,
+}
+
+/// Errors returned when registering codecs.
+#[derive(Debug, Error)]
+pub enum RegistryError {
+    /// Attempted to register two codecs for the same `TypeId`.
+    #[error("duplicate codec registration for type id: {0:?}")]
+    DuplicateTypeId(TypeId),
+}
+
+impl CodecRegistry {
+    /// Creates an empty registry.
+    #[must_use]
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Registers codec `C` for payload type `T`.
+    ///
+    /// `type_name` is a human-readable identifier for debugging/tooling.
+    ///
+    /// # Errors
+    /// Returns [`RegistryError::DuplicateTypeId`] if a codec is already registered
+    /// for the same `TypeId`.
+    pub fn register<T, C>(&mut self, type_name: &'static str) -> Result<(), RegistryError>
+    where
+        T: Any + Send + Sync,
+        C: Codec<T> + Send + Sync + 'static,
+    {
+        let type_id = C::TYPE_ID;
+        if self.codecs.contains_key(&type_id) {
+            return Err(RegistryError::DuplicateTypeId(type_id));
+        }
+        self.codecs
+            .insert(type_id, Box::new(ErasedCodecImpl::<T, C>::new(type_name)));
+        Ok(())
+    }
+
+    /// Returns the codec registered for `type_id` (if any).
+    #[must_use]
+    pub fn get(&self, type_id: &TypeId) -> Option<&dyn ErasedCodec> {
+        self.codecs.get(type_id).map(std::convert::AsRef::as_ref)
+    }
+
+    /// Decodes a typed atom payload using the codec registered for its `type_id`.
+    ///
+    /// # Errors
+    /// Returns [`DecodeError::TypeMismatch`] if the payload `type_id` is not
+    /// registered.
+    pub fn decode_atom(&self, payload: &AtomPayload) -> Result<Box<dyn Any>, DecodeError> {
+        let Some(codec) = self.get(&payload.type_id) else {
+            return Err(DecodeError::UnknownTypeId(payload.type_id));
+        };
+        codec.decode_any(&payload.bytes)
+    }
+}
+
+#[cfg(test)]
+#[allow(clippy::expect_used, clippy::unwrap_used)]
+mod tests {
+    use super::*;
+
+    struct U8Codec;
+
+    impl Codec<u8> for U8Codec {
+        const TYPE_ID: TypeId = TypeId([0xA5; 32]);
+
+        fn encode_canon(value: &u8) -> Bytes {
+            Bytes::from(vec![*value])
+        }
+
+        fn decode_strict(bytes: &Bytes) -> Result<u8, DecodeError> {
+            if bytes.len() != 1 {
+                return Err(DecodeError::InvalidBytes);
+            }
+            Ok(*bytes.first().ok_or(DecodeError::InvalidBytes)?)
+        }
+    }
+
+    #[test]
+    fn atom_decode_with_enforces_type_id() {
+        let payload = AtomPayload::new(U8Codec::TYPE_ID, U8Codec::encode_canon(&7u8));
+        assert_eq!(payload.decode_with::<U8Codec, u8>().unwrap(), 7u8);
+
+        let wrong = AtomPayload::new(TypeId([0x5A; 32]), U8Codec::encode_canon(&7u8));
+        assert!(
+            matches!(
+                wrong.decode_with::<U8Codec, u8>(),
+                Err(DecodeError::TypeMismatch { .. })
+            ),
+            "type mismatch must be reported deterministically"
+        );
+    }
+
+    #[test]
+    fn atom_decode_for_match_is_deterministic_and_conservative() {
+        // v0 semantics: any decode failure -> rule does not apply (None).
+        let good = AtomPayload::new(U8Codec::TYPE_ID, U8Codec::encode_canon(&9u8));
+        assert_eq!(good.decode_for_match::<U8Codec, u8>(), Some(9u8));
+
+        let wrong_type = AtomPayload::new(TypeId([0x00; 32]), U8Codec::encode_canon(&9u8));
+        assert_eq!(wrong_type.decode_for_match::<U8Codec, u8>(), None);
+
+        let bad_bytes = AtomPayload::new(U8Codec::TYPE_ID, Bytes::from_static(&[]));
+        assert_eq!(bad_bytes.decode_for_match::<U8Codec, u8>(), None);
+    }
+}
diff --git a/crates/warp-core/src/demo/motion.rs b/crates/warp-core/src/demo/motion.rs
index fb121e8a..7e008e56 100644
--- a/crates/warp-core/src/demo/motion.rs
+++ b/crates/warp-core/src/demo/motion.rs
@@ -2,11 +2,15 @@
 // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
 //! Demo motion rule: advances position by velocity stored in payload.
 
+use crate::attachment::AttachmentValue;
 use crate::engine_impl::Engine;
-use crate::footprint::{Footprint, IdSet};
+use crate::footprint::{AttachmentSet, Footprint, IdSet, PortSet};
 use crate::graph::GraphStore;
 use crate::ident::{make_node_id, make_type_id, Hash, NodeId};
-use crate::payload::{decode_motion_payload, encode_motion_payload};
+use crate::payload::{
+    decode_motion_atom_payload, decode_motion_payload, encode_motion_payload,
+    motion_payload_type_id,
+};
 use crate::record::NodeRecord;
 use crate::rule::{ConflictPolicy, PatternGraph, RewriteRule};
 // Build-time generated canonical ids (domain-separated).
@@ -30,24 +34,28 @@ include!(concat!(env!("OUT_DIR"), "/rule_ids.rs"));
 pub const MOTION_RULE_NAME: &str = "motion/update";
 
 fn motion_executor(store: &mut GraphStore, scope: &NodeId) {
-    if let Some(node) = store.node_mut(scope) {
-        if let Some(payload) = &mut node.payload {
-            if let Some((mut pos, vel)) = decode_motion_payload(payload) {
-                pos[0] += vel[0];
-                pos[1] += vel[1];
-                pos[2] += vel[2];
-                *payload = encode_motion_payload(pos, vel);
-            }
-        }
+    if store.node(scope).is_none() {
+        return;
+    }
+    let Some(AttachmentValue::Atom(payload)) = store.node_attachment_mut(scope) else {
+        return;
+    };
+    if payload.type_id != motion_payload_type_id() {
+        return;
+    }
+    if let Some((mut pos, vel)) = decode_motion_payload(&payload.bytes) {
+        pos[0] += vel[0];
+        pos[1] += vel[1];
+        pos[2] += vel[2];
+        payload.bytes = encode_motion_payload(pos, vel);
     }
 }
 
 fn motion_matcher(store: &GraphStore, scope: &NodeId) -> bool {
-    store
-        .node(scope)
-        .and_then(|n| n.payload.as_ref())
-        .and_then(decode_motion_payload)
-        .is_some()
+    matches!(
+        store.node_attachment(scope),
+        Some(AttachmentValue::Atom(payload)) if decode_motion_atom_payload(payload).is_some()
+    )
 }
 
 /// Deterministic rule id bytes for `rule:motion/update`.
@@ -80,18 +88,23 @@ pub fn motion_rule() -> RewriteRule {
 }
 
 fn compute_motion_footprint(store: &GraphStore, scope: &NodeId) -> Footprint {
-    // Motion updates the payload on the scoped node only (write), no edges/ports.
-    let mut n_write = IdSet::default();
+    // Motion updates only the node's attachment payload (α plane).
+    let mut a_write = AttachmentSet::default();
     if store.node(scope).is_some() {
-        n_write.insert_node(scope);
+        a_write.insert(crate::AttachmentKey::node_alpha(crate::NodeKey {
+            warp_id: store.warp_id(),
+            local_id: *scope,
+        }));
     }
     Footprint {
         n_read: IdSet::default(),
-        n_write,
+        n_write: IdSet::default(),
         e_read: IdSet::default(),
         e_write: IdSet::default(),
-        b_in: crate::footprint::PortSet::default(),
-        b_out: crate::footprint::PortSet::default(),
+        a_read: AttachmentSet::default(),
+        a_write,
+        b_in: PortSet::default(),
+        b_out: PortSet::default(),
         factor_mask: 0,
     }
 }
@@ -113,13 +126,7 @@ pub fn build_motion_demo_engine() -> Engine {
     let mut store = GraphStore::default();
     let root_id = make_node_id("world-root");
     let root_type = make_type_id("world");
-    store.insert_node(
-        root_id,
-        NodeRecord {
-            ty: root_type,
-            payload: None,
-        },
-    );
+    store.insert_node(root_id, NodeRecord { ty: root_type });
 
     let mut engine = Engine::new(store, root_id);
     engine
@@ -153,24 +160,19 @@ mod tests {
         let ty = make_type_id("entity");
         let pos = [10.0, -2.0, 3.5];
         let vel = [0.125, 2.0, -1.5];
-        let payload = encode_motion_payload(pos, vel);
-        store.insert_node(
-            ent,
-            NodeRecord {
-                ty,
-                payload: Some(payload),
-            },
-        );
+        let payload = crate::encode_motion_atom_payload(pos, vel);
+        store.insert_node(ent, NodeRecord { ty });
+        store.set_node_attachment(ent, Some(AttachmentValue::Atom(payload)));
 
         // Run executor directly and validate position math and encoded bytes.
         motion_executor(&mut store, &ent);
-        let Some(rec) = store.node(&ent) else {
+        let Some(_rec) = store.node(&ent) else {
             unreachable!("entity present");
         };
-        let Some(bytes) = rec.payload.as_ref() else {
+        let Some(AttachmentValue::Atom(bytes)) = store.node_attachment(&ent) else {
             unreachable!("payload present");
         };
-        let Some((new_pos, new_vel)) = decode_motion_payload(bytes) else {
+        let Some((new_pos, new_vel)) = decode_motion_atom_payload(bytes) else {
             unreachable!("payload decode");
         };
         // Compare component-wise using exact bit equality for deterministic values.
@@ -181,9 +183,9 @@ mod tests {
         }
         // Encoding round-trip should match re-encoding of updated values exactly.
         let expected_bytes = encode_motion_payload(new_pos, new_vel);
-        let Some(bytes) = rec.payload.as_ref() else {
+        let Some(AttachmentValue::Atom(bytes)) = store.node_attachment(&ent) else {
             unreachable!("payload present after executor");
         };
-        assert_eq!(bytes, &expected_bytes);
+        assert_eq!(bytes.bytes, expected_bytes);
     }
 }
diff --git a/crates/warp-core/src/demo/ports.rs b/crates/warp-core/src/demo/ports.rs
index 5d6959ce..3f366b25 100644
--- a/crates/warp-core/src/demo/ports.rs
+++ b/crates/warp-core/src/demo/ports.rs
@@ -2,11 +2,15 @@
 // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
 //! Demo rule that reserves a boundary input port, used to exercise the
 //! reservation gate and independence checks.
+use crate::attachment::AttachmentValue;
 use crate::engine_impl::Engine;
-use crate::footprint::{pack_port_key, Footprint, IdSet, PortSet};
+use crate::footprint::{pack_port_key, AttachmentSet, Footprint, IdSet, PortSet};
 use crate::graph::GraphStore;
 use crate::ident::{make_node_id, make_type_id, Hash, NodeId};
-use crate::payload::{decode_motion_payload, encode_motion_payload};
+use crate::payload::{
+    decode_motion_payload, encode_motion_atom_payload, encode_motion_payload,
+    motion_payload_type_id,
+};
 use crate::record::NodeRecord;
 use crate::rule::{ConflictPolicy, PatternGraph, RewriteRule};
 
@@ -18,26 +22,47 @@ fn port_matcher(_: &GraphStore, _: &NodeId) -> bool {
 }
 
 fn port_executor(store: &mut GraphStore, scope: &NodeId) {
-    if let Some(node) = store.node_mut(scope) {
-        // Use motion payload layout; increment pos.x by 1.0
-        if let Some(bytes) = &mut node.payload {
-            if let Some((mut pos, vel)) = decode_motion_payload(bytes) {
-                pos[0] += 1.0;
-                *bytes = encode_motion_payload(pos, vel);
-            }
-        } else {
-            let pos = [1.0, 0.0, 0.0];
-            let vel = [0.0, 0.0, 0.0];
-            node.payload = Some(encode_motion_payload(pos, vel));
-        }
+    if store.node(scope).is_none() {
+        return;
+    }
+
+    // State machine:
+    // - missing attachment => initialize the motion payload (pos.x starts at 1.0)
+    // - existing Atom motion payload => increment pos.x by 1.0
+    // - any other attachment kind/type => no-op (rule does not apply)
+    // Use motion payload layout; increment pos.x by 1.0
+    let Some(attachment) = store.node_attachment_mut(scope) else {
+        let pos = [1.0, 0.0, 0.0];
+        let vel = [0.0, 0.0, 0.0];
+        store.set_node_attachment(
+            *scope,
+            Some(AttachmentValue::Atom(encode_motion_atom_payload(pos, vel))),
+        );
+        return;
+    };
+
+    let AttachmentValue::Atom(payload) = attachment else {
+        return;
+    };
+    if payload.type_id != motion_payload_type_id() {
+        return;
+    }
+    if let Some((mut pos, vel)) = decode_motion_payload(&payload.bytes) {
+        pos[0] += 1.0;
+        payload.bytes = encode_motion_payload(pos, vel);
     }
 }
 
 fn compute_port_footprint(store: &GraphStore, scope: &NodeId) -> Footprint {
     let mut n_write = IdSet::default();
+    let mut a_write = AttachmentSet::default();
     let mut b_in = PortSet::default();
     if store.node(scope).is_some() {
         n_write.insert_node(scope);
+        a_write.insert(crate::AttachmentKey::node_alpha(crate::NodeKey {
+            warp_id: store.warp_id(),
+            local_id: *scope,
+        }));
         b_in.insert(pack_port_key(scope, 0, true));
     }
     Footprint {
@@ -45,6 +70,8 @@ fn compute_port_footprint(store: &GraphStore, scope: &NodeId) -> Footprint {
         n_write,
         e_read: IdSet::default(),
         e_write: IdSet::default(),
+        a_read: AttachmentSet::default(),
+        a_write,
         b_in,
         b_out: PortSet::default(),
         factor_mask: 0,
@@ -94,13 +121,7 @@ pub fn build_port_demo_engine() -> Engine {
     let mut store = GraphStore::default();
     let root_id = make_node_id("world-root-ports");
     let root_type = make_type_id("world");
-    store.insert_node(
-        root_id,
-        NodeRecord {
-            ty: root_type,
-            payload: None,
-        },
-    );
+    store.insert_node(root_id, NodeRecord { ty: root_type });
     let mut engine = Engine::new(store, root_id);
     engine
         .register_rule(port_rule())
diff --git a/crates/warp-core/src/engine_impl.rs b/crates/warp-core/src/engine_impl.rs
index c20dbc4b..158dc9fd 100644
--- a/crates/warp-core/src/engine_impl.rs
+++ b/crates/warp-core/src/engine_impl.rs
@@ -6,15 +6,17 @@ use std::collections::{HashMap, HashSet};
 use blake3::Hasher;
 use thiserror::Error;
 
+use crate::attachment::{AttachmentKey, AttachmentValue};
 use crate::graph::GraphStore;
-use crate::ident::{CompactRuleId, EdgeId, Hash, NodeId};
+use crate::ident::{CompactRuleId, EdgeId, Hash, NodeId, NodeKey, WarpId};
 use crate::receipt::{TickReceipt, TickReceiptDisposition, TickReceiptEntry, TickReceiptRejection};
 use crate::record::NodeRecord;
 use crate::rule::{ConflictPolicy, RewriteRule};
 use crate::scheduler::{DeterministicScheduler, PendingRewrite, RewritePhase, SchedulerKind};
 use crate::snapshot::{compute_commit_hash_v2, compute_state_root, Snapshot};
-use crate::tick_patch::{diff_store, SlotId, TickCommitStatus, WarpTickPatchV1};
+use crate::tick_patch::{diff_state, SlotId, TickCommitStatus, WarpTickPatchV1};
 use crate::tx::TxId;
+use crate::warp_state::{WarpInstance, WarpState};
 
 /// Result of calling [`Engine::apply`].
 #[derive(Debug)]
@@ -46,6 +48,9 @@ pub enum EngineError {
     /// Internal invariant violated (engine state corruption).
     #[error("internal invariant violated: {0}")]
     InternalCorruption(&'static str),
+    /// Attempted to access a warp instance that does not exist.
+    #[error("unknown warp instance: {0:?}")]
+    UnknownWarp(WarpId),
 }
 
 /// Core rewrite engine used by the spike.
@@ -58,7 +63,7 @@ pub enum EngineError {
 /// a breaking change to snapshot identity and must be recorded in the
 /// determinism spec and tests.
 pub struct Engine {
-    store: GraphStore,
+    state: WarpState,
     rules: HashMap<&'static str, RewriteRule>,
     rules_by_id: HashMap<Hash, &'static str>,
     compact_rule_ids: HashMap<Hash, CompactRuleId>,
@@ -72,7 +77,7 @@ pub struct Engine {
     policy_id: u32,
     tx_counter: u64,
     live_txs: HashSet<u64>,
-    current_root: NodeId,
+    current_root: NodeKey,
     last_snapshot: Option<Snapshot>,
 }
 
@@ -119,6 +124,8 @@ impl Engine {
     ///
     /// # Parameters
     /// - `store`: Backing graph store.
+    ///   The supplied store is assigned to the canonical root warp instance; any pre-existing
+    ///   `warp_id` on the store is overwritten.
     /// - `root`: Root node id for snapshot hashing.
     /// - `kind`: Scheduler variant (Radix vs Legacy).
     /// - `policy_id`: Policy identifier committed into `patch_digest` and `commit_id` v2.
@@ -128,8 +135,22 @@ impl Engine {
         kind: SchedulerKind,
         policy_id: u32,
     ) -> Self {
-        Self {
+        // NOTE: The supplied `GraphStore` is assigned to the canonical root warp instance.
+        // Any pre-existing `warp_id` on the store is overwritten.
+        let root_warp = crate::ident::make_warp_id("root");
+        let mut state = WarpState::new();
+        let mut store = store;
+        store.warp_id = root_warp;
+        state.upsert_instance(
+            WarpInstance {
+                warp_id: root_warp,
+                root_node: root,
+                parent: None,
+            },
             store,
+        );
+        Self {
+            state,
             rules: HashMap::new(),
             rules_by_id: HashMap::new(),
             compact_rule_ids: HashMap::new(),
@@ -138,11 +159,77 @@ impl Engine {
             policy_id,
             tx_counter: 0,
             live_txs: HashSet::new(),
-            current_root: root,
+            current_root: NodeKey {
+                warp_id: root_warp,
+                local_id: root,
+            },
             last_snapshot: None,
         }
     }
 
+    /// Constructs an engine from an existing multi-instance [`WarpState`] (Stage B1).
+    ///
+    /// This constructor is primarily intended for:
+    /// - replaying a sequence of tick patches into a `WarpState`, then continuing execution,
+    /// - building multi-instance fixtures for tests/tools without exposing `WarpState` internals, and
+    /// - running rules against an externally-authored state (imported or synthesized).
+    ///
+    /// **Important**: `Engine::with_state` initializes a clean execution environment:
+    /// the scheduler starts empty (no pending rewrites), there are no live transactions,
+    /// and `tx_counter` is reset to `0`. Transaction/scheduler state from any original
+    /// execution is intentionally not preserved.
+    ///
+    /// # Parameters
+    /// - `state`: pre-constructed multi-instance state.
+    /// - `root`: the root node for snapshot hashing and commits. This must refer to the root instance.
+    /// - `kind`: scheduler variant (Radix vs Legacy).
+    /// - `policy_id`: policy identifier committed into `patch_digest` and `commit_id` v2.
+    ///
+    /// # Errors
+    /// Returns [`EngineError::UnknownWarp`] if the root warp instance is not present
+    /// (missing store or missing instance metadata).
+    ///
+    /// Returns [`EngineError::InternalCorruption`] if the supplied `root` does not
+    /// match the root instance metadata (`WarpInstance.root_node`), or if the root
+    /// instance declares a `parent` (root instances must have `parent = None`).
+    pub fn with_state(
+        state: WarpState,
+        root: NodeKey,
+        kind: SchedulerKind,
+        policy_id: u32,
+    ) -> Result<Self, EngineError> {
+        let Some(root_instance) = state.instance(&root.warp_id) else {
+            return Err(EngineError::UnknownWarp(root.warp_id));
+        };
+        if root_instance.parent.is_some() {
+            return Err(EngineError::InternalCorruption(
+                "root warp instance must not declare a parent",
+            ));
+        }
+        if root_instance.root_node != root.local_id {
+            return Err(EngineError::InternalCorruption(
+                "Engine root must match WarpInstance.root_node",
+            ));
+        }
+        if state.store(&root.warp_id).is_none() {
+            return Err(EngineError::UnknownWarp(root.warp_id));
+        }
+
+        Ok(Self {
+            state,
+            rules: HashMap::new(),
+            rules_by_id: HashMap::new(),
+            compact_rule_ids: HashMap::new(),
+            rules_by_compact: HashMap::new(),
+            scheduler: DeterministicScheduler::new(kind),
+            policy_id,
+            tx_counter: 0,
+            live_txs: HashSet::new(),
+            current_root: root,
+            last_snapshot: None,
+        })
+    }
+
     /// Registers a rewrite rule so it can be referenced by name.
     ///
     /// # Errors
@@ -198,6 +285,32 @@ impl Engine {
         tx: TxId,
         rule_name: &str,
         scope: &NodeId,
+    ) -> Result<ApplyResult, EngineError> {
+        self.apply_in_warp(tx, self.current_root.warp_id, rule_name, scope, &[])
+    }
+
+    /// Queues a rewrite for execution within a specific warp instance.
+    ///
+    /// `descent_stack` is the chain of attachment slots (root → … → current)
+    /// that establishes reachability for this instance. For Stage B1
+    /// determinism, any match/exec inside a descended instance must record
+    /// READs of every `AttachmentKey` in this stack so that changing a descent
+    /// pointer deterministically invalidates the match.
+    ///
+    /// # Errors
+    /// Returns:
+    /// - [`EngineError::UnknownTx`] if `tx` is invalid or already closed.
+    /// - [`EngineError::UnknownRule`] if `rule_name` was not registered.
+    /// - [`EngineError::UnknownWarp`] if `warp_id` does not exist.
+    /// - [`EngineError::InternalCorruption`] if an internal rule table invariant is violated
+    ///   (should not occur when using the public registration APIs).
+    pub fn apply_in_warp(
+        &mut self,
+        tx: TxId,
+        warp_id: WarpId,
+        rule_name: &str,
+        scope: &NodeId,
+        descent_stack: &[AttachmentKey],
     ) -> Result<ApplyResult, EngineError> {
         if tx.value() == 0 || !self.live_txs.contains(&tx.value()) {
             return Err(EngineError::UnknownTx);
@@ -205,13 +318,25 @@ impl Engine {
         let Some(rule) = self.rules.get(rule_name) else {
             return Err(EngineError::UnknownRule(rule_name.to_owned()));
         };
-        let matches = (rule.matcher)(&self.store, scope);
+        let Some(store) = self.state.store(&warp_id) else {
+            return Err(EngineError::UnknownWarp(warp_id));
+        };
+        let matches = (rule.matcher)(store, scope);
         if !matches {
             return Ok(ApplyResult::NoMatch);
         }
 
-        let scope_fp = scope_hash(&rule.id, scope);
-        let footprint = (rule.compute_footprint)(&self.store, scope);
+        let scope_key = NodeKey {
+            warp_id,
+            local_id: *scope,
+        };
+        let scope_fp = scope_hash(&rule.id, &scope_key);
+        let mut footprint = (rule.compute_footprint)(store, scope);
+        // Stage B1 law: any match/exec inside a descended instance must READ
+        // every attachment slot in the descent chain.
+        for key in descent_stack {
+            footprint.a_read.insert(*key);
+        }
         let Some(&compact_rule) = self.compact_rule_ids.get(&rule.id) else {
             return Err(EngineError::InternalCorruption(
                 "missing compact rule id for a registered rule",
@@ -223,7 +348,7 @@ impl Engine {
                 rule_id: rule.id,
                 compact_rule,
                 scope_hash: scope_fp,
-                scope: *scope,
+                scope: scope_key,
                 footprint,
                 phase: RewritePhase::Matched,
             },
@@ -280,12 +405,13 @@ impl Engine {
         let rewrites_digest = compute_rewrites_digest(&reserved_rewrites);
 
         // Capture pre-state for delta patch construction.
-        let store_before = self.store.clone();
+        // PERF: Full state clone; consider COW or incremental tracking for large graphs.
+        let state_before = self.state.clone();
 
         self.apply_reserved_rewrites(reserved_rewrites)?;
 
         // Delta tick patch (Paper III boundary artifact).
-        let ops = diff_store(&store_before, &self.store);
+        let ops = diff_state(&state_before, &self.state);
         let patch = WarpTickPatchV1::new(
             policy_id,
             rule_pack_id,
@@ -296,7 +422,7 @@ impl Engine {
         );
         let patch_digest = patch.digest();
 
-        let state_root = crate::snapshot::compute_state_root(&self.store, &self.current_root);
+        let state_root = crate::snapshot::compute_state_root(&self.state, &self.current_root);
         let parents: Vec<Hash> = self
             .last_snapshot
             .as_ref()
@@ -383,7 +509,12 @@ impl Engine {
                 },
             });
             if accepted {
-                extend_slots_from_footprint(&mut in_slots, &mut out_slots, &rewrite.footprint);
+                extend_slots_from_footprint(
+                    &mut in_slots,
+                    &mut out_slots,
+                    &rewrite.scope.warp_id,
+                    &rewrite.footprint,
+                );
                 reserved.push(rewrite);
                 reserved_entry_indices.push(entry_idx_u32);
             }
@@ -404,13 +535,24 @@ impl Engine {
     ) -> Result<(), EngineError> {
         for rewrite in rewrites {
             let id = rewrite.compact_rule;
-            let Some(rule) = self.rule_by_compact(id) else {
-                debug_assert!(false, "missing rule for compact id: {id:?}");
-                return Err(EngineError::InternalCorruption(
-                    "missing rule for compact id during commit",
-                ));
+            let executor = {
+                let Some(rule) = self.rule_by_compact(id) else {
+                    debug_assert!(false, "missing rule for compact id: {id:?}");
+                    return Err(EngineError::InternalCorruption(
+                        "missing rule for compact id during commit",
+                    ));
+                };
+                rule.executor
+            };
+            let Some(store) = self.state.store_mut(&rewrite.scope.warp_id) else {
+                debug_assert!(
+                    false,
+                    "missing store for warp id: {:?}",
+                    rewrite.scope.warp_id
+                );
+                return Err(EngineError::UnknownWarp(rewrite.scope.warp_id));
             };
-            (rule.executor)(&mut self.store, &rewrite.scope);
+            (executor)(store, &rewrite.scope.local_id);
         }
         Ok(())
     }
@@ -422,7 +564,7 @@ impl Engine {
         // same v2 commit hash shape (parents + state_root + patch_digest) but
         // with empty diagnostic digests. This makes it explicit that no rewrites
         // were applied while keeping the structure stable for callers/tools.
-        let state_root = compute_state_root(&self.store, &self.current_root);
+        let state_root = compute_state_root(&self.state, &self.current_root);
         let parents: Vec<Hash> = self
             .last_snapshot
             .as_ref()
@@ -457,16 +599,83 @@ impl Engine {
     }
 
     /// Returns a shared view of a node when it exists.
-    #[must_use]
-    pub fn node(&self, id: &NodeId) -> Option<&NodeRecord> {
-        self.store.node(id)
+    ///
+    /// # Errors
+    /// Returns [`EngineError::UnknownWarp`] if the root warp store is missing.
+    pub fn node(&self, id: &NodeId) -> Result<Option<&NodeRecord>, EngineError> {
+        let Some(store) = self.state.store(&self.current_root.warp_id) else {
+            return Err(EngineError::UnknownWarp(self.current_root.warp_id));
+        };
+        Ok(store.node(id))
+    }
+
+    /// Returns the node's attachment value (if any) in the root instance.
+    ///
+    /// # Errors
+    /// Returns [`EngineError::UnknownWarp`] if the root warp store is missing.
+    pub fn node_attachment(&self, id: &NodeId) -> Result<Option<&AttachmentValue>, EngineError> {
+        let Some(store) = self.state.store(&self.current_root.warp_id) else {
+            return Err(EngineError::UnknownWarp(self.current_root.warp_id));
+        };
+        Ok(store.node_attachment(id))
     }
 
     /// Inserts or replaces a node directly inside the store.
     ///
     /// The spike uses this to create motion entities prior to executing rewrites.
-    pub fn insert_node(&mut self, id: NodeId, record: NodeRecord) {
-        self.store.insert_node(id, record);
+    ///
+    /// # Errors
+    /// Returns [`EngineError::UnknownWarp`] if the root warp store is missing.
+    /// This indicates internal state corruption: the root warp store is expected
+    /// to exist after engine construction.
+    pub fn insert_node(&mut self, id: NodeId, record: NodeRecord) -> Result<(), EngineError> {
+        let Some(store) = self.state.store_mut(&self.current_root.warp_id) else {
+            return Err(EngineError::UnknownWarp(self.current_root.warp_id));
+        };
+        store.insert_node(id, record);
+        Ok(())
+    }
+
+    /// Sets the node's attachment value (if any) in the root instance.
+    ///
+    /// # Errors
+    /// Returns [`EngineError::UnknownWarp`] if the root warp store is missing.
+    /// This indicates internal state corruption: the root warp store is expected
+    /// to exist after engine construction.
+    pub fn set_node_attachment(
+        &mut self,
+        id: NodeId,
+        value: Option<AttachmentValue>,
+    ) -> Result<(), EngineError> {
+        let Some(store) = self.state.store_mut(&self.current_root.warp_id) else {
+            return Err(EngineError::UnknownWarp(self.current_root.warp_id));
+        };
+        store.set_node_attachment(id, value);
+        Ok(())
+    }
+
+    /// Inserts or replaces a node and sets its attachment value (if any) in the root instance.
+    ///
+    /// This is a convenience for bootstrapping/demo callers that want to avoid
+    /// partially-initialized nodes (node record inserted without its attachment)
+    /// if an engine invariant is violated.
+    ///
+    /// # Errors
+    /// Returns [`EngineError::UnknownWarp`] if the root warp store is missing.
+    /// This indicates internal state corruption: the root warp store is expected
+    /// to exist after engine construction.
+    pub fn insert_node_with_attachment(
+        &mut self,
+        id: NodeId,
+        record: NodeRecord,
+        attachment: Option<AttachmentValue>,
+    ) -> Result<(), EngineError> {
+        let Some(store) = self.state.store_mut(&self.current_root.warp_id) else {
+            return Err(EngineError::UnknownWarp(self.current_root.warp_id));
+        };
+        store.insert_node(id, record);
+        store.set_node_attachment(id, attachment);
+        Ok(())
     }
 }
 
@@ -499,6 +708,12 @@ fn footprints_conflict(a: &crate::footprint::Footprint, b: &crate::footprint::Fo
     {
         return true;
     }
+    if a.a_write.intersects(&b.a_write)
+        || a.a_write.intersects(&b.a_read)
+        || b.a_write.intersects(&a.a_read)
+    {
+        return true;
+    }
     a.n_write.intersects(&b.n_write)
         || a.n_write.intersects(&b.n_read)
         || b.n_write.intersects(&a.n_read)
@@ -526,7 +741,8 @@ fn compute_rewrites_digest(rewrites: &[PendingRewrite]) -> Hash {
     for r in rewrites {
         hasher.update(&r.rule_id);
         hasher.update(&r.scope_hash);
-        hasher.update(&(r.scope).0);
+        hasher.update(r.scope.warp_id.as_bytes());
+        hasher.update(r.scope.local_id.as_bytes());
     }
     hasher.finalize().into()
 }
@@ -560,34 +776,57 @@ impl Engine {
 /// candidates by both the producing rule and the scoped node.
 ///
 /// Stable definition (v0):
-/// - `scope_hash := blake3(rule_id || scope_node_id)`
-pub fn scope_hash(rule_id: &Hash, scope: &NodeId) -> Hash {
+/// - `scope_hash := blake3(rule_id || warp_id || scope_node_id)`
+pub fn scope_hash(rule_id: &Hash, scope: &NodeKey) -> Hash {
     let mut hasher = Hasher::new();
     hasher.update(rule_id);
-    hasher.update(&scope.0);
+    hasher.update(scope.warp_id.as_bytes());
+    hasher.update(scope.local_id.as_bytes());
     hasher.finalize().into()
 }
 
 fn extend_slots_from_footprint(
     in_slots: &mut std::collections::BTreeSet<SlotId>,
     out_slots: &mut std::collections::BTreeSet<SlotId>,
+    warp_id: &WarpId,
     fp: &crate::footprint::Footprint,
 ) {
     for node_hash in fp.n_read.iter() {
-        in_slots.insert(SlotId::Node(NodeId(*node_hash)));
+        in_slots.insert(SlotId::Node(NodeKey {
+            warp_id: *warp_id,
+            local_id: NodeId(*node_hash),
+        }));
     }
     for node_hash in fp.n_write.iter() {
         let id = NodeId(*node_hash);
-        in_slots.insert(SlotId::Node(id));
-        out_slots.insert(SlotId::Node(id));
+        let key = NodeKey {
+            warp_id: *warp_id,
+            local_id: id,
+        };
+        in_slots.insert(SlotId::Node(key));
+        out_slots.insert(SlotId::Node(key));
     }
     for edge_hash in fp.e_read.iter() {
-        in_slots.insert(SlotId::Edge(EdgeId(*edge_hash)));
+        in_slots.insert(SlotId::Edge(crate::ident::EdgeKey {
+            warp_id: *warp_id,
+            local_id: EdgeId(*edge_hash),
+        }));
     }
     for edge_hash in fp.e_write.iter() {
         let id = EdgeId(*edge_hash);
-        in_slots.insert(SlotId::Edge(id));
-        out_slots.insert(SlotId::Edge(id));
+        let key = crate::ident::EdgeKey {
+            warp_id: *warp_id,
+            local_id: id,
+        };
+        in_slots.insert(SlotId::Edge(key));
+        out_slots.insert(SlotId::Edge(key));
+    }
+    for key in fp.a_read.iter() {
+        in_slots.insert(SlotId::Attachment(*key));
+    }
+    for key in fp.a_write.iter() {
+        in_slots.insert(SlotId::Attachment(*key));
+        out_slots.insert(SlotId::Attachment(*key));
     }
     for port_key in fp.b_in.keys() {
         in_slots.insert(SlotId::Port(*port_key));
@@ -601,20 +840,27 @@ fn extend_slots_from_footprint(
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::attachment::{AttachmentKey, AttachmentValue};
     use crate::demo::motion::{motion_rule, MOTION_RULE_NAME};
     use crate::ident::{make_node_id, make_type_id};
-    use crate::payload::encode_motion_payload;
+    use crate::payload::encode_motion_atom_payload;
     use crate::record::NodeRecord;
 
     #[test]
     fn scope_hash_stable_for_rule_and_scope() {
         let rule = motion_rule();
-        let scope = make_node_id("scope-hash-entity");
+        let warp_id = crate::ident::make_warp_id("scope-hash-test-warp");
+        let scope_node = make_node_id("scope-hash-entity");
+        let scope = NodeKey {
+            warp_id,
+            local_id: scope_node,
+        };
         let h1 = super::scope_hash(&rule.id, &scope);
         // Recompute expected value manually using the same inputs.
         let mut hasher = blake3::Hasher::new();
         hasher.update(&rule.id);
-        hasher.update(&scope.0);
+        hasher.update(warp_id.as_bytes());
+        hasher.update(scope_node.as_bytes());
         let expected: Hash = hasher.finalize().into();
         assert_eq!(h1, expected);
     }
@@ -645,16 +891,11 @@ mod tests {
     fn tick_patch_replay_matches_post_state() {
         let entity = make_node_id("tick-patch-entity");
         let entity_type = make_type_id("entity");
-        let payload = encode_motion_payload([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
+        let payload = encode_motion_atom_payload([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
 
         let mut store = GraphStore::default();
-        store.insert_node(
-            entity,
-            NodeRecord {
-                ty: entity_type,
-                payload: Some(payload),
-            },
-        );
+        store.insert_node(entity, NodeRecord { ty: entity_type });
+        store.set_node_attachment(entity, Some(AttachmentValue::Atom(payload)));
 
         let mut engine = Engine::new(store, entity);
         let register = engine.register_rule(motion_rule());
@@ -667,7 +908,7 @@ mod tests {
             "expected ApplyResult::Applied, got {applied:?}"
         );
 
-        let store_before = engine.store.clone();
+        let state_before = engine.state.clone();
         let committed = engine.commit_with_receipt(tx);
         assert!(
             committed.is_ok(),
@@ -676,16 +917,16 @@ mod tests {
         let Ok((snapshot, _receipt, patch)) = committed else {
             return;
         };
-        let store_after = engine.store.clone();
+        let state_after = engine.state.clone();
 
         // Replay patch delta from the captured pre-state and compare the resulting state root.
-        let mut store_replay = store_before;
-        let replay = patch.apply_to_store(&mut store_replay);
+        let mut state_replay = state_before;
+        let replay = patch.apply_to_state(&mut state_replay);
         assert!(replay.is_ok(), "patch replay failed: {replay:?}");
 
-        let root = entity;
-        let state_after = compute_state_root(&store_after, &root);
-        let state_replay = compute_state_root(&store_replay, &root);
+        let root = engine.current_root;
+        let state_after = compute_state_root(&state_after, &root);
+        let state_replay = compute_state_root(&state_replay, &root);
         assert_eq!(
             state_after, state_replay,
             "patch replay must match post-state"
@@ -704,8 +945,12 @@ mod tests {
             "commit hash v2 must commit to state_root + patch_digest (+ parents/policy)"
         );
 
-        // Conservative slots from footprint: motion writes the scoped node record.
-        assert!(patch.in_slots().contains(&SlotId::Node(entity)));
-        assert!(patch.out_slots().contains(&SlotId::Node(entity)));
+        // Conservative slots from footprint: motion writes the scoped node attachment (α plane).
+        let slot = SlotId::Attachment(AttachmentKey::node_alpha(NodeKey {
+            warp_id: root.warp_id,
+            local_id: entity,
+        }));
+        assert!(patch.in_slots().contains(&slot));
+        assert!(patch.out_slots().contains(&slot));
     }
 }
diff --git a/crates/warp-core/src/footprint.rs b/crates/warp-core/src/footprint.rs
index c70125e3..50bd3fe1 100644
--- a/crates/warp-core/src/footprint.rs
+++ b/crates/warp-core/src/footprint.rs
@@ -12,6 +12,7 @@
 
 use std::collections::BTreeSet;
 
+use crate::attachment::AttachmentKey;
 use crate::ident::{EdgeId, Hash, NodeId};
 
 /// Packed 64‑bit key for a boundary port.
@@ -95,6 +96,38 @@ impl PortSet {
     }
 }
 
+/// Ordered set of attachment slots.
+#[derive(Debug, Clone, Default)]
+pub struct AttachmentSet(BTreeSet<AttachmentKey>);
+
+impl AttachmentSet {
+    /// Inserts an attachment key.
+    pub fn insert(&mut self, key: AttachmentKey) {
+        let _ = self.0.insert(key);
+    }
+
+    /// Returns an iterator over attachment keys.
+    pub fn iter(&self) -> impl Iterator<Item = &AttachmentKey> {
+        self.0.iter()
+    }
+
+    /// Returns true if any element is shared with `other`.
+    pub fn intersects(&self, other: &Self) -> bool {
+        let mut a = self.0.iter();
+        let mut b = other.0.iter();
+        let mut va = a.next();
+        let mut vb = b.next();
+        while let (Some(x), Some(y)) = (va, vb) {
+            match x.cmp(y) {
+                core::cmp::Ordering::Less => va = a.next(),
+                core::cmp::Ordering::Greater => vb = b.next(),
+                core::cmp::Ordering::Equal => return true,
+            }
+        }
+        false
+    }
+}
+
 /// Footprint capturing the read/write sets and factor mask of a rewrite.
 #[derive(Debug, Clone, Default)]
 pub struct Footprint {
@@ -106,6 +139,10 @@ pub struct Footprint {
     pub e_read: IdSet,
     /// Edges written/created/deleted by the rewrite.
     pub e_write: IdSet,
+    /// Attachment slots read by the rewrite.
+    pub a_read: AttachmentSet,
+    /// Attachment slots written by the rewrite.
+    pub a_write: AttachmentSet,
     /// Boundary input ports touched.
     pub b_in: PortSet,
     /// Boundary output ports touched.
@@ -138,6 +175,12 @@ impl Footprint {
         {
             return false;
         }
+        if self.a_write.intersects(&other.a_write)
+            || self.a_write.intersects(&other.a_read)
+            || other.a_write.intersects(&self.a_read)
+        {
+            return false;
+        }
         if self.n_write.intersects(&other.n_write)
             || self.n_write.intersects(&other.n_read)
             || other.n_write.intersects(&self.n_read)
diff --git a/crates/warp-core/src/graph.rs b/crates/warp-core/src/graph.rs
index f07917a9..ee18e419 100644
--- a/crates/warp-core/src/graph.rs
+++ b/crates/warp-core/src/graph.rs
@@ -3,27 +3,75 @@
 //! Minimal in-memory graph store used by the rewrite executor and tests.
 use std::collections::BTreeMap;
 
-use crate::ident::{EdgeId, NodeId};
+use crate::attachment::AttachmentValue;
+use crate::ident::{EdgeId, NodeId, WarpId};
 use crate::record::{EdgeRecord, NodeRecord};
 
 /// In-memory graph storage for the spike.
 ///
 /// The production engine will eventually swap in a content-addressed store,
 /// but this structure keeps the motion rewrite spike self-contained.
-#[derive(Default, Clone)]
+#[derive(Debug, Clone)]
 pub struct GraphStore {
+    /// Warp instance identifier for this store (Stage B1).
+    pub(crate) warp_id: WarpId,
     /// Mapping from node identifiers to their materialised records.
     pub(crate) nodes: BTreeMap<NodeId, NodeRecord>,
     /// Mapping from source node to outbound edge records.
     pub(crate) edges_from: BTreeMap<NodeId, Vec<EdgeRecord>>,
+    /// Reverse adjacency: mapping from destination node to inbound edge ids.
+    ///
+    /// This allows `delete_node_cascade` to remove inbound edges without scanning
+    /// every `edges_from` bucket (removal becomes `O(inbound_edges)`).
+    pub(crate) edges_to: BTreeMap<NodeId, Vec<EdgeId>>,
+    /// Attachment plane payloads for nodes (Paper I `α` plane).
+    ///
+    /// Entries are present only when the attachment is `Some(...)`.
+    pub(crate) node_attachments: BTreeMap<NodeId, AttachmentValue>,
+    /// Attachment plane payloads for edges (Paper I `β` plane).
+    ///
+    /// Entries are present only when the attachment is `Some(...)`.
+    pub(crate) edge_attachments: BTreeMap<EdgeId, AttachmentValue>,
     /// Reverse index of `EdgeId -> from NodeId`.
     ///
     /// This enables efficient edge migration/removal by id (used by tick patch replay),
     /// avoiding `O(total_edges)` scans across all buckets.
     pub(crate) edge_index: BTreeMap<EdgeId, NodeId>,
+    /// Reverse index of `EdgeId -> to NodeId`.
+    ///
+    /// This enables efficient maintenance of [`GraphStore::edges_to`] during
+    /// edge migration and deletion.
+    pub(crate) edge_to_index: BTreeMap<EdgeId, NodeId>,
+}
+
+impl Default for GraphStore {
+    fn default() -> Self {
+        Self::new(crate::ident::make_warp_id("root"))
+    }
 }
 
 impl GraphStore {
+    /// Creates an empty store for `warp_id`.
+    #[must_use]
+    pub fn new(warp_id: WarpId) -> Self {
+        Self {
+            warp_id,
+            nodes: BTreeMap::new(),
+            edges_from: BTreeMap::new(),
+            edges_to: BTreeMap::new(),
+            node_attachments: BTreeMap::new(),
+            edge_attachments: BTreeMap::new(),
+            edge_index: BTreeMap::new(),
+            edge_to_index: BTreeMap::new(),
+        }
+    }
+
+    /// Returns the warp instance identifier for this store.
+    #[must_use]
+    pub fn warp_id(&self) -> WarpId {
+        self.warp_id
+    }
+
     /// Iterate over all nodes (id, record) in deterministic order.
     pub fn iter_nodes(&self) -> impl Iterator<Item = (&NodeId, &NodeRecord)> {
         self.nodes.iter()
@@ -39,6 +87,24 @@ impl GraphStore {
         self.nodes.get(id)
     }
 
+    /// Returns the node's attachment value (if any).
+    pub fn node_attachment(&self, id: &NodeId) -> Option<&AttachmentValue> {
+        self.node_attachments.get(id)
+    }
+
+    /// Iterate over all node attachment entries (id, value) in deterministic order.
+    ///
+    /// The attachment plane stores entries only when a value exists (`Some`);
+    /// this iterator yields those present values.
+    pub fn iter_node_attachments(&self) -> impl Iterator<Item = (&NodeId, &AttachmentValue)> {
+        self.node_attachments.iter()
+    }
+
+    /// Returns a mutable reference to the node's attachment value (if any).
+    pub fn node_attachment_mut(&mut self, id: &NodeId) -> Option<&mut AttachmentValue> {
+        self.node_attachments.get_mut(id)
+    }
+
     /// Returns an iterator over edges that originate from the provided node.
     ///
     /// Edges are yielded in insertion order. For deterministic traversal
@@ -52,6 +118,58 @@ impl GraphStore {
         self.nodes.get_mut(id)
     }
 
+    /// Sets the node's attachment value.
+    ///
+    /// Passing `None` clears any existing attachment.
+    pub fn set_node_attachment(&mut self, id: NodeId, value: Option<AttachmentValue>) {
+        match value {
+            None => {
+                self.node_attachments.remove(&id);
+            }
+            Some(v) => {
+                self.node_attachments.insert(id, v);
+            }
+        }
+    }
+
+    /// Returns the edge's attachment value (if any).
+    pub fn edge_attachment(&self, id: &EdgeId) -> Option<&AttachmentValue> {
+        self.edge_attachments.get(id)
+    }
+
+    /// Iterate over all edge attachment entries (id, value) in deterministic order.
+    ///
+    /// The attachment plane stores entries only when a value exists (`Some`);
+    /// this iterator yields those present values.
+    pub fn iter_edge_attachments(&self) -> impl Iterator<Item = (&EdgeId, &AttachmentValue)> {
+        self.edge_attachments.iter()
+    }
+
+    /// Returns `true` if an edge with `edge_id` exists in the store.
+    #[must_use]
+    pub fn has_edge(&self, edge_id: &EdgeId) -> bool {
+        self.edge_index.contains_key(edge_id)
+    }
+
+    /// Returns a mutable reference to the edge's attachment value (if any).
+    pub fn edge_attachment_mut(&mut self, id: &EdgeId) -> Option<&mut AttachmentValue> {
+        self.edge_attachments.get_mut(id)
+    }
+
+    /// Sets the edge's attachment value.
+    ///
+    /// Passing `None` clears any existing attachment.
+    pub fn set_edge_attachment(&mut self, id: EdgeId, value: Option<AttachmentValue>) {
+        match value {
+            None => {
+                self.edge_attachments.remove(&id);
+            }
+            Some(v) => {
+                self.edge_attachments.insert(id, v);
+            }
+        }
+    }
+
     /// Inserts or replaces a node in the store.
     pub fn insert_node(&mut self, id: NodeId, record: NodeRecord) {
         self.nodes.insert(id, record);
@@ -84,7 +202,10 @@ impl GraphStore {
             edge.from = from;
         }
         let edge_id = edge.id;
-        if let Some(prev_from) = self.edge_index.insert(edge_id, from) {
+        let to = edge.to;
+        let prev_from = self.edge_index.insert(edge_id, from);
+        let prev_to = self.edge_to_index.insert(edge_id, to);
+        if let Some(prev_from) = prev_from {
             let bucket_is_empty = self.edges_from.get_mut(&prev_from).map_or_else(
                 || {
                     debug_assert!(
@@ -109,7 +230,115 @@ impl GraphStore {
                 self.edges_from.remove(&prev_from);
             }
         }
+        if let Some(prev_to) = prev_to {
+            let bucket_is_empty = self.edges_to.get_mut(&prev_to).map_or_else(
+                || {
+                    debug_assert!(
+                        false,
+                        "edge-to index referenced a missing bucket for edge id: {edge_id:?}"
+                    );
+                    false
+                },
+                |edges| {
+                    let before = edges.len();
+                    edges.retain(|id| *id != edge_id);
+                    if edges.len() == before {
+                        debug_assert!(
+                            false,
+                            "edge-to index referenced an edge missing from its bucket: {edge_id:?}"
+                        );
+                    }
+                    edges.is_empty()
+                },
+            );
+            if bucket_is_empty {
+                self.edges_to.remove(&prev_to);
+            }
+        }
         self.edges_from.entry(from).or_default().push(edge);
+        self.edges_to.entry(to).or_default().push(edge_id);
+    }
+
+    /// Deletes a node and removes any attachments and incident edges.
+    ///
+    /// Returns `true` if the node existed and was removed.
+    pub(crate) fn delete_node_cascade(&mut self, node: NodeId) -> bool {
+        if self.nodes.remove(&node).is_none() {
+            return false;
+        }
+        self.node_attachments.remove(&node);
+
+        // Remove outgoing edges (the bucket).
+        if let Some(out_edges) = self.edges_from.remove(&node) {
+            for e in out_edges {
+                self.edge_index.remove(&e.id);
+                self.edge_to_index.remove(&e.id);
+                let remove_bucket = self.edges_to.get_mut(&e.to).map_or_else(
+                    || {
+                        debug_assert!(
+                            false,
+                            "edge-to index missing inbound bucket for edge id: {:?}",
+                            e.id
+                        );
+                        false
+                    },
+                    |edges| {
+                        edges.retain(|id| *id != e.id);
+                        edges.is_empty()
+                    },
+                );
+                if remove_bucket {
+                    self.edges_to.remove(&e.to);
+                }
+                self.edge_attachments.remove(&e.id);
+            }
+        }
+
+        // Remove inbound edges (reverse adjacency).
+        if let Some(inbound) = self.edges_to.remove(&node) {
+            for edge_id in inbound {
+                let Some(from) = self.edge_index.remove(&edge_id) else {
+                    debug_assert!(
+                        false,
+                        "edge index missing inbound edge id during delete_node_cascade: {edge_id:?}"
+                    );
+                    continue;
+                };
+                let Some(to) = self.edge_to_index.remove(&edge_id) else {
+                    debug_assert!(
+                        false,
+                        "edge-to index missing inbound edge id during delete_node_cascade: {edge_id:?}"
+                    );
+                    continue;
+                };
+                debug_assert_eq!(
+                    to, node,
+                    "inbound edge-to index desynced for edge id: {edge_id:?}"
+                );
+                let Some(edges) = self.edges_from.get_mut(&from) else {
+                    debug_assert!(
+                        false,
+                        "edge index referenced a missing bucket for inbound edge id: {edge_id:?}"
+                    );
+                    continue;
+                };
+                let before = edges.len();
+                edges.retain(|e| e.id != edge_id);
+                if edges.len() == before {
+                    debug_assert!(
+                        false,
+                        "edge index referenced an inbound edge missing from its bucket: {edge_id:?}"
+                    );
+                    continue;
+                }
+                let bucket_is_empty = edges.is_empty();
+                if bucket_is_empty {
+                    self.edges_from.remove(&from);
+                }
+                self.edge_attachments.remove(&edge_id);
+            }
+        }
+        true
     }
 
     /// Deletes an edge from the specified bucket if it exists and matches the reverse index.
@@ -121,6 +350,13 @@ impl GraphStore {
             Some(current_from) if *current_from == from => {}
             _ => return false,
         }
+        let Some(to) = self.edge_to_index.get(&edge_id).copied() else {
+            debug_assert!(
+                false,
+                "edge-to index missing edge id referenced by edge_index: {edge_id:?}"
+            );
+            return false;
+        };
         let Some(edges) = self.edges_from.get_mut(&from) else {
             debug_assert!(
                 false,
@@ -139,9 +375,109 @@ impl GraphStore {
         }
         let bucket_is_empty = edges.is_empty();
         self.edge_index.remove(&edge_id);
+        self.edge_to_index.remove(&edge_id);
         if bucket_is_empty {
             self.edges_from.remove(&from);
         }
+        let remove_bucket = self.edges_to.get_mut(&to).map_or_else(
+            || {
+                debug_assert!(
+                    false,
+                    "edge-to index referenced a missing bucket for edge id: {edge_id:?}"
+                );
+                false
+            },
+            |edges| {
+                edges.retain(|id| *id != edge_id);
+                edges.is_empty()
+            },
+        );
+        if remove_bucket {
+            self.edges_to.remove(&to);
+        }
+        self.edge_attachments.remove(&edge_id);
         true
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::ident::{make_edge_id, make_node_id, make_type_id};
+
+    #[test]
+    fn delete_node_cascade_updates_reverse_indexes() {
+        let mut store = GraphStore::default();
+        let node_ty = make_type_id("node");
+        let edge_ty = make_type_id("edge");
+
+        let a = make_node_id("a");
+        let b = make_node_id("b");
+        let c = make_node_id("c");
+        store.insert_node(a, NodeRecord { ty: node_ty });
+        store.insert_node(b, NodeRecord { ty: node_ty });
+        store.insert_node(c, NodeRecord { ty: node_ty });
+
+        let e1 = make_edge_id("a->b");
+        let e2 = make_edge_id("c->b");
+        let e3 = make_edge_id("b->a");
+        let e4 = make_edge_id("a->c");
+        store.insert_edge(
+            a,
+            EdgeRecord {
+                id: e1,
+                from: a,
+                to: b,
+                ty: edge_ty,
+            },
+        );
+        store.insert_edge(
+            c,
+            EdgeRecord {
+                id: e2,
+                from: c,
+                to: b,
+                ty: edge_ty,
+            },
+        );
+        store.insert_edge(
+            b,
+            EdgeRecord {
+                id: e3,
+                from: b,
+                to: a,
+                ty: edge_ty,
+            },
+        );
+        store.insert_edge(
+            a,
+            EdgeRecord {
+                id: e4,
+                from: a,
+                to: c,
+                ty: edge_ty,
+            },
+        );
+
+        assert!(store.delete_node_cascade(b));
+        assert!(store.node(&b).is_none());
+
+        assert!(!store.has_edge(&e1));
+        assert!(!store.has_edge(&e2));
+        assert!(!store.has_edge(&e3));
+        assert!(store.has_edge(&e4));
+
+        assert!(!store.edge_index.contains_key(&e1));
+        assert!(!store.edge_to_index.contains_key(&e1));
+        assert!(!store.edge_index.contains_key(&e2));
+        assert!(!store.edge_to_index.contains_key(&e2));
+        assert!(!store.edge_index.contains_key(&e3));
+        assert!(!store.edge_to_index.contains_key(&e3));
+        assert_eq!(store.edge_index.get(&e4), Some(&a));
+        assert_eq!(store.edge_to_index.get(&e4), Some(&c));
+
+        assert!(!store.edges_to.contains_key(&b));
+        assert!(!store.edges_to.contains_key(&a));
+        assert_eq!(store.edges_to.get(&c), Some(&vec![e4]));
+    }
+}
diff --git a/crates/warp-core/src/ident.rs b/crates/warp-core/src/ident.rs
index fbfa65f3..7b6cd4e3 100644
--- a/crates/warp-core/src/ident.rs
+++ b/crates/warp-core/src/ident.rs
@@ -14,6 +14,14 @@ pub type Hash = [u8; 32];
 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
 pub struct NodeId(pub Hash);
 
+impl NodeId {
+    /// Returns the canonical byte representation of this id.
+    #[must_use]
+    pub fn as_bytes(&self) -> &Hash {
+        &self.0
+    }
+}
+
 /// Strongly typed identifier for the logical kind of a node or component.
 ///
 /// `TypeId` values are produced by [`make_type_id`] which hashes a label; using
@@ -25,6 +33,22 @@ pub struct TypeId(pub Hash);
 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
 pub struct EdgeId(pub Hash);
 
+/// Strongly typed identifier for a WARP instance.
+///
+/// A `WarpId` namespaces node/edge ids for Stage B1 “flattened indirection”
+/// descended attachments: nodes and edges live in instance-scoped graphs
+/// addressed by `(warp_id, local_id)`.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+pub struct WarpId(pub Hash);
+
+impl WarpId {
+    /// Returns the canonical byte representation of this id.
+    #[must_use]
+    pub fn as_bytes(&self) -> &Hash {
+        &self.0
+    }
+}
+
 /// Produces a stable, domain‑separated type identifier (prefix `b"type:"`) using BLAKE3.
 pub fn make_type_id(label: &str) -> TypeId {
     let mut hasher = Hasher::new();
@@ -57,6 +81,32 @@ pub fn make_edge_id(label: &str) -> EdgeId {
     EdgeId(hasher.finalize().into())
 }
 
+/// Produces a stable, domain-separated warp identifier (prefix `b"warp:"`) using BLAKE3.
+pub fn make_warp_id(label: &str) -> WarpId {
+    let mut hasher = Hasher::new();
+    hasher.update(b"warp:");
+    hasher.update(label.as_bytes());
+    WarpId(hasher.finalize().into())
+}
+
+/// Instance-scoped identifier for a node.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+pub struct NodeKey {
+    /// Warp instance that namespaces the local node id.
+    pub warp_id: WarpId,
+    /// Local node identifier within the instance.
+    pub local_id: NodeId,
+}
+
+/// Instance-scoped identifier for an edge.
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+pub struct EdgeKey {
+    /// Warp instance that namespaces the local edge id.
+    pub warp_id: WarpId,
+    /// Local edge identifier within the instance.
+    pub local_id: EdgeId,
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -67,8 +117,12 @@ mod tests {
         let t = make_type_id(lbl).0;
         let n = make_node_id(lbl).0;
         let e = make_edge_id(lbl).0;
+        let w = make_warp_id(lbl).0;
         assert_ne!(t, n);
         assert_ne!(t, e);
+        assert_ne!(t, w);
         assert_ne!(n, e);
+        assert_ne!(n, w);
+        assert_ne!(e, w);
     }
 }
diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs
index cb052056..4ce39c66 100644
--- a/crates/warp-core/src/lib.rs
+++ b/crates/warp-core/src/lib.rs
@@ -38,6 +38,7 @@
 /// Deterministic math subsystem (Vec3, Mat4, Quat, PRNG).
 pub mod math;
 
+mod attachment;
 mod constants;
 /// Demo implementations showcasing engine capabilities (e.g., motion rule).
 pub mod demo;
@@ -54,8 +55,14 @@ mod scheduler;
 mod snapshot;
 mod tick_patch;
 mod tx;
+mod warp_state;
 
 // Re-exports for stable public API
+/// Attachment-plane atoms and codec boundaries.
+pub use attachment::{
+    AtomPayload, AttachmentKey, AttachmentOwner, AttachmentPlane, AttachmentValue, Codec,
+    CodecRegistry, DecodeError, ErasedCodec, RegistryError,
+};
 /// Canonical digests (e.g., empty inputs, empty length-prefixed lists).
 pub use constants::{BLAKE3_EMPTY, DIGEST_LEN0_U64, POLICY_ID_NO_POLICY_V0};
 /// Demo helpers and constants for the motion rule.
@@ -75,9 +82,15 @@ pub use footprint::{pack_port_key, Footprint, PortKey};
 /// In-memory graph store used by the engine spike.
 pub use graph::GraphStore;
 /// Core identifier types and constructors for nodes, types, and edges.
-pub use ident::{make_edge_id, make_node_id, make_type_id, EdgeId, Hash, NodeId, TypeId};
+pub use ident::{
+    make_edge_id, make_node_id, make_type_id, make_warp_id, EdgeId, EdgeKey, Hash, NodeId, NodeKey,
+    TypeId, WarpId,
+};
 /// Motion payload encoding/decoding helpers.
-pub use payload::{decode_motion_payload, encode_motion_payload};
+pub use payload::{
+    decode_motion_atom_payload, decode_motion_payload, encode_motion_atom_payload,
+    encode_motion_payload, motion_payload_type_id,
+};
 /// Tick receipts for deterministic commits (accepted vs rejected rewrites).
 pub use receipt::{TickReceipt, TickReceiptDisposition, TickReceiptEntry, TickReceiptRejection};
 /// Graph node and edge record types.
@@ -91,6 +104,11 @@ pub use scheduler::SchedulerKind;
 /// Immutable deterministic snapshot.
 pub use snapshot::Snapshot;
 /// Tick patch boundary artifacts (Paper III): replayable delta ops + slot sets.
-pub use tick_patch::{SlotId, TickCommitStatus, TickPatchError, WarpOp, WarpTickPatchV1};
+pub use tick_patch::{
+    slice_worldline_indices, PortalInit, SlotId, TickCommitStatus, TickPatchError, WarpOp,
+    WarpTickPatchV1,
+};
 /// Transaction identifier type.
 pub use tx::TxId;
+/// Stage B1 multi-instance state types (`WarpInstances`).
+pub use warp_state::{WarpInstance, WarpState};
diff --git a/crates/warp-core/src/payload.rs b/crates/warp-core/src/payload.rs
index 338fa9ba..4e8e95ba 100644
--- a/crates/warp-core/src/payload.rs
+++ b/crates/warp-core/src/payload.rs
@@ -1,10 +1,27 @@
 // SPDX-License-Identifier: Apache-2.0
 // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
 //! Canonical payload encoding for the motion demo.
+
+use std::sync::OnceLock;
+
 use bytes::Bytes;
 
+use crate::attachment::AtomPayload;
+use crate::ident::{make_type_id, TypeId};
+
 const POSITION_VELOCITY_BYTES: usize = 24;
 
+static MOTION_PAYLOAD_TYPE_ID: OnceLock<TypeId> = OnceLock::new();
+
+/// Returns the canonical payload `TypeId` for the motion demo atom payload.
+///
+/// This is used as the attachment-plane `type_id` for motion component bytes.
+/// It is cached after the first call to avoid repeated hashing overhead.
+#[must_use]
+pub fn motion_payload_type_id() -> TypeId {
+    *MOTION_PAYLOAD_TYPE_ID.get_or_init(|| make_type_id("payload/motion/v0"))
+}
+
 /// Serialises a 3D position + velocity pair into the canonical payload.
 ///
 /// Note: Values are encoded verbatim as `f32` little‑endian bytes; callers are
@@ -24,6 +41,17 @@ pub fn encode_motion_payload(position: [f32; 3], velocity: [f32; 3]) -> Bytes {
     Bytes::from(buf)
 }
 
+/// Serialises motion data into a typed atom payload (`AtomPayload`).
+///
+/// Equivalent to `AtomPayload { type_id: motion_payload_type_id(), bytes: encode_motion_payload(...) }`.
+#[must_use]
+pub fn encode_motion_atom_payload(position: [f32; 3], velocity: [f32; 3]) -> AtomPayload {
+    AtomPayload::new(
+        motion_payload_type_id(),
+        encode_motion_payload(position, velocity),
+    )
+}
+
 /// Deserialises a canonical motion payload into `(position, velocity)` arrays.
 ///
 /// Expects exactly 24 bytes laid out as six little-endian `f32` values in
@@ -45,6 +73,18 @@ pub fn decode_motion_payload(bytes: &Bytes) -> Option<([f32; 3], [f32; 3])> {
     Some((position, velocity))
 }
 
+/// Deserialises a typed atom payload into `(position, velocity)` arrays.
+///
+/// Returns `None` if the payload `type_id` is not `motion_payload_type_id()` or
+/// if the underlying bytes do not match the canonical motion encoding.
+#[must_use]
+pub fn decode_motion_atom_payload(payload: &AtomPayload) -> Option<([f32; 3], [f32; 3])> {
+    if payload.type_id != motion_payload_type_id() {
+        return None;
+    }
+    decode_motion_payload(&payload.bytes)
+}
+
 #[cfg(test)]
 #[allow(
     clippy::panic,
diff --git a/crates/warp-core/src/receipt.rs b/crates/warp-core/src/receipt.rs
index 26f1f957..ca66489d 100644
--- a/crates/warp-core/src/receipt.rs
+++ b/crates/warp-core/src/receipt.rs
@@ -16,7 +16,7 @@
 
 use blake3::Hasher;
 
-use crate::ident::{Hash, NodeId};
+use crate::ident::{Hash, NodeKey};
 use crate::tx::TxId;
 
 /// A tick receipt: the per-candidate outcomes for a single commit attempt.
@@ -111,8 +111,11 @@ pub struct TickReceiptEntry {
     pub rule_id: Hash,
     /// Scope hash used in the scheduler’s sort key.
     pub scope_hash: Hash,
-    /// Scope node supplied when `Engine::apply` was invoked.
-    pub scope: NodeId,
+    /// Instance-scoped scope node supplied when `Engine::apply` was invoked.
+    ///
+    /// This is a [`NodeKey`]: it contains both the warp instance identifier
+    /// (`warp_id`) and the local node identifier within that instance (`local_id`).
+    pub scope: NodeKey,
     /// Outcome of the candidate rewrite in this tick.
     pub disposition: TickReceiptDisposition,
 }
@@ -139,13 +142,14 @@ fn compute_tick_receipt_digest(entries: &[TickReceiptEntry]) -> Hash {
     }
     let mut hasher = Hasher::new();
     // Receipt format version tag.
-    hasher.update(&1u16.to_le_bytes());
+    hasher.update(&2u16.to_le_bytes());
     // Entry count.
     hasher.update(&(entries.len() as u64).to_le_bytes());
     for entry in entries {
         hasher.update(&entry.rule_id);
         hasher.update(&entry.scope_hash);
-        hasher.update(&(entry.scope).0);
+        hasher.update(entry.scope.warp_id.as_bytes());
+        hasher.update(entry.scope.local_id.as_bytes());
         let code = match entry.disposition {
             TickReceiptDisposition::Applied => 1u8,
             TickReceiptDisposition::Rejected(TickReceiptRejection::FootprintConflict) => 2u8,
@@ -158,21 +162,28 @@ fn compute_tick_receipt_digest(entries: &[TickReceiptEntry]) -> Hash {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::ident::make_node_id;
+    use crate::ident::{make_node_id, make_warp_id};
 
     #[test]
     fn receipt_digest_is_stable_for_same_entries() {
+        let warp_id = crate::ident::make_warp_id("receipt-test-warp");
         let entries = vec![
             TickReceiptEntry {
                 rule_id: [1u8; 32],
                 scope_hash: [2u8; 32],
-                scope: make_node_id("a"),
+                scope: NodeKey {
+                    warp_id,
+                    local_id: make_node_id("a"),
+                },
                 disposition: TickReceiptDisposition::Applied,
             },
             TickReceiptEntry {
                 rule_id: [3u8; 32],
                 scope_hash: [4u8; 32],
-                scope: make_node_id("b"),
+                scope: NodeKey {
+                    warp_id,
+                    local_id: make_node_id("b"),
+                },
                 disposition: TickReceiptDisposition::Rejected(
                     TickReceiptRejection::FootprintConflict,
                 ),
@@ -183,4 +194,35 @@ mod tests {
         assert_eq!(digest_a, digest_b);
         assert_ne!(digest_a, *crate::constants::DIGEST_LEN0_U64);
     }
+
+    #[test]
+    fn receipt_digest_includes_warp_id() {
+        let warp_id_a = make_warp_id("receipt-test-warp-a");
+        let warp_id_b = make_warp_id("receipt-test-warp-b");
+        let node = make_node_id("receipt-test-node");
+
+        let entries_a = vec![TickReceiptEntry {
+            rule_id: [1u8; 32],
+            scope_hash: [2u8; 32],
+            scope: NodeKey {
+                warp_id: warp_id_a,
+                local_id: node,
+            },
+            disposition: TickReceiptDisposition::Applied,
+        }];
+
+        let entries_b = vec![TickReceiptEntry {
+            rule_id: [1u8; 32],
+            scope_hash: [2u8; 32],
+            scope: NodeKey {
+                warp_id: warp_id_b,
+                local_id: node,
+            },
+            disposition: TickReceiptDisposition::Applied,
+        }];
+
+        let digest_a = compute_tick_receipt_digest(&entries_a);
+        let digest_b = compute_tick_receipt_digest(&entries_b);
+        assert_ne!(digest_a, digest_b);
+    }
 }
diff --git a/crates/warp-core/src/record.rs b/crates/warp-core/src/record.rs
index 957d0fd2..f8e56e59 100644
--- a/crates/warp-core/src/record.rs
+++ b/crates/warp-core/src/record.rs
@@ -1,25 +1,24 @@
 // SPDX-License-Identifier: Apache-2.0
 // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
 //! Graph record types: nodes and edges.
-use bytes::Bytes;
 
 use crate::ident::{EdgeId, NodeId, TypeId};
 
 /// Materialised record for a single node stored in the graph.
 ///
-/// The optional `payload` carries domain-specific bytes (component data,
-/// attachments, etc) and is interpreted by higher layers.
+/// Node records are **skeleton-plane only**: they describe structural identity
+/// (currently: the node type) but do not carry attachment payloads.
+///
+/// Attachment-plane payloads are stored separately (see [`crate::AttachmentValue`])
+/// and are addressed via [`crate::AttachmentKey`] / [`crate::SlotId`].
 ///
 /// Invariants
 /// - `ty` must be a valid type identifier in the current schema.
 /// - The node identifier is not embedded here; the store supplies it externally.
-/// - `payload` encoding is caller-defined and opaque to the store.
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub struct NodeRecord {
     /// Type identifier describing the node.
     pub ty: TypeId,
-    /// Optional payload owned by the node (component data, attachments, etc.).
-    pub payload: Option<Bytes>,
 }
 
 /// Materialised record for a single edge stored in the graph.
@@ -28,7 +27,6 @@ pub struct NodeRecord {
 /// - `from` and `to` reference existing nodes in the same store.
 /// - `id` is stable across runs for the same logical edge.
 /// - `ty` must be a valid edge type in the current schema.
-/// - `payload` encoding is caller-defined and opaque to the store.
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub struct EdgeRecord {
     /// Stable identifier for the edge.
@@ -39,6 +37,4 @@ pub struct EdgeRecord {
     pub to: NodeId,
     /// Type identifier describing the edge.
     pub ty: TypeId,
-    /// Optional payload owned by the edge.
-    pub payload: Option<Bytes>,
 }
diff --git a/crates/warp-core/src/scheduler.rs b/crates/warp-core/src/scheduler.rs
index d3c5de0a..8328b0f9 100644
--- a/crates/warp-core/src/scheduler.rs
+++ b/crates/warp-core/src/scheduler.rs
@@ -15,7 +15,7 @@ use std::collections::{BTreeMap, HashMap};
 use rustc_hash::FxHashMap;
 
 use crate::footprint::Footprint;
-use crate::ident::{CompactRuleId, Hash, NodeId};
+use crate::ident::{CompactRuleId, EdgeKey, Hash, NodeId, NodeKey};
 #[cfg(feature = "telemetry")]
 use crate::telemetry;
 use crate::tx::TxId;
@@ -24,13 +24,17 @@ use crate::tx::TxId;
 #[derive(Debug)]
 pub(crate) struct ActiveFootprints {
     /// Nodes written by reserved rewrites
-    nodes_written: GenSet<NodeId>,
+    nodes_written: GenSet<NodeKey>,
     /// Nodes read by reserved rewrites
-    nodes_read: GenSet<NodeId>,
+    nodes_read: GenSet<NodeKey>,
     /// Edges written by reserved rewrites
-    edges_written: GenSet<crate::ident::EdgeId>,
+    edges_written: GenSet<EdgeKey>,
     /// Edges read by reserved rewrites
-    edges_read: GenSet<crate::ident::EdgeId>,
+    edges_read: GenSet<EdgeKey>,
+    /// Attachments written by reserved rewrites
+    attachments_written: GenSet<crate::attachment::AttachmentKey>,
+    /// Attachments read by reserved rewrites
+    attachments_read: GenSet<crate::attachment::AttachmentKey>,
     /// Boundary ports touched (both `b_in` and `b_out`, since any intersection conflicts)
     ports: GenSet<crate::footprint::PortKey>,
 }
@@ -42,6 +46,8 @@ impl ActiveFootprints {
             nodes_read: GenSet::new(),
             edges_written: GenSet::new(),
             edges_read: GenSet::new(),
+            attachments_written: GenSet::new(),
+            attachments_read: GenSet::new(),
             ports: GenSet::new(),
         }
     }
@@ -70,7 +76,7 @@ pub(crate) struct PendingRewrite {
     /// Scope hash used for deterministic ordering (full 32 bytes).
     pub scope_hash: Hash,
     /// Scope node supplied when `apply` was invoked.
-    pub scope: NodeId,
+    pub scope: NodeKey,
     /// Footprint used for independence checks and conflict resolution.
     pub footprint: Footprint,
     /// State machine phase for the rewrite.
@@ -177,7 +183,11 @@ impl RadixScheduler {
         // Node writes conflict with prior writes OR reads
         for node_hash in pr.footprint.n_write.iter() {
             let node_id = NodeId(*node_hash);
-            if active.nodes_written.contains(node_id) || active.nodes_read.contains(node_id) {
+            let key = NodeKey {
+                warp_id: pr.scope.warp_id,
+                local_id: node_id,
+            };
+            if active.nodes_written.contains(key) || active.nodes_read.contains(key) {
                 return true;
             }
         }
@@ -185,7 +195,11 @@ impl RadixScheduler {
         // Node reads conflict with prior writes (but NOT prior reads)
         for node_hash in pr.footprint.n_read.iter() {
             let node_id = NodeId(*node_hash);
-            if active.nodes_written.contains(node_id) {
+            let key = NodeKey {
+                warp_id: pr.scope.warp_id,
+                local_id: node_id,
+            };
+            if active.nodes_written.contains(key) {
                 return true;
             }
         }
@@ -193,7 +207,11 @@ impl RadixScheduler {
         // Edge writes conflict with prior writes OR reads
         for edge_hash in pr.footprint.e_write.iter() {
             let edge_id = EdgeId(*edge_hash);
-            if active.edges_written.contains(edge_id) || active.edges_read.contains(edge_id) {
+            let key = EdgeKey {
+                warp_id: pr.scope.warp_id,
+                local_id: edge_id,
+            };
+            if active.edges_written.contains(key) || active.edges_read.contains(key) {
                 return true;
             }
         }
@@ -201,7 +219,25 @@ impl RadixScheduler {
         // Edge reads conflict with prior writes (but NOT prior reads)
         for edge_hash in pr.footprint.e_read.iter() {
             let edge_id = EdgeId(*edge_hash);
-            if active.edges_written.contains(edge_id) {
+            let key = EdgeKey {
+                warp_id: pr.scope.warp_id,
+                local_id: edge_id,
+            };
+            if active.edges_written.contains(key) {
+                return true;
+            }
+        }
+
+        // Attachment writes conflict with prior writes OR reads.
+        for key in pr.footprint.a_write.iter() {
+            if active.attachments_written.contains(*key) || active.attachments_read.contains(*key) {
+                return true;
+            }
+        }
+
+        // Attachment reads conflict with prior writes (but NOT prior reads).
+        for key in pr.footprint.a_read.iter() {
+            if active.attachments_written.contains(*key) {
                 return true;
             }
         }
@@ -226,16 +262,34 @@ impl RadixScheduler {
         use crate::ident::EdgeId;
 
         for node_hash in pr.footprint.n_write.iter() {
-            active.nodes_written.mark(NodeId(*node_hash));
+            active.nodes_written.mark(NodeKey {
+                warp_id: pr.scope.warp_id,
+                local_id: NodeId(*node_hash),
+            });
         }
         for node_hash in pr.footprint.n_read.iter() {
-            active.nodes_read.mark(NodeId(*node_hash));
+            active.nodes_read.mark(NodeKey {
+                warp_id: pr.scope.warp_id,
+                local_id: NodeId(*node_hash),
+            });
         }
         for edge_hash in pr.footprint.e_write.iter() {
-            active.edges_written.mark(EdgeId(*edge_hash));
+            active.edges_written.mark(EdgeKey {
+                warp_id: pr.scope.warp_id,
+                local_id: EdgeId(*edge_hash),
+            });
         }
         for edge_hash in pr.footprint.e_read.iter() {
-            active.edges_read.mark(EdgeId(*edge_hash));
+            active.edges_read.mark(EdgeKey {
+                warp_id: pr.scope.warp_id,
+                local_id: EdgeId(*edge_hash),
+            });
+        }
+        for key in pr.footprint.a_write.iter() {
+            active.attachments_written.mark(*key);
+        }
+        for key in pr.footprint.a_read.iter() {
+            active.attachments_read.mark(*key);
         }
         for port_key in pr.footprint.b_in.keys() {
             active.ports.mark(*port_key);
@@ -690,7 +744,7 @@ impl DeterministicScheduler {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::ident::make_node_id;
+    use crate::ident::{make_node_id, NodeKey, WarpId};
 
     // Test-only helper: pack a boundary port key from components.
     #[inline]
@@ -712,10 +766,21 @@ mod tests {
         out
     }
 
+    fn test_warp_id() -> WarpId {
+        crate::ident::make_warp_id("scheduler-test-warp")
+    }
+
+    fn scope_key(label: &str) -> NodeKey {
+        NodeKey {
+            warp_id: test_warp_id(),
+            local_id: make_node_id(label),
+        }
+    }
+
     #[test]
     fn drain_for_tx_returns_deterministic_order() {
         let tx = TxId::from_raw(1);
-        let scope = make_node_id("s");
+        let scope = scope_key("s");
         let mut sched = RadixScheduler::default();
 
         // Insert out of lexicographic order: (2,1), (1,2), (1,1)
@@ -746,7 +811,7 @@ mod tests {
     #[test]
     fn last_wins_dedupe() {
         let tx = TxId::from_raw(1);
-        let scope = make_node_id("s");
+        let scope = scope_key("s");
         let mut sched = RadixScheduler::default();
         let scope_h = h(5);
 
@@ -806,7 +871,7 @@ mod tests {
             rule_id: h(1),
             compact_rule: CompactRuleId(1),
             scope_hash: h(1),
-            scope: make_node_id("scope1"),
+            scope: scope_key("scope1"),
             footprint: Footprint {
                 factor_mask: 0b0001, // Set factor mask so independence check proceeds
                 ..Default::default()
@@ -820,7 +885,7 @@ mod tests {
             rule_id: h(2),
             compact_rule: CompactRuleId(2),
             scope_hash: h(2),
-            scope: make_node_id("scope2"),
+            scope: scope_key("scope2"),
             footprint: Footprint {
                 factor_mask: 0b0001, // Overlapping factor mask
                 ..Default::default()
@@ -858,7 +923,7 @@ mod tests {
             rule_id: h(1),
             compact_rule: CompactRuleId(1),
             scope_hash: h(1),
-            scope: make_node_id("scope1"),
+            scope: scope_key("scope1"),
             footprint: Footprint {
                 factor_mask: 0b0001,
                 ..Default::default()
@@ -872,7 +937,7 @@ mod tests {
             rule_id: h(2),
             compact_rule: CompactRuleId(2),
             scope_hash: h(2),
-            scope: make_node_id("scope2"),
+            scope: scope_key("scope2"),
             footprint: Footprint {
                 factor_mask: 0b0001,
                 ..Default::default()
@@ -910,7 +975,7 @@ mod tests {
             rule_id: h(1),
             compact_rule: CompactRuleId(1),
             scope_hash: h(1),
-            scope: make_node_id("scope1"),
+            scope: scope_key("scope1"),
             footprint: Footprint {
                 factor_mask: 0b0001,
                 ..Default::default()
@@ -924,7 +989,7 @@ mod tests {
             rule_id: h(2),
             compact_rule: CompactRuleId(2),
             scope_hash: h(2),
-            scope: make_node_id("scope2"),
+            scope: scope_key("scope2"),
             footprint: Footprint {
                 factor_mask: 0b0001,
                 ..Default::default()
@@ -960,7 +1025,7 @@ mod tests {
             rule_id: h(1),
             compact_rule: CompactRuleId(1),
             scope_hash: h(1),
-            scope: make_node_id("scope1"),
+            scope: scope_key("scope1"),
             footprint: Footprint {
                 factor_mask: 0b0001,
                 ..Default::default()
@@ -974,7 +1039,7 @@ mod tests {
             rule_id: h(2),
             compact_rule: CompactRuleId(2),
             scope_hash: h(2),
-            scope: make_node_id("scope2"),
+            scope: scope_key("scope2"),
             footprint: Footprint {
                 factor_mask: 0b0001,
                 ..Default::default()
@@ -999,6 +1064,63 @@ mod tests {
         );
     }
 
+    #[test]
+    fn reserve_should_detect_descent_chain_attachment_conflict_across_instances() {
+        use crate::attachment::AttachmentKey;
+        use crate::ident::make_warp_id;
+
+        let tx = TxId::from_raw(1);
+        let mut sched = RadixScheduler::default();
+
+        let warp_root = make_warp_id("root");
+        let warp_child = make_warp_id("child");
+        let portal_owner = make_node_id("portal-node");
+        let portal_key = AttachmentKey::node_alpha(NodeKey {
+            warp_id: warp_root,
+            local_id: portal_owner,
+        });
+
+        // Rewrite in root instance: changes the portal attachment (write).
+        let mut rewrite_root = PendingRewrite {
+            rule_id: h(1),
+            compact_rule: CompactRuleId(1),
+            scope_hash: h(1),
+            scope: NodeKey {
+                warp_id: warp_root,
+                local_id: make_node_id("scope-root"),
+            },
+            footprint: Footprint {
+                factor_mask: 0b0001,
+                ..Default::default()
+            },
+            phase: RewritePhase::Matched,
+        };
+        rewrite_root.footprint.a_write.insert(portal_key);
+
+        // Rewrite in descendant instance: must READ the portal chain (descent stack).
+        let mut rewrite_child = PendingRewrite {
+            rule_id: h(2),
+            compact_rule: CompactRuleId(2),
+            scope_hash: h(2),
+            scope: NodeKey {
+                warp_id: warp_child,
+                local_id: make_node_id("scope-child"),
+            },
+            footprint: Footprint {
+                factor_mask: 0b0001,
+                ..Default::default()
+            },
+            phase: RewritePhase::Matched,
+        };
+        rewrite_child.footprint.a_read.insert(portal_key);
+
+        assert!(sched.reserve(tx, &mut rewrite_root));
+        assert!(
+            !sched.reserve(tx, &mut rewrite_child),
+            "descendant rewrite must conflict when the portal chain is written"
+        );
+    }
+
     #[test]
     fn reserve_is_atomic_no_partial_marking_on_conflict() {
         // This test proves that if a conflict is detected, NO resources are marked.
@@ -1013,7 +1135,7 @@ mod tests {
             rule_id: h(1),
             compact_rule: CompactRuleId(1),
             scope_hash: h(1),
-            scope: make_node_id("scope1"),
+            scope: scope_key("scope1"),
             footprint: Footprint {
                 factor_mask: 0b0001,
                 ..Default::default()
@@ -1033,7 +1155,7 @@ mod tests {
             rule_id: h(2),
             compact_rule: CompactRuleId(2),
             scope_hash: h(2),
-            scope: make_node_id("scope2"),
+            scope: scope_key("scope2"),
             footprint: Footprint {
                 factor_mask: 0b0001,
                 ..Default::default()
@@ -1054,7 +1176,7 @@ mod tests {
             rule_id: h(3),
             compact_rule: CompactRuleId(3),
             scope_hash: h(3),
-            scope: make_node_id("scope3"),
+            scope: scope_key("scope3"),
             footprint: Footprint {
                 factor_mask: 0b0001,
                 ..Default::default()
@@ -1085,7 +1207,7 @@ mod tests {
                 rule_id: h(1),
                 compact_rule: CompactRuleId(1),
                 scope_hash: h(1),
-                scope: make_node_id("s1"),
+                scope: scope_key("s1"),
                 footprint: Footprint {
                     factor_mask: 1,
                     ..Default::default()
@@ -1100,7 +1222,7 @@ mod tests {
                 rule_id: h(2),
                 compact_rule: CompactRuleId(2),
                 scope_hash: h(2),
-                scope: make_node_id("s2"),
+                scope: scope_key("s2"),
                 footprint: Footprint {
                     factor_mask: 1,
                     ..Default::default()
@@ -1115,7 +1237,7 @@ mod tests {
                 rule_id: h(3),
                 compact_rule: CompactRuleId(3),
                 scope_hash: h(3),
-                scope: make_node_id("s3"),
+                scope: scope_key("s3"),
                 footprint: Footprint {
                     factor_mask: 1,
                     ..Default::default()
@@ -1130,7 +1252,7 @@ mod tests {
                 rule_id: h(4),
                 compact_rule: CompactRuleId(4),
                 scope_hash: h(4),
-                scope: make_node_id("s4"),
+                scope: scope_key("s4"),
                 footprint: Footprint {
                     factor_mask: 1,
                     ..Default::default()
@@ -1180,7 +1302,7 @@ mod tests {
                 rule_id: h(i),
                 compact_rule: CompactRuleId(u32::from(i)),
                 scope_hash: h(i),
-                scope: make_node_id(&format!("prior_{i}")),
+                scope: scope_key(&format!("prior_{i}")),
                 footprint: Footprint {
                     factor_mask: 0b0001,
                     ..Default::default()
@@ -1205,7 +1327,7 @@ mod tests {
                 rule_id: h(200),
                 compact_rule: CompactRuleId(200),
                 scope_hash: h(200),
-                scope: make_node_id(&format!("test_{size}")),
+                scope: scope_key(&format!("test_{size}")),
                 footprint: Footprint {
                     factor_mask: 0b0001,
                     ..Default::default()
@@ -1237,7 +1359,7 @@ mod tests {
                     rule_id: h(i),
                     compact_rule: CompactRuleId(u32::from(i)),
                     scope_hash: h(i),
-                    scope: make_node_id(&format!("prior_{i}")),
+                    scope: scope_key(&format!("prior_{i}")),
                     footprint: Footprint {
                         factor_mask: 0b0001,
                         ..Default::default()
@@ -1274,7 +1396,7 @@ mod tests {
             rule_id: h(1),
             compact_rule: CompactRuleId(1),
             scope_hash: h(1),
-            scope: make_node_id("scope1"),
+            scope: scope_key("scope1"),
             footprint: Footprint::default(),
             phase: RewritePhase::Matched,
         };
@@ -1287,7 +1409,7 @@ mod tests {
             rule_id: h(2),
             compact_rule: CompactRuleId(2),
             scope_hash: h(2),
-            scope: make_node_id("scope2"),
+            scope: scope_key("scope2"),
             footprint: Footprint::default(),
             phase: RewritePhase::Matched,
         };
diff --git a/crates/warp-core/src/snapshot.rs b/crates/warp-core/src/snapshot.rs
index ebf772da..d9986812 100644
--- a/crates/warp-core/src/snapshot.rs
+++ b/crates/warp-core/src/snapshot.rs
@@ -16,8 +16,10 @@
 //!   sorted by ascending `EdgeId` before being encoded.
 //! - Encoding is fixed-size and architecture-independent:
 //!   - All ids (`NodeId`, `TypeId`, `EdgeId`) are raw 32-byte values.
-//!   - Payloads are prefixed by an 8-byte little-endian length, followed by the
-//!     exact payload bytes (or length `0` with no payload).
+//!   - Payloads are encoded as:
+//!     - 1 byte presence tag (`0` = None, `1` = Some)
+//!     - when present: payload `type_id` (32 bytes), then 8-byte little-endian
+//!       length, then the exact payload bytes.
 //! - The root id is included first to bind the subgraph identity.
 //!
 //! Notes
@@ -31,10 +33,11 @@ use std::collections::{BTreeSet, VecDeque};
 
 use blake3::Hasher;
 
-use crate::graph::GraphStore;
-use crate::ident::{Hash, NodeId};
+use crate::attachment::{AtomPayload, AttachmentKey, AttachmentOwner, AttachmentValue};
+use crate::ident::{Hash, NodeKey, WarpId};
 use crate::record::EdgeRecord;
 use crate::tx::TxId;
+use crate::warp_state::WarpState;
 
 /// Snapshot returned after a successful commit.
 ///
@@ -44,7 +47,7 @@ use crate::tx::TxId;
 #[derive(Debug, Clone)]
 pub struct Snapshot {
     /// Node identifier that serves as the root of the snapshot.
-    pub root: NodeId,
+    pub root: NodeKey,
     /// Canonical commit hash derived from `state_root` + metadata (see below).
     pub hash: Hash,
     /// Parent snapshot hashes (empty for initial commit, 1 for linear history, 2+ for merges).
@@ -71,88 +74,128 @@ pub struct Snapshot {
     pub tx: TxId,
 }
 
-/// Computes a canonical hash for the current graph state.
-///
-/// Algorithm
-/// 1) Update with `root` id bytes.
-/// 2) For each `(node_id, node)` in `store.nodes` (ascending by `node_id`):
-///    - Update with `node_id`, `node.ty`.
-///    - Update with 8-byte LE payload length, then payload bytes (if any).
-/// 3) For each `(from, edges)` in `store.edges_from` (ascending by `from`):
-///    - Update with `from` id and edge count (8-byte LE).
-///    - Sort `edges` by `edge.id` ascending and for each edge:
-///      - Update with `edge.id`, `edge.ty`, `edge.to`.
-///      - Update with 8-byte LE payload length, then payload bytes (if any).
-pub(crate) fn compute_snapshot_hash(store: &GraphStore, root: &NodeId) -> Hash {
-    // 1) Determine reachable subgraph using a deterministic BFS over outgoing edges.
-    let mut reachable: BTreeSet<NodeId> = BTreeSet::new();
-    let mut queue: VecDeque<NodeId> = VecDeque::new();
-    reachable.insert(*root);
+/// Computes the canonical state root hash (graph only).
+pub(crate) fn compute_state_root(state: &WarpState, root: &NodeKey) -> Hash {
+    // 1) Determine reachable nodes across instances via deterministic BFS:
+    // - follow skeleton edges within an instance
+    // - follow any `Descend(WarpId)` attachments on reachable nodes/edges
+    let mut reachable_nodes: BTreeSet<NodeKey> = BTreeSet::new();
+    let mut reachable_warps: BTreeSet<WarpId> = BTreeSet::new();
+    let mut queue: VecDeque<NodeKey> = VecDeque::new();
+
+    reachable_nodes.insert(*root);
+    reachable_warps.insert(root.warp_id);
     queue.push_back(*root);
+
     while let Some(current) = queue.pop_front() {
-        for edge in store.edges_from(&current) {
-            if reachable.insert(edge.to) {
-                queue.push_back(edge.to);
+        let Some(store) = state.store(&current.warp_id) else {
+            debug_assert!(
+                false,
+                "reachable traversal referenced missing warp store: {:?}",
+                current.warp_id
+            );
+            continue;
+        };
+
+        for edge in store.edges_from(&current.local_id) {
+            let to = NodeKey {
+                warp_id: current.warp_id,
+                local_id: edge.to,
+            };
+            if reachable_nodes.insert(to) {
+                queue.push_back(to);
+            }
+
+            if let Some(AttachmentValue::Descend(child_warp)) = store.edge_attachment(&edge.id) {
+                enqueue_descend(
+                    state,
+                    *child_warp,
+                    &mut reachable_warps,
+                    &mut reachable_nodes,
+                    &mut queue,
+                );
             }
         }
+
+        if let Some(AttachmentValue::Descend(child_warp)) = store.node_attachment(&current.local_id)
+        {
+            enqueue_descend(
+                state,
+                *child_warp,
+                &mut reachable_warps,
+                &mut reachable_nodes,
+                &mut queue,
+            );
+        }
     }
 
+    // 2) Hash reachable instance content in canonical order.
     let mut hasher = Hasher::new();
-    hasher.update(&root.0);
+    hasher.update(&(root.warp_id).0);
+    hasher.update(&(root.local_id).0);
 
-    // 2) Hash nodes in ascending NodeId order but only if reachable.
-    for (node_id, node) in &store.nodes {
-        if !reachable.contains(node_id) {
+    for warp_id in &reachable_warps {
+        let Some(instance) = state.instance(warp_id) else {
+            debug_assert!(false, "missing warp instance metadata: {warp_id:?}");
             continue;
-        }
-        hasher.update(&node_id.0);
-        hasher.update(&(node.ty).0);
-        match &node.payload {
-            Some(payload) => {
-                hasher.update(&(payload.len() as u64).to_le_bytes());
-                hasher.update(payload);
-            }
-            None => {
-                hasher.update(&0u64.to_le_bytes());
+        };
+        let Some(store) = state.store(warp_id) else {
+            debug_assert!(false, "missing warp store for instance: {warp_id:?}");
+            continue;
+        };
+
+        // Instance header: bind metadata into the deterministic boundary.
+        hasher.update(&(instance.warp_id).0);
+        hasher.update(&(instance.root_node).0);
+        hash_attachment_key_opt(&mut hasher, instance.parent.as_ref());
+
+        // Nodes: ascending NodeId order, filtered to reachable nodes in this warp.
+        for (node_id, node) in &store.nodes {
+            let key = NodeKey {
+                warp_id: *warp_id,
+                local_id: *node_id,
+            };
+            if !reachable_nodes.contains(&key) {
+                continue;
             }
+            hasher.update(&node_id.0);
+            hasher.update(&(node.ty).0);
+            hash_attachment_value_opt(&mut hasher, store.node_attachment(node_id));
         }
-    }
 
-    // 3) Hash outgoing edges per reachable source, sorted by EdgeId, and only
-    // include edges whose destination is also reachable.
-    for (from, edges) in &store.edges_from {
-        if !reachable.contains(from) {
-            continue;
-        }
-        // Filter to reachable targets first; length counts included edges only.
-        let mut sorted_edges: Vec<&EdgeRecord> =
-            edges.iter().filter(|e| reachable.contains(&e.to)).collect();
-        sorted_edges.sort_by(|a, b| a.id.0.cmp(&b.id.0));
-
-        hasher.update(&from.0);
-        hasher.update(&(sorted_edges.len() as u64).to_le_bytes());
-        for edge in sorted_edges {
-            hasher.update(&(edge.id).0);
-            hasher.update(&(edge.ty).0);
-            hasher.update(&(edge.to).0);
-            match &edge.payload {
-                Some(payload) => {
-                    hasher.update(&(payload.len() as u64).to_le_bytes());
-                    hasher.update(payload);
-                }
-                None => {
-                    hasher.update(&0u64.to_le_bytes());
-                }
+        // Edges: per reachable source node bucket, sorted by EdgeId, filtered to reachable targets.
+        for (from, edges) in &store.edges_from {
+            let from_key = NodeKey {
+                warp_id: *warp_id,
+                local_id: *from,
+            };
+            if !reachable_nodes.contains(&from_key) {
+                continue;
+            }
+
+            let mut sorted_edges: Vec<&EdgeRecord> = edges
+                .iter()
+                .filter(|e| {
+                    reachable_nodes.contains(&NodeKey {
+                        warp_id: *warp_id,
+                        local_id: e.to,
+                    })
+                })
+                .collect();
+            sorted_edges.sort_by(|a, b| a.id.0.cmp(&b.id.0));
+
+            hasher.update(&from.0);
+            hasher.update(&(sorted_edges.len() as u64).to_le_bytes());
+            for edge in sorted_edges {
+                hasher.update(&(edge.id).0);
+                hasher.update(&(edge.ty).0);
+                hasher.update(&(edge.to).0);
+                hash_attachment_value_opt(&mut hasher, store.edge_attachment(&edge.id));
             }
         }
     }
-    hasher.finalize().into()
-}
 
-/// Computes the canonical state root hash (graph only) using the same
-/// reachable‑only traversal as `compute_snapshot_hash`.
-pub(crate) fn compute_state_root(store: &GraphStore, root: &NodeId) -> Hash {
-    compute_snapshot_hash(store, root)
+    hasher.finalize().into()
 }
 
 /// Computes the final commit hash from the state root and metadata digests.
@@ -213,3 +256,86 @@ pub(crate) fn compute_commit_hash_v2(
 // Tests for commit header encoding and hashing live under PR-09
 // (branch: echo/pr-09-blake3-header-tests). Intentionally omitted here
 // to keep PR-10 scope to README/docs/CI and avoid duplicate content.
+
+fn enqueue_descend(
+    state: &WarpState,
+    child_warp: WarpId,
+    reachable_warps: &mut BTreeSet<WarpId>,
+    reachable_nodes: &mut BTreeSet<NodeKey>,
+    queue: &mut VecDeque<NodeKey>,
+) {
+    reachable_warps.insert(child_warp);
+    let Some(child) = state.instance(&child_warp) else {
+        debug_assert!(
+            false,
+            "descend referenced missing warp instance metadata: {child_warp:?}"
+        );
+        return;
+    };
+    let child_root = NodeKey {
+        warp_id: child_warp,
+        local_id: child.root_node,
+    };
+    if reachable_nodes.insert(child_root) {
+        queue.push_back(child_root);
+    }
+}
+
+fn hash_attachment_key_opt(hasher: &mut Hasher, key: Option<&AttachmentKey>) {
+    match key {
+        None => {
+            hasher.update(&[0u8]);
+        }
+        Some(key) => {
+            hasher.update(&[1u8]);
+            hash_attachment_key(hasher, key);
+        }
+    }
+}
+
+fn hash_attachment_key(hasher: &mut Hasher, key: &AttachmentKey) {
+    let (owner_tag, plane_tag) = key.tag();
+    hasher.update(&[owner_tag]);
+    hasher.update(&[plane_tag]);
+    match key.owner {
+        AttachmentOwner::Node(node) => {
+            hasher.update(&(node.warp_id).0);
+            hasher.update(&(node.local_id).0);
+        }
+        AttachmentOwner::Edge(edge) => {
+            hasher.update(&(edge.warp_id).0);
+            hasher.update(&(edge.local_id).0);
+        }
+    }
+}
+
+fn hash_attachment_value_opt(hasher: &mut Hasher, value: Option<&AttachmentValue>) {
+    match value {
+        None => {
+            hasher.update(&[0u8]);
+        }
+        Some(value) => {
+            hasher.update(&[1u8]);
+            hash_attachment_value(hasher, value);
+        }
+    }
+}
+
+fn hash_attachment_value(hasher: &mut Hasher, value: &AttachmentValue) {
+    match value {
+        AttachmentValue::Atom(atom) => {
+            hasher.update(&[1u8]);
+            hash_atom_payload(hasher, atom);
+        }
+        AttachmentValue::Descend(warp_id) => {
+            hasher.update(&[2u8]);
+            hasher.update(&warp_id.0);
+        }
+    }
+}
+
+fn hash_atom_payload(hasher: &mut Hasher, atom: &AtomPayload) {
+    hasher.update(&(atom.type_id).0);
+    hasher.update(&(atom.bytes.len() as u64).to_le_bytes());
+    hasher.update(&atom.bytes);
+}
diff --git a/crates/warp-core/src/tick_patch.rs b/crates/warp-core/src/tick_patch.rs
index 1da780f8..0d7aceaf 100644
--- a/crates/warp-core/src/tick_patch.rs
+++ b/crates/warp-core/src/tick_patch.rs
@@ -15,10 +15,14 @@
 use blake3::Hasher;
 use thiserror::Error;
 
+use crate::attachment::{
+    AtomPayload, AttachmentKey, AttachmentOwner, AttachmentPlane, AttachmentValue,
+};
 use crate::footprint::PortKey;
 use crate::graph::GraphStore;
-use crate::ident::{EdgeId, Hash as ContentHash, NodeId};
+use crate::ident::{EdgeId, EdgeKey, Hash as ContentHash, NodeId, NodeKey, WarpId};
 use crate::record::{EdgeRecord, NodeRecord};
+use crate::warp_state::{WarpInstance, WarpState};
 
 /// Commit status of a tick patch.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -41,10 +45,12 @@ impl TickCommitStatus {
 /// Unversioned slot identifier for slicing and provenance bookkeeping.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub enum SlotId {
-    /// Full node record at `NodeId` (type id + payload bytes).
-    Node(NodeId),
-    /// Full edge record at `EdgeId` (from/to/type/payload).
-    Edge(EdgeId),
+    /// Full node record at `NodeKey` (instance-scoped skeleton record).
+    Node(NodeKey),
+    /// Full edge record at `EdgeKey` (instance-scoped skeleton record).
+    Edge(EdgeKey),
+    /// Attachment slot (node/edge plane payload, including `Descend` links).
+    Attachment(AttachmentKey),
     /// Boundary port value (opaque key).
     Port(PortKey),
 }
@@ -54,7 +60,8 @@ impl SlotId {
         match self {
             Self::Node(_) => 1,
             Self::Edge(_) => 2,
-            Self::Port(_) => 3,
+            Self::Attachment(_) => 3,
+            Self::Port(_) => 4,
         }
     }
 }
@@ -72,8 +79,9 @@ impl Ord for SlotId {
             return tag_cmp;
         }
         match (*self, *other) {
-            (Self::Node(a), Self::Node(b)) => a.0.cmp(&b.0),
-            (Self::Edge(a), Self::Edge(b)) => a.0.cmp(&b.0),
+            (Self::Node(a), Self::Node(b)) => a.cmp(&b),
+            (Self::Edge(a), Self::Edge(b)) => a.cmp(&b),
+            (Self::Attachment(a), Self::Attachment(b)) => a.cmp(&b),
             (Self::Port(a), Self::Port(b)) => a.cmp(&b),
             // SAFETY: tag comparison above guarantees matching variants.
             _ => unreachable!("tag mismatch in SlotId::cmp"),
@@ -84,62 +92,193 @@ impl Ord for SlotId {
 /// A canonical delta operation applied to the graph store.
 #[derive(Debug, Clone)]
 pub enum WarpOp {
+    /// Open a descended attachment portal atomically (Stage B1.1).
+    ///
+    /// This is the canonical authoring operation for descended attachments:
+    /// it is illegal for a replay/slice to observe a “dangling portal”
+    /// (`Descend(child_warp)` without a corresponding `WarpInstance`), or an
+    /// “orphan instance” (a `WarpInstance` whose `parent` slot does not point to it).
+    ///
+    /// Semantics:
+    /// - Ensure `WarpInstance(child_warp)` exists with `parent = Some(key)` and
+    ///   `root_node = child_root`.
+    /// - Ensure the child root node exists (via `init`).
+    /// - Set `Attachment[key] = Descend(child_warp)`.
+    OpenPortal {
+        /// Attachment slot key that will point to the child instance.
+        key: AttachmentKey,
+        /// Child instance identifier.
+        child_warp: WarpId,
+        /// Root node id within the child instance.
+        child_root: NodeId,
+        /// How to initialize/validate the child instance root.
+        init: PortalInit,
+    },
+    /// Insert or replace warp instance metadata (Stage B1).
+    UpsertWarpInstance {
+        /// Instance metadata record.
+        instance: WarpInstance,
+    },
+    /// Delete a warp instance and all its contents.
+    DeleteWarpInstance {
+        /// Instance identifier to delete.
+        warp_id: WarpId,
+    },
     /// Insert or replace a node record.
     UpsertNode {
-        /// Node identifier being inserted or replaced.
-        node: NodeId,
+        /// Node identifier being inserted or replaced (instance-scoped).
+        node: NodeKey,
         /// Full node record contents.
         record: NodeRecord,
     },
     /// Delete a node record.
     DeleteNode {
-        /// Node identifier being deleted.
-        node: NodeId,
+        /// Node identifier being deleted (instance-scoped).
+        node: NodeKey,
     },
     /// Insert or replace an edge record.
     UpsertEdge {
+        /// Instance containing the edge.
+        warp_id: WarpId,
         /// Full edge record contents.
         record: EdgeRecord,
     },
     /// Delete an edge record from the outbound edge list of `from`.
     DeleteEdge {
+        /// Instance containing the edge.
+        warp_id: WarpId,
         /// Source node bucket holding the edge.
         from: NodeId,
         /// Edge identifier being deleted.
         edge_id: EdgeId,
     },
+    /// Set (or clear) an attachment slot value.
+    SetAttachment {
+        /// Attachment slot key.
+        key: AttachmentKey,
+        /// New value (`None` clears the slot).
+        value: Option<AttachmentValue>,
+    },
+}
+
+/// Initialization policy for [`WarpOp::OpenPortal`].
+#[derive(Debug, Clone)]
+pub enum PortalInit {
+    /// Create a new child instance with only a root node (using `root_record`).
+    Empty {
+        /// Record to use when creating the child root node.
+        root_record: NodeRecord,
+    },
+    /// Require that the child instance and root node already exist.
+    RequireExisting,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
 struct WarpOpKey {
     kind: u8,
+    warp: ContentHash,
     a: ContentHash,
     b: ContentHash,
 }
 
 impl WarpOp {
+    /// Canonical replay ordering key for this operation.
+    ///
+    /// This ordering is used for two purposes:
+    /// - to define the deterministic replay order of a tick patch, and
+    /// - to define which operations are considered “the same” for patch construction
+    ///   (see [`WarpTickPatchV1::new`], which dedupes by this key with last-wins semantics).
+    ///
+    /// Ordering rationale (v2):
+    /// - Instance/portal operations sort before per-instance skeleton edits so that stores exist
+    ///   before nodes/edges/attachments are applied.
+    /// - Skeleton deletions sort before skeleton upserts (delete-before-upsert) to support
+    ///   within-tick replacement semantics for nodes/edges.
+    /// - Attachment writes sort last so they cannot reference missing skeleton elements.
+    ///
+    /// Note: `UpsertWarpInstance` sorts before `DeleteWarpInstance` even though node/edge ops
+    /// use delete-before-upsert. Patches are expected not to contain both operations for the
+    /// same `warp_id`; if they do, this ordering makes the resulting state (and any subsequent
+    /// invalid references) deterministic rather than silently ambiguous.
     fn sort_key(&self) -> WarpOpKey {
         match self {
-            Self::DeleteEdge { from, edge_id } => WarpOpKey {
-                kind: 1,
+            Self::OpenPortal { key, .. } => {
+                let (owner_tag, plane_tag) = key.tag();
+                let (warp, local) = match key.owner {
+                    AttachmentOwner::Node(node) => ((node.warp_id).0, (node.local_id).0),
+                    AttachmentOwner::Edge(edge) => ((edge.warp_id).0, (edge.local_id).0),
+                };
+                WarpOpKey {
+                    kind: 1,
+                    warp,
+                    a: {
+                        let mut buf = [0u8; 32];
+                        buf[0] = owner_tag;
+                        buf[1] = plane_tag;
+                        buf
+                    },
+                    b: local,
+                }
+            }
+            Self::UpsertWarpInstance { instance } => WarpOpKey {
+                kind: 2,
+                warp: (instance.warp_id).0,
+                a: (instance.warp_id).0,
+                b: [0u8; 32],
+            },
+            Self::DeleteWarpInstance { warp_id } => WarpOpKey {
+                kind: 3,
+                warp: warp_id.0,
+                a: warp_id.0,
+                b: [0u8; 32],
+            },
+            Self::DeleteEdge {
+                warp_id,
+                from,
+                edge_id,
+            } => WarpOpKey {
+                kind: 4,
+                warp: warp_id.0,
                 a: from.0,
                 b: edge_id.0,
             },
             Self::DeleteNode { node } => WarpOpKey {
-                kind: 2,
-                a: node.0,
+                kind: 5,
+                warp: (node.warp_id).0,
+                a: (node.local_id).0,
                 b: [0u8; 32],
             },
             Self::UpsertNode { node, .. } => WarpOpKey {
-                kind: 3,
-                a: node.0,
+                kind: 6,
+                warp: (node.warp_id).0,
+                a: (node.local_id).0,
                 b: [0u8; 32],
             },
-            Self::UpsertEdge { record } => WarpOpKey {
-                kind: 4,
+            Self::UpsertEdge { warp_id, record } => WarpOpKey {
+                kind: 7,
+                warp: warp_id.0,
                 a: record.from.0,
                 b: record.id.0,
             },
+            Self::SetAttachment { key, .. } => {
+                let (owner_tag, plane_tag) = key.tag();
+                // Stable ordering: (kind, owner_tag, plane_tag, warp_id, local_id).
+                let (warp, local) = match key.owner {
+                    AttachmentOwner::Node(node) => ((node.warp_id).0, (node.local_id).0),
+                    AttachmentOwner::Edge(edge) => ((edge.warp_id).0, (edge.local_id).0),
+                };
+                WarpOpKey {
+                    kind: 8,
+                    warp,
+                    a: {
+                        let mut buf = [0u8; 32];
+                        buf[0] = owner_tag;
+                        buf[1] = plane_tag;
+                        buf
+                    },
+                    b: local,
+                }
+            }
         }
     }
 }
@@ -182,22 +321,23 @@ impl WarpTickPatchV1 {
         commit_status: TickCommitStatus,
         mut in_slots: Vec<SlotId>,
         mut out_slots: Vec<SlotId>,
-        mut ops: Vec<WarpOp>,
+        ops: Vec<WarpOp>,
     ) -> Self {
         in_slots.sort();
         in_slots.dedup();
         out_slots.sort();
         out_slots.dedup();
-        ops.sort_by_key(WarpOp::sort_key);
-        ops.dedup_by(|a, b| {
-            if a.sort_key() == b.sort_key() {
-                *b = a.clone();
-                true
-            } else {
-                false
+        let ops = {
+            let mut op_map: std::collections::BTreeMap<WarpOpKey, WarpOp> =
+                std::collections::BTreeMap::new();
+            for op in ops {
+                // Last-wins: if multiple ops share the same canonical sort key, keep the
+                // last op provided by the caller and drop earlier duplicates.
+                op_map.insert(op.sort_key(), op);
             }
-        });
-        let digest = compute_patch_digest_v1(
+            op_map.into_values().collect::<Vec<_>>()
+        };
+        let digest = compute_patch_digest_v2(
             policy_id,
             &rule_pack_id,
             commit_status,
@@ -261,48 +401,330 @@ impl WarpTickPatchV1 {
         self.digest
     }
 
-    /// Applies the patch delta to `store`.
+    /// Applies the patch delta to `state`.
     ///
     /// # Errors
     /// Returns an error if an operation is invalid for the current store
     /// state (e.g., deleting a missing edge).
-    pub fn apply_to_store(&self, store: &mut GraphStore) -> Result<(), TickPatchError> {
+    pub fn apply_to_state(&self, state: &mut WarpState) -> Result<(), TickPatchError> {
         for op in &self.ops {
-            match op {
-                WarpOp::UpsertNode { node, record } => {
-                    store.nodes.insert(*node, record.clone());
-                }
-                WarpOp::DeleteNode { node } => {
-                    if store.nodes.remove(node).is_none() {
-                        return Err(TickPatchError::MissingNode(*node));
-                    }
-                }
-                WarpOp::UpsertEdge { record } => {
-                    store.upsert_edge_record(record.from, record.clone());
+            apply_op_to_state(state, op)?;
+        }
+        validate_portal_invariants(state)?;
+        Ok(())
+    }
+}
+
+fn validate_portal_invariants(state: &WarpState) -> Result<(), TickPatchError> {
+    // 1) No orphan instances: every instance that declares a parent slot must be reachable
+    // via that slot (`AttachmentValue::Descend(warp_id)`).
+    for (warp_id, instance) in state.iter_instances() {
+        let Some(parent) = instance.parent else {
+            continue;
+        };
+        // Ensure the parent key is well-formed and its owner exists.
+        let _parent_warp = validate_attachment_owner_exists(state, &parent)?;
+        match attachment_value_for_key(state, &parent) {
+            Some(AttachmentValue::Descend(child_warp)) if *child_warp == *warp_id => {}
+            _ => return Err(TickPatchError::PortalInvariantViolation),
+        }
+    }
+
+    // 2) No dangling portals: every `Descend(warp_id)` attachment must refer to an existing
+    // instance whose `parent` points back at this exact attachment slot.
+    for (warp_id, store) in state.iter_stores() {
+        for (node_id, value) in store.iter_node_attachments() {
+            if let AttachmentValue::Descend(child_warp) = value {
+                let key = AttachmentKey::node_alpha(NodeKey {
+                    warp_id: *warp_id,
+                    local_id: *node_id,
+                });
+                validate_descend_target(state, key, *child_warp)?;
+            }
+        }
+        for (edge_id, value) in store.iter_edge_attachments() {
+            if let AttachmentValue::Descend(child_warp) = value {
+                let key = AttachmentKey::edge_beta(EdgeKey {
+                    warp_id: *warp_id,
+                    local_id: *edge_id,
+                });
+                validate_descend_target(state, key, *child_warp)?;
+            }
+        }
+    }
+
+    Ok(())
+}
+
+fn validate_descend_target(
+    state: &WarpState,
+    key: AttachmentKey,
+    child_warp: WarpId,
+) -> Result<(), TickPatchError> {
+    let Some(child_instance) = state.instance(&child_warp) else {
+        return Err(TickPatchError::PortalInvariantViolation);
+    };
+    if child_instance.parent != Some(key) {
+        return Err(TickPatchError::PortalInvariantViolation);
+    }
+    if state.store(&child_warp).is_none() {
+        return Err(TickPatchError::PortalInvariantViolation);
+    }
+    Ok(())
+}
+
+fn apply_op_to_state(state: &mut WarpState, op: &WarpOp) -> Result<(), TickPatchError> {
+    match op {
+        WarpOp::OpenPortal {
+            key,
+            child_warp,
+            child_root,
+            init,
+        } => apply_open_portal(state, key, *child_warp, *child_root, init),
+        WarpOp::UpsertWarpInstance { instance } => {
+            let store = state.take_or_create_store(instance.warp_id);
+            state.upsert_instance(instance.clone(), store);
+            Ok(())
+        }
+        WarpOp::DeleteWarpInstance { warp_id } => {
+            if !state.delete_instance(warp_id) {
+                return Err(TickPatchError::MissingWarp(*warp_id));
+            }
+            Ok(())
+        }
+        WarpOp::UpsertNode { node, record } => {
+            let Some(store) = state.store_mut(&node.warp_id) else {
+                return Err(TickPatchError::MissingWarp(node.warp_id));
+            };
+            store.insert_node(node.local_id, record.clone());
+            Ok(())
+        }
+        WarpOp::DeleteNode { node } => {
+            let Some(store) = state.store_mut(&node.warp_id) else {
+                return Err(TickPatchError::MissingWarp(node.warp_id));
+            };
+            if !store.delete_node_cascade(node.local_id) {
+                return Err(TickPatchError::MissingNode(*node));
+            }
+            Ok(())
+        }
+        WarpOp::UpsertEdge { warp_id, record } => {
+            let Some(store) = state.store_mut(warp_id) else {
+                return Err(TickPatchError::MissingWarp(*warp_id));
+            };
+            store.upsert_edge_record(record.from, record.clone());
+            Ok(())
+        }
+        WarpOp::DeleteEdge {
+            warp_id,
+            from,
+            edge_id,
+        } => {
+            let Some(store) = state.store_mut(warp_id) else {
+                return Err(TickPatchError::MissingWarp(*warp_id));
+            };
+            if !store.delete_edge_exact(*from, *edge_id) {
+                return Err(TickPatchError::MissingEdge(EdgeKey {
+                    warp_id: *warp_id,
+                    local_id: *edge_id,
+                }));
+            }
+            Ok(())
+        }
+        WarpOp::SetAttachment { key, value } => apply_set_attachment(state, key, value.as_ref()),
+    }
+}
+
+fn apply_open_portal(
+    state: &mut WarpState,
+    key: &AttachmentKey,
+    child_warp: WarpId,
+    child_root: NodeId,
+    init: &PortalInit,
+) -> Result<(), TickPatchError> {
+    let parent_warp = validate_attachment_owner_exists(state, key)?;
+
+    // Ensure the child instance exists and is consistent with the portal key.
+    let mut created_instance = false;
+    if let Some(existing) = state.instance(&child_warp) {
+        if existing.parent != Some(*key) || existing.root_node != child_root {
+            return Err(TickPatchError::PortalInvariantViolation);
+        }
+    } else {
+        match init {
+            PortalInit::Empty { root_record } => {
+                let mut store = GraphStore::new(child_warp);
+                store.insert_node(child_root, root_record.clone());
+                state.upsert_instance(
+                    WarpInstance {
+                        warp_id: child_warp,
+                        root_node: child_root,
+                        parent: Some(*key),
+                    },
+                    store,
+                );
+                created_instance = true;
+            }
+            PortalInit::RequireExisting => return Err(TickPatchError::PortalInitRequired),
+        }
+    }
+
+    if !created_instance {
+        ensure_child_root(state, child_warp, child_root, init)?;
+    }
+
+    // Finally, set the portal attachment slot to point at the child warp id.
+    let Some(parent_store) = state.store_mut(&parent_warp) else {
+        return Err(TickPatchError::MissingWarp(parent_warp));
+    };
+    match key.owner {
+        AttachmentOwner::Node(node) => {
+            parent_store
+                .set_node_attachment(node.local_id, Some(AttachmentValue::Descend(child_warp)));
+        }
+        AttachmentOwner::Edge(edge) => {
+            parent_store
+                .set_edge_attachment(edge.local_id, Some(AttachmentValue::Descend(child_warp)));
+        }
+    }
+    Ok(())
+}
+
+fn validate_attachment_plane(key: &AttachmentKey) -> Result<(), TickPatchError> {
+    match key.owner {
+        AttachmentOwner::Node(_) => {
+            if key.plane != AttachmentPlane::Alpha {
+                return Err(TickPatchError::InvalidAttachmentKey(*key));
+            }
+        }
+        AttachmentOwner::Edge(_) => {
+            if key.plane != AttachmentPlane::Beta {
+                return Err(TickPatchError::InvalidAttachmentKey(*key));
+            }
+        }
+    }
+    Ok(())
+}
+
+fn validate_attachment_owner_exists(
+    state: &WarpState,
+    key: &AttachmentKey,
+) -> Result<WarpId, TickPatchError> {
+    validate_attachment_plane(key)?;
+    match key.owner {
+        AttachmentOwner::Node(node) => {
+            let Some(store) = state.store(&node.warp_id) else {
+                return Err(TickPatchError::MissingWarp(node.warp_id));
+            };
+            if store.node(&node.local_id).is_none() {
+                return Err(TickPatchError::MissingNode(node));
+            }
+            Ok(node.warp_id)
+        }
+        AttachmentOwner::Edge(edge) => {
+            let Some(store) = state.store(&edge.warp_id) else {
+                return Err(TickPatchError::MissingWarp(edge.warp_id));
+            };
+            if !store.has_edge(&edge.local_id) {
+                return Err(TickPatchError::MissingEdge(edge));
+            }
+            Ok(edge.warp_id)
+        }
+    }
+}
+
+fn ensure_child_root(
+    state: &mut WarpState,
+    child_warp: WarpId,
+    child_root: NodeId,
+    init: &PortalInit,
+) -> Result<(), TickPatchError> {
+    match init {
+        PortalInit::Empty { root_record } => {
+            let Some(store) = state.store_mut(&child_warp) else {
+                return Err(TickPatchError::MissingWarp(child_warp));
+            };
+            match store.node(&child_root) {
+                None => {
+                    store.insert_node(child_root, root_record.clone());
                 }
-                WarpOp::DeleteEdge { from, edge_id } => {
-                    if !store.delete_edge_exact(*from, *edge_id) {
-                        return Err(TickPatchError::MissingEdge(*edge_id));
+                Some(existing) => {
+                    if existing != root_record {
+                        return Err(TickPatchError::PortalInvariantViolation);
                     }
                 }
             }
+            Ok(())
+        }
+        PortalInit::RequireExisting => {
+            let Some(store) = state.store(&child_warp) else {
+                return Err(TickPatchError::MissingWarp(child_warp));
+            };
+            if store.node(&child_root).is_none() {
+                return Err(TickPatchError::MissingNode(NodeKey {
+                    warp_id: child_warp,
+                    local_id: child_root,
+                }));
+            }
+            Ok(())
+        }
+    }
+}
+
+fn apply_set_attachment(
+    state: &mut WarpState,
+    key: &AttachmentKey,
+    value: Option<&AttachmentValue>,
+) -> Result<(), TickPatchError> {
+    validate_attachment_plane(key)?;
+    match key.owner {
+        AttachmentOwner::Node(node) => {
+            let Some(store) = state.store_mut(&node.warp_id) else {
+                return Err(TickPatchError::MissingWarp(node.warp_id));
+            };
+            if store.node(&node.local_id).is_none() {
+                return Err(TickPatchError::MissingNode(node));
+            }
+            store.set_node_attachment(node.local_id, value.cloned());
+            Ok(())
+        }
+        AttachmentOwner::Edge(edge) => {
+            let Some(store) = state.store_mut(&edge.warp_id) else {
+                return Err(TickPatchError::MissingWarp(edge.warp_id));
+            };
+            if !store.has_edge(&edge.local_id) {
+                return Err(TickPatchError::MissingEdge(edge));
+            }
+            store.set_edge_attachment(edge.local_id, value.cloned());
+            Ok(())
         }
-        Ok(())
     }
 }
 
 /// Errors produced while applying a tick patch.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
 pub enum TickPatchError {
+    /// Referenced a warp instance that did not exist.
+    #[error("missing warp: {0:?}")]
+    MissingWarp(WarpId),
     /// Tried to delete a node that did not exist.
     #[error("missing node: {0:?}")]
-    MissingNode(NodeId),
+    MissingNode(NodeKey),
     /// Tried to delete an edge that did not exist.
     #[error("missing edge: {0:?}")]
-    MissingEdge(EdgeId),
+    MissingEdge(EdgeKey),
+    /// Tried to set an attachment slot that is not valid in v1.
+    #[error("invalid attachment key: {0:?}")]
+    InvalidAttachmentKey(AttachmentKey),
+    /// `OpenPortal` requires `PortalInit::Empty` when the child instance does not exist.
+    #[error("portal init required")]
+    PortalInitRequired,
+    /// `OpenPortal` invariants were violated (dangling portal / inconsistent parent chain / root mismatch).
+    #[error("portal invariant violation")]
+    PortalInvariantViolation,
 }
 
-fn compute_patch_digest_v1(
+fn compute_patch_digest_v2(
     policy_id: u32,
     rule_pack_id: &ContentHash,
     commit_status: TickCommitStatus,
@@ -312,7 +734,7 @@ fn compute_patch_digest_v1(
 ) -> ContentHash {
     let mut h = Hasher::new();
     // Patch format version.
-    h.update(&1u16.to_le_bytes());
+    h.update(&2u16.to_le_bytes());
     h.update(&policy_id.to_le_bytes());
     h.update(rule_pack_id);
     h.update(&[commit_status.code()]);
@@ -329,14 +751,20 @@ fn encode_slots(h: &mut Hasher, slots: &[SlotId]) {
         match slot {
             SlotId::Node(id) => {
                 h.update(&[1u8]);
-                h.update(&id.0);
+                h.update(&(id.warp_id).0);
+                h.update(&(id.local_id).0);
             }
             SlotId::Edge(id) => {
                 h.update(&[2u8]);
-                h.update(&id.0);
+                h.update(&(id.warp_id).0);
+                h.update(&(id.local_id).0);
             }
-            SlotId::Port(key) => {
+            SlotId::Attachment(key) => {
                 h.update(&[3u8]);
+                encode_attachment_key(h, key);
+            }
+            SlotId::Port(key) => {
+                h.update(&[4u8]);
                 h.update(&key.to_le_bytes());
             }
         }
@@ -353,168 +781,448 @@ fn encode_ops(h: &mut Hasher, ops: &[WarpOp]) {
     h.update(&(ops.len() as u64).to_le_bytes());
     for op in ops {
         match op {
-            WarpOp::UpsertNode { node, record } => {
+            WarpOp::OpenPortal {
+                key,
+                child_warp,
+                child_root,
+                init,
+            } => {
+                h.update(&[8u8]);
+                encode_attachment_key(h, key);
+                h.update(&child_warp.0);
+                h.update(&child_root.0);
+                encode_portal_init(h, init);
+            }
+            WarpOp::UpsertWarpInstance { instance } => {
                 h.update(&[1u8]);
-                h.update(&node.0);
-                h.update(&(record.ty).0);
-                match &record.payload {
-                    Some(payload) => {
-                        h.update(&(payload.len() as u64).to_le_bytes());
-                        h.update(payload);
-                    }
-                    None => {
-                        h.update(&0u64.to_le_bytes());
-                    }
-                }
+                h.update(&(instance.warp_id).0);
+                h.update(&(instance.root_node).0);
+                encode_attachment_key_opt(h, instance.parent.as_ref());
             }
-            WarpOp::DeleteNode { node } => {
+            WarpOp::DeleteWarpInstance { warp_id } => {
                 h.update(&[2u8]);
-                h.update(&node.0);
+                h.update(&warp_id.0);
             }
-            WarpOp::UpsertEdge { record } => {
+            WarpOp::UpsertNode { node, record } => {
                 h.update(&[3u8]);
+                h.update(&(node.warp_id).0);
+                h.update(&(node.local_id).0);
+                h.update(&(record.ty).0);
+            }
+            WarpOp::DeleteNode { node } => {
+                h.update(&[4u8]);
+                h.update(&(node.warp_id).0);
+                h.update(&(node.local_id).0);
+            }
+            WarpOp::UpsertEdge { warp_id, record } => {
+                h.update(&[5u8]);
+                h.update(&warp_id.0);
                 h.update(&(record.from).0);
                 h.update(&(record.id).0);
                 h.update(&(record.to).0);
                 h.update(&(record.ty).0);
-                match &record.payload {
-                    Some(payload) => {
-                        h.update(&(payload.len() as u64).to_le_bytes());
-                        h.update(payload);
-                    }
-                    None => {
-                        h.update(&0u64.to_le_bytes());
-                    }
-                }
             }
-            WarpOp::DeleteEdge { from, edge_id } => {
-                h.update(&[4u8]);
+            WarpOp::DeleteEdge {
+                warp_id,
+                from,
+                edge_id,
+            } => {
+                h.update(&[6u8]);
+                h.update(&warp_id.0);
                 h.update(&from.0);
                 h.update(&edge_id.0);
             }
+            WarpOp::SetAttachment { key, value } => {
+                h.update(&[7u8]);
+                encode_attachment_key(h, key);
+                encode_attachment_value_opt(h, value.as_ref());
+            }
         }
     }
 }
 
-/// Computes a canonical delta op list that transforms one in-memory store state into another.
-///
-/// This is the “diff” constructor used by the v0 engine to build a
-/// [`WarpTickPatchV1`]: the engine snapshots `store_before`, executes rewrites
-/// to produce `store_after`, then calls `diff_store(store_before, store_after)`
-/// to obtain a canonical set of delta operations (`WarpOp`) for replay.
-///
-/// # Required invariants
-/// This function assumes both `before` and `after` are *valid* [`GraphStore`]
-/// instances that satisfy the store invariants:
-/// - `nodes` contains at most one record per `NodeId`.
-/// - `edges_from` contains outbound edges whose `EdgeRecord.from` matches the
-///   bucket key used to store them (the “from bucket” invariant).
-/// - Each `EdgeId` is globally unique across the entire store (no duplicates in
-///   different buckets).
-///
-/// Ordering within each store is expected to be deterministic (`BTreeMap`
-/// iteration order), but the returned ops do **not** preserve any original
-/// mutation order; they are canonicalized for hashing/replay.
-///
-/// # Semantics of returned ops
-/// - [`WarpOp::UpsertNode`]: ensure the node record at `node` matches `after`.
-///   Emitted when a node is new or its type/payload changed.
-/// - [`WarpOp::DeleteNode`]: remove a node record that existed in `before` but
-///   is absent in `after`.
-/// - [`WarpOp::UpsertEdge`]: ensure the edge record for `EdgeId` matches `after`.
-///   Emitted when an edge is new or any of its fields changed.
-/// - [`WarpOp::DeleteEdge`]: remove an edge record from the specified `from`
-///   bucket. Emitted when an edge is removed, and also when an edge’s `from`
-///   bucket changes (migration), in which case a `DeleteEdge(old_from, id)` is
-///   emitted before the corresponding `UpsertEdge(new_record)`.
+fn encode_portal_init(h: &mut Hasher, init: &PortalInit) {
+    match init {
+        PortalInit::RequireExisting => {
+            h.update(&[0u8]);
+        }
+        PortalInit::Empty { root_record } => {
+            h.update(&[1u8]);
+            h.update(&(root_record.ty).0);
+        }
+    }
+}
+
+fn encode_attachment_key_opt(h: &mut Hasher, key: Option<&AttachmentKey>) {
+    match key {
+        None => {
+            h.update(&[0u8]);
+        }
+        Some(key) => {
+            h.update(&[1u8]);
+            encode_attachment_key(h, key);
+        }
+    }
+}
+
+fn encode_attachment_key(h: &mut Hasher, key: &AttachmentKey) {
+    let (owner_tag, plane_tag) = key.tag();
+    h.update(&[owner_tag]);
+    h.update(&[plane_tag]);
+    match key.owner {
+        AttachmentOwner::Node(node) => {
+            h.update(&(node.warp_id).0);
+            h.update(&(node.local_id).0);
+        }
+        AttachmentOwner::Edge(edge) => {
+            h.update(&(edge.warp_id).0);
+            h.update(&(edge.local_id).0);
+        }
+    }
+}
+
+fn encode_attachment_value_opt(h: &mut Hasher, value: Option<&AttachmentValue>) {
+    match value {
+        None => {
+            h.update(&[0u8]);
+        }
+        Some(value) => {
+            h.update(&[1u8]);
+            encode_attachment_value(h, value);
+        }
+    }
+}
+
+fn encode_attachment_value(h: &mut Hasher, value: &AttachmentValue) {
+    match value {
+        AttachmentValue::Atom(atom) => {
+            h.update(&[1u8]);
+            encode_atom_payload(h, atom);
+        }
+        AttachmentValue::Descend(warp_id) => {
+            h.update(&[2u8]);
+            h.update(&warp_id.0);
+        }
+    }
+}
+
+fn encode_atom_payload(h: &mut Hasher, atom: &AtomPayload) {
+    h.update(&(atom.type_id).0);
+    h.update(&(atom.bytes.len() as u64).to_le_bytes());
+    h.update(&atom.bytes);
+}
+
+/// Computes a canonical delta op list that transforms one multi-instance state into another.
 ///
-/// The returned list is sorted by the canonical op ordering key (`WarpOp::sort_key`) so it can be applied in
-/// deterministic order. Applying the returned ops to a store in the `before`
-/// state should yield a store equivalent to `after`.
+/// This is the engine’s “diff constructor” for [`WarpTickPatchV1`]. The engine:
+/// 1) snapshots `before`,
+/// 2) executes rewrites to produce `after`, then
+/// 3) calls `diff_state(before, after)` to obtain a canonical list of [`WarpOp`]
+///    edits suitable for deterministic replay and hashing (`patch_digest`).
 ///
-/// (Note: `WarpOp::sort_key` is an internal ordering key; it is not itself part
-/// of the wire encoding or patch digest encoding.)
+/// Typical use cases:
+/// - Engine commit path: derive the tick’s delta patch without re-searching.
+/// - Tooling/tests: validate that two states differ only by a specific op set.
 ///
-/// # Why diff vs. recording ops directly?
-/// `diff_store` is useful when the engine executes arbitrary user code (rule
-/// executors) that mutates the store and we want a deterministic, replayable
-/// delta patch without requiring the executor to emit canonical ops itself.
+/// # Required invariants
+/// Callers must provide internally consistent `WarpState` inputs:
+/// - Every `WarpInstance` in `instances` has a corresponding `GraphStore` in `stores`
+///   and `GraphStore.warp_id == WarpInstance.warp_id`.
+/// - Per-store referential integrity holds (edges reference existing nodes, etc).
+/// - Deletions are *cascading*: deleting a node/instance implicitly deletes its
+///   incident edges and its attachment slots. This function therefore does not emit
+///   explicit “clear attachment” ops for deleted owners.
 ///
-/// In the long run, recording canonical ops directly (or emitting a cheaper
-/// structural diff) can avoid the clone+diff cost, but `diff_store` is the
-/// simplest correct v0 boundary artifact.
+/// # Returned ops (semantic meaning)
+/// - `UpsertWarpInstance` / `DeleteWarpInstance`: instance metadata changes.
+/// - `UpsertNode` / `DeleteNode`: skeleton-plane node record edits scoped to an instance.
+/// - `UpsertEdge` / `DeleteEdge`: skeleton-plane edge record edits scoped to an instance.
+/// - `SetAttachment`: attachment-plane slot edits (atoms and `Descend` links).
 ///
-/// # Edge cases and behavior
-/// - If `before` and `after` are identical, this returns an empty list.
-/// - This function does not validate the stores; if either store violates the
-///   invariants above, the resulting op list may be incomplete or replay may
-///   fail (e.g., deleting a missing edge).
+/// # Edge cases & performance
+/// - If the inputs are identical, returns an empty vector.
+/// - Complexity is linear in the number of nodes/edges/attachments across all instances,
+///   plus a per-instance edge id map build (`edges_by_id`).
 ///
-/// # Performance characteristics
-/// This routine is `O(nodes + edges)` to walk both stores plus `O(edges log edges)`
-/// to build `EdgeId`-keyed maps. It allocates and clones edge/node records as
-/// needed for op payloads, so it is not intended for hot paths.
-pub(crate) fn diff_store(before: &GraphStore, after: &GraphStore) -> Vec<WarpOp> {
+/// Determinism contract:
+/// - Instance iteration is deterministic (`BTreeMap`).
+/// - The returned ops are canonicalized by `WarpOp::sort_key` and are suitable
+///   for both replay ordering and `patch_digest` hashing.
+pub(crate) fn diff_state(before: &WarpState, after: &WarpState) -> Vec<WarpOp> {
     let mut ops: Vec<WarpOp> = Vec::new();
+    let mut portal_warps: std::collections::BTreeSet<WarpId> = std::collections::BTreeSet::new();
+    let mut skip_node_upserts: std::collections::BTreeSet<NodeKey> =
+        std::collections::BTreeSet::new();
+    let mut skip_attachment_ops: std::collections::BTreeSet<AttachmentKey> =
+        std::collections::BTreeSet::new();
+
+    // Canonicalize portal authoring: when we observe a new descended instance that is
+    // linked via `parent` and whose parent slot is set to `Descend(child_warp)`,
+    // emit a single `OpenPortal` op instead of separate instance + attachment edits.
+    //
+    // This prevents slices from “forgetting the baby exists” by ensuring that
+    // portal creation and instance creation are inseparable at the patch level.
+    for (warp_id, inst_after) in &after.instances {
+        if before.instances.contains_key(warp_id) {
+            continue;
+        }
+        let Some(parent_key) = inst_after.parent else {
+            continue;
+        };
+        let Some(parent_value) = attachment_value_for_key(after, &parent_key) else {
+            continue;
+        };
+        if parent_value != &AttachmentValue::Descend(*warp_id) {
+            continue;
+        }
+        let Some(child_store) = after.store(warp_id) else {
+            continue;
+        };
+        let Some(root_record) = child_store.node(&inst_after.root_node) else {
+            continue;
+        };
+        ops.push(WarpOp::OpenPortal {
+            key: parent_key,
+            child_warp: *warp_id,
+            child_root: inst_after.root_node,
+            init: PortalInit::Empty {
+                root_record: root_record.clone(),
+            },
+        });
+        portal_warps.insert(*warp_id);
+        skip_node_upserts.insert(NodeKey {
+            warp_id: *warp_id,
+            local_id: inst_after.root_node,
+        });
+        skip_attachment_ops.insert(parent_key);
+    }
 
-    // Nodes
+    // WarpInstances: deletions and upserts.
+    for warp_id in before.instances.keys() {
+        if !after.instances.contains_key(warp_id) {
+            ops.push(WarpOp::DeleteWarpInstance { warp_id: *warp_id });
+        }
+    }
+    for (warp_id, inst_after) in &after.instances {
+        match before.instances.get(warp_id) {
+            None => {
+                if !portal_warps.contains(warp_id) {
+                    ops.push(WarpOp::UpsertWarpInstance {
+                        instance: inst_after.clone(),
+                    });
+                }
+            }
+            Some(inst_before) => {
+                if inst_before != inst_after {
+                    ops.push(WarpOp::UpsertWarpInstance {
+                        instance: inst_after.clone(),
+                    });
+                }
+            }
+        }
+    }
+
+    // Per-instance skeleton and attachment-plane diffs.
+    let empty = empty_graph_store();
+    for (warp_id, after_store) in &after.stores {
+        let before_store = before.stores.get(warp_id).unwrap_or(empty);
+        diff_instance(
+            &mut ops,
+            *warp_id,
+            before_store,
+            after_store,
+            &skip_node_upserts,
+            &skip_attachment_ops,
+        );
+    }
+
+    ops.sort_by_key(WarpOp::sort_key);
+    ops
+}
+
+fn empty_graph_store() -> &'static GraphStore {
+    static EMPTY: std::sync::OnceLock<GraphStore> = std::sync::OnceLock::new();
+    EMPTY.get_or_init(GraphStore::default)
+}
+
+fn diff_instance(
+    ops: &mut Vec<WarpOp>,
+    warp_id: WarpId,
+    before: &GraphStore,
+    after: &GraphStore,
+    skip_node_upserts: &std::collections::BTreeSet<NodeKey>,
+    skip_attachment_ops: &std::collections::BTreeSet<AttachmentKey>,
+) {
+    diff_nodes(ops, warp_id, before, after, skip_node_upserts);
+    diff_node_attachments(ops, warp_id, before, after, skip_attachment_ops);
+
+    // Edges (skeleton plane): map by EdgeId for stable diff independent of insertion order.
+    let before_edges = edges_by_id(before);
+    let after_edges = edges_by_id(after);
+    diff_edges(ops, warp_id, &before_edges, &after_edges);
+    diff_edge_attachments(
+        ops,
+        warp_id,
+        before,
+        after,
+        &after_edges,
+        skip_attachment_ops,
+    );
+}
+
+fn diff_nodes(
+    ops: &mut Vec<WarpOp>,
+    warp_id: WarpId,
+    before: &GraphStore,
+    after: &GraphStore,
+    skip_node_upserts: &std::collections::BTreeSet<NodeKey>,
+) {
     for (id, rec_before) in &before.nodes {
+        let node = NodeKey {
+            warp_id,
+            local_id: *id,
+        };
+        if skip_node_upserts.contains(&node) {
+            continue;
+        }
         let Some(rec_after) = after.nodes.get(id) else {
-            ops.push(WarpOp::DeleteNode { node: *id });
+            ops.push(WarpOp::DeleteNode { node });
             continue;
         };
-        if rec_before.ty != rec_after.ty || rec_before.payload != rec_after.payload {
+        if rec_before != rec_after {
             ops.push(WarpOp::UpsertNode {
-                node: *id,
+                node,
                 record: rec_after.clone(),
             });
         }
     }
     for (id, rec_after) in &after.nodes {
+        let node = NodeKey {
+            warp_id,
+            local_id: *id,
+        };
+        if skip_node_upserts.contains(&node) {
+            continue;
+        }
         if !before.nodes.contains_key(id) {
             ops.push(WarpOp::UpsertNode {
-                node: *id,
+                node,
                 record: rec_after.clone(),
             });
         }
     }
+}
 
-    // Edges: map by EdgeId for stable diffing independent of insertion order.
-    let before_edges = edges_by_id(before);
-    let after_edges = edges_by_id(after);
-    for (id, rec_before) in &before_edges {
+fn diff_node_attachments(
+    ops: &mut Vec<WarpOp>,
+    warp_id: WarpId,
+    before: &GraphStore,
+    after: &GraphStore,
+    skip_attachment_ops: &std::collections::BTreeSet<AttachmentKey>,
+) {
+    for node_id in after.nodes.keys() {
+        let before_val = before.node_attachment(node_id);
+        let after_val = after.node_attachment(node_id);
+        if before_val == after_val {
+            continue;
+        }
+
+        let key = AttachmentKey::node_alpha(NodeKey {
+            warp_id,
+            local_id: *node_id,
+        });
+        if skip_attachment_ops.contains(&key) {
+            continue;
+        }
+        ops.push(WarpOp::SetAttachment {
+            key,
+            value: after_val.cloned(),
+        });
+    }
+}
+
+fn diff_edges(
+    ops: &mut Vec<WarpOp>,
+    warp_id: WarpId,
+    before_edges: &std::collections::BTreeMap<ContentHash, EdgeRecord>,
+    after_edges: &std::collections::BTreeMap<ContentHash, EdgeRecord>,
+) {
+    for (id, rec_before) in before_edges {
         if !after_edges.contains_key(id) {
             ops.push(WarpOp::DeleteEdge {
+                warp_id,
                 from: rec_before.from,
                 edge_id: EdgeId(*id),
             });
         }
     }
-    for (id, rec_after) in &after_edges {
+
+    for (id, rec_after) in after_edges {
         match before_edges.get(id) {
             None => {
                 ops.push(WarpOp::UpsertEdge {
+                    warp_id,
                     record: rec_after.clone(),
                 });
             }
             Some(rec_before) => {
-                if rec_before != rec_after {
-                    if rec_before.from != rec_after.from {
-                        ops.push(WarpOp::DeleteEdge {
-                            from: rec_before.from,
-                            edge_id: EdgeId(*id),
-                        });
-                    }
-                    ops.push(WarpOp::UpsertEdge {
-                        record: rec_after.clone(),
+                if rec_before == rec_after {
+                    continue;
+                }
+                if rec_before.from != rec_after.from {
+                    ops.push(WarpOp::DeleteEdge {
+                        warp_id,
+                        from: rec_before.from,
+                        edge_id: EdgeId(*id),
                     });
                 }
+                ops.push(WarpOp::UpsertEdge {
+                    warp_id,
+                    record: rec_after.clone(),
+                });
             }
         }
     }
+}
 
-    ops.sort_by_key(WarpOp::sort_key);
-    ops
+fn diff_edge_attachments(
+    ops: &mut Vec<WarpOp>,
+    warp_id: WarpId,
+    before: &GraphStore,
+    after: &GraphStore,
+    after_edges: &std::collections::BTreeMap<ContentHash, EdgeRecord>,
+    skip_attachment_ops: &std::collections::BTreeSet<AttachmentKey>,
+) {
+    for id in after_edges.keys() {
+        let edge_id = EdgeId(*id);
+        let before_val = before.edge_attachment(&edge_id);
+        let after_val = after.edge_attachment(&edge_id);
+        if before_val == after_val {
+            continue;
+        }
+
+        let key = AttachmentKey::edge_beta(EdgeKey {
+            warp_id,
+            local_id: edge_id,
+        });
+        if skip_attachment_ops.contains(&key) {
+            continue;
+        }
+        ops.push(WarpOp::SetAttachment {
+            key,
+            value: after_val.cloned(),
+        });
+    }
+}
+
+fn attachment_value_for_key<'a>(
+    state: &'a WarpState,
+    key: &AttachmentKey,
+) -> Option<&'a AttachmentValue> {
+    match key.owner {
+        AttachmentOwner::Node(node) => state.store(&node.warp_id)?.node_attachment(&node.local_id),
+        AttachmentOwner::Edge(edge) => state.store(&edge.warp_id)?.edge_attachment(&edge.local_id),
+    }
 }
 
 fn edges_by_id(store: &GraphStore) -> std::collections::BTreeMap<ContentHash, EdgeRecord> {
@@ -526,3 +1234,438 @@ fn edges_by_id(store: &GraphStore) -> std::collections::BTreeMap<ContentHash, Ed
     }
     out
 }
+
+/// Extracts a Paper III-style slice from a linear worldline payload.
+///
+/// This function implements the “unversioned slots, interpretive SSA” rule:
+/// a slot version is `slot@i`, where `i` is the index of the producing patch
+/// in the worldline payload `P = (μ0, …, μn-1)`.
+///
+/// Returns the set of tick indices (in ascending order) that must be retained
+/// to replay the dependency cone for `target` at the end of the worldline.
+pub fn slice_worldline_indices(patches: &[WarpTickPatchV1], target: SlotId) -> Vec<usize> {
+    // Build a producer index: slot -> sorted list of producing tick indices.
+    let mut producers: std::collections::BTreeMap<SlotId, Vec<usize>> =
+        std::collections::BTreeMap::new();
+    for (tick, patch) in patches.iter().enumerate() {
+        for slot in patch.out_slots() {
+            producers.entry(*slot).or_default().push(tick);
+        }
+    }
+
+    let mut needed_ticks: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
+    let work_cap = core::cmp::max(1, patches.len() / 4);
+    let mut work: Vec<(SlotId, usize)> = Vec::with_capacity(work_cap);
+    work.push((target, patches.len()));
+    let mut seen: std::collections::BTreeSet<(SlotId, usize)> = std::collections::BTreeSet::new();
+
+    while let Some((slot, consumer_tick)) = work.pop() {
+        if !seen.insert((slot, consumer_tick)) {
+            continue;
+        }
+        let Some(ticks) = producers.get(&slot) else {
+            continue; // Value originates from U0 (or is absent); no producing patch.
+        };
+        let Some(producer_tick) = producer_before(ticks, consumer_tick) else {
+            continue;
+        };
+        if needed_ticks.insert(producer_tick) {
+            for in_slot in patches[producer_tick].in_slots() {
+                work.push((*in_slot, producer_tick));
+            }
+        }
+    }
+
+    needed_ticks.into_iter().collect()
+}
+
+fn producer_before(producers: &[usize], consumer_tick: usize) -> Option<usize> {
+    let insertion = match producers.binary_search(&consumer_tick) {
+        Ok(pos) | Err(pos) => pos,
+    };
+    if insertion == 0 {
+        None
+    } else {
+        Some(producers[insertion - 1])
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use crate::ident::{make_node_id, make_type_id, make_warp_id};
+
+    #[test]
+    fn new_dedupes_duplicate_ops_by_sort_key_last_wins() {
+        let warp_id = make_warp_id("dedupe-warp");
+        let node_id = make_node_id("dedupe-node");
+        let node = NodeKey {
+            warp_id,
+            local_id: node_id,
+        };
+
+        let op_a = WarpOp::UpsertNode {
+            node,
+            record: NodeRecord {
+                ty: make_type_id("A"),
+            },
+        };
+        let op_b = WarpOp::UpsertNode {
+            node,
+            record: NodeRecord {
+                ty: make_type_id("B"),
+            },
+        };
+
+        let patch = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![],
+            vec![op_a, op_b],
+        );
+
+        let ops = patch.ops();
+        assert_eq!(ops.len(), 1);
+        assert!(matches!(&ops[0], WarpOp::UpsertNode { .. }));
+        let WarpOp::UpsertNode {
+            node: got_node,
+            record,
+        } = &ops[0]
+        else {
+            return;
+        };
+        assert_eq!(got_node, &node);
+        assert_eq!(record.ty, make_type_id("B"));
+    }
+
+    #[test]
+    fn slice_includes_portal_chain_for_descended_instance() {
+        let root_warp = make_warp_id("root");
+        let child_warp = make_warp_id("child");
+
+        let root_node = make_node_id("root-node");
+        let portal_key = AttachmentKey::node_alpha(NodeKey {
+            warp_id: root_warp,
+            local_id: root_node,
+        });
+
+        let child_root = make_node_id("child-root");
+        let child_root_key = NodeKey {
+            warp_id: child_warp,
+            local_id: child_root,
+        };
+
+        let child_node = make_node_id("child-node");
+        let child_node_key = NodeKey {
+            warp_id: child_warp,
+            local_id: child_node,
+        };
+
+        // Tick 0: establish the portal by setting the root attachment slot to Descend(child).
+        let patch0 = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![SlotId::Attachment(portal_key), SlotId::Node(child_root_key)],
+            vec![WarpOp::OpenPortal {
+                key: portal_key,
+                child_warp,
+                child_root,
+                init: PortalInit::Empty {
+                    root_record: NodeRecord {
+                        ty: make_type_id("ChildRootTy"),
+                    },
+                },
+            }],
+        );
+
+        // Tick 1: execute inside the descended instance; the engine enforces that
+        // the descent chain attachment slot is read (in_slots includes portal_key).
+        let patch1 = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![SlotId::Attachment(portal_key)],
+            vec![SlotId::Node(child_node_key)],
+            vec![WarpOp::UpsertNode {
+                node: child_node_key,
+                record: NodeRecord {
+                    ty: make_type_id("ChildTy"),
+                },
+            }],
+        );
+
+        let worldline = [patch0, patch1];
+        let ticks = slice_worldline_indices(&worldline, SlotId::Node(child_node_key));
+        assert_eq!(ticks, vec![0, 1]);
+    }
+
+    #[test]
+    fn apply_to_state_rejects_dangling_portal_set_attachment() {
+        let root_warp = make_warp_id("root");
+        let child_warp = make_warp_id("child");
+
+        let root_node = make_node_id("root-node");
+        let root_key = NodeKey {
+            warp_id: root_warp,
+            local_id: root_node,
+        };
+        let portal_key = AttachmentKey::node_alpha(root_key);
+
+        let mut state = WarpState::new();
+        let init_root = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![SlotId::Node(root_key)],
+            vec![
+                WarpOp::UpsertWarpInstance {
+                    instance: WarpInstance {
+                        warp_id: root_warp,
+                        root_node,
+                        parent: None,
+                    },
+                },
+                WarpOp::UpsertNode {
+                    node: root_key,
+                    record: NodeRecord {
+                        ty: make_type_id("RootTy"),
+                    },
+                },
+            ],
+        );
+        assert!(init_root.apply_to_state(&mut state).is_ok());
+
+        // Dangling portal: sets Descend(child) without creating the child instance.
+        let dangling = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![SlotId::Attachment(portal_key)],
+            vec![WarpOp::SetAttachment {
+                key: portal_key,
+                value: Some(AttachmentValue::Descend(child_warp)),
+            }],
+        );
+        assert!(matches!(
+            dangling.apply_to_state(&mut state),
+            Err(TickPatchError::PortalInvariantViolation)
+        ));
+    }
+
+    #[test]
+    fn apply_to_state_rejects_orphan_instance_missing_portal() {
+        let root_warp = make_warp_id("root");
+        let child_warp = make_warp_id("child");
+
+        let root_node = make_node_id("root-node");
+        let root_key = NodeKey {
+            warp_id: root_warp,
+            local_id: root_node,
+        };
+        let portal_key = AttachmentKey::node_alpha(root_key);
+
+        let mut state = WarpState::new();
+        let init_root = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![SlotId::Node(root_key)],
+            vec![
+                WarpOp::UpsertWarpInstance {
+                    instance: WarpInstance {
+                        warp_id: root_warp,
+                        root_node,
+                        parent: None,
+                    },
+                },
+                WarpOp::UpsertNode {
+                    node: root_key,
+                    record: NodeRecord {
+                        ty: make_type_id("RootTy"),
+                    },
+                },
+            ],
+        );
+        assert!(init_root.apply_to_state(&mut state).is_ok());
+
+        // Orphan instance: create child metadata that declares a parent portal key
+        // without establishing the portal slot value.
+        let orphan = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![],
+            vec![WarpOp::UpsertWarpInstance {
+                instance: WarpInstance {
+                    warp_id: child_warp,
+                    root_node: make_node_id("child-root"),
+                    parent: Some(portal_key),
+                },
+            }],
+        );
+        assert!(matches!(
+            orphan.apply_to_state(&mut state),
+            Err(TickPatchError::PortalInvariantViolation)
+        ));
+    }
+
+    #[test]
+    fn apply_to_state_rejects_delete_instance_without_clearing_portal() {
+        let root_warp = make_warp_id("root");
+        let child_warp = make_warp_id("child");
+
+        let root_node = make_node_id("root-node");
+        let root_key = NodeKey {
+            warp_id: root_warp,
+            local_id: root_node,
+        };
+        let portal_key = AttachmentKey::node_alpha(root_key);
+
+        let child_root = make_node_id("child-root");
+
+        let mut state = WarpState::new();
+        let init_root = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![SlotId::Node(root_key)],
+            vec![
+                WarpOp::UpsertWarpInstance {
+                    instance: WarpInstance {
+                        warp_id: root_warp,
+                        root_node,
+                        parent: None,
+                    },
+                },
+                WarpOp::UpsertNode {
+                    node: root_key,
+                    record: NodeRecord {
+                        ty: make_type_id("RootTy"),
+                    },
+                },
+            ],
+        );
+        assert!(init_root.apply_to_state(&mut state).is_ok());
+
+        let open = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![SlotId::Attachment(portal_key)],
+            vec![WarpOp::OpenPortal {
+                key: portal_key,
+                child_warp,
+                child_root,
+                init: PortalInit::Empty {
+                    root_record: NodeRecord {
+                        ty: make_type_id("ChildRootTy"),
+                    },
+                },
+            }],
+        );
+        assert!(open.apply_to_state(&mut state).is_ok());
+
+        // Dangling portal: delete the child instance but forget to clear the portal slot.
+        let delete_child = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![],
+            vec![WarpOp::DeleteWarpInstance {
+                warp_id: child_warp,
+            }],
+        );
+        assert!(matches!(
+            delete_child.apply_to_state(&mut state),
+            Err(TickPatchError::PortalInvariantViolation)
+        ));
+    }
+
+    #[test]
+    fn apply_to_state_rejects_clear_portal_without_deleting_instance() {
+        let root_warp = make_warp_id("root");
+        let child_warp = make_warp_id("child");
+
+        let root_node = make_node_id("root-node");
+        let root_key = NodeKey {
+            warp_id: root_warp,
+            local_id: root_node,
+        };
+        let portal_key = AttachmentKey::node_alpha(root_key);
+
+        let child_root = make_node_id("child-root");
+
+        let mut state = WarpState::new();
+        let init_root = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![SlotId::Node(root_key)],
+            vec![
+                WarpOp::UpsertWarpInstance {
+                    instance: WarpInstance {
+                        warp_id: root_warp,
+                        root_node,
+                        parent: None,
+                    },
+                },
+                WarpOp::UpsertNode {
+                    node: root_key,
+                    record: NodeRecord {
+                        ty: make_type_id("RootTy"),
+                    },
+                },
+            ],
+        );
+        assert!(init_root.apply_to_state(&mut state).is_ok());
+
+        let open = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![SlotId::Attachment(portal_key)],
+            vec![WarpOp::OpenPortal {
+                key: portal_key,
+                child_warp,
+                child_root,
+                init: PortalInit::Empty {
+                    root_record: NodeRecord {
+                        ty: make_type_id("ChildRootTy"),
+                    },
+                },
+            }],
+        );
+        assert!(open.apply_to_state(&mut state).is_ok());
+
+        // Orphan instance: clear the portal slot while leaving the child instance metadata present.
+        let clear_portal = WarpTickPatchV1::new(
+            crate::POLICY_ID_NO_POLICY_V0,
+            [1u8; 32],
+            TickCommitStatus::Committed,
+            vec![],
+            vec![],
+            vec![WarpOp::SetAttachment {
+                key: portal_key,
+                value: None,
+            }],
+        );
+        assert!(matches!(
+            clear_portal.apply_to_state(&mut state),
+            Err(TickPatchError::PortalInvariantViolation)
+        ));
+    }
+}
diff --git a/crates/warp-core/src/warp_state.rs b/crates/warp-core/src/warp_state.rs
new file mode 100644
index 00000000..8be8fba1
--- /dev/null
+++ b/crates/warp-core/src/warp_state.rs
@@ -0,0 +1,114 @@
+// SPDX-License-Identifier: Apache-2.0
+// © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
+//! Stage B1: multi-instance WARP state (flattened indirection).
+//!
+//! This module introduces **`WarpInstances`**: each descended attachment points
+//! to a separate instance (a namespaced skeleton graph) rather than embedding a
+//! recursive Rust data structure.
+
+use std::collections::BTreeMap;
+
+use crate::attachment::AttachmentKey;
+use crate::graph::GraphStore;
+use crate::ident::{NodeId, WarpId};
+
+/// Metadata record describing one WARP instance (a “layer”).
+///
+/// Instances are addressed by [`WarpId`]. Each instance has a designated root
+/// node id within its local skeleton store. Descended instances optionally
+/// record the attachment slot that descends into them (`parent`), enabling
+/// deterministic “include the portal chain” slicing without searching the
+/// entire attachment plane.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WarpInstance {
+    /// Instance identifier (namespace for local node/edge ids).
+    pub warp_id: WarpId,
+    /// Root node id within the instance's local [`GraphStore`].
+    pub root_node: NodeId,
+    /// Attachment slot that descends into this instance (`None` for the root instance).
+    pub parent: Option<AttachmentKey>,
+}
+
+/// Multi-instance WARP state: a collection of instance-scoped skeleton stores
+/// plus instance metadata.
+///
+/// Determinism contract:
+/// - All maps are `BTreeMap` for stable iteration order.
+/// - Invariants that affect determinism (e.g., missing instance metadata) are
+///   treated as internal corruption and should be prevented by constructors and
+///   patch replay validation.
+#[derive(Debug, Clone, Default)]
+pub struct WarpState {
+    pub(crate) stores: BTreeMap<WarpId, GraphStore>,
+    pub(crate) instances: BTreeMap<WarpId, WarpInstance>,
+}
+
+impl WarpState {
+    /// Creates an empty state.
+    #[must_use]
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Returns the instance metadata for `warp_id` (if present).
+    #[must_use]
+    pub fn instance(&self, warp_id: &WarpId) -> Option<&WarpInstance> {
+        self.instances.get(warp_id)
+    }
+
+    /// Returns the skeleton store for `warp_id` (if present).
+    #[must_use]
+    pub fn store(&self, warp_id: &WarpId) -> Option<&GraphStore> {
+        self.stores.get(warp_id)
+    }
+
+    /// Iterate over all instance metadata entries in deterministic order.
+    pub(crate) fn iter_instances(&self) -> impl Iterator<Item = (&WarpId, &WarpInstance)> {
+        self.instances.iter()
+    }
+
+    /// Iterate over all stores in deterministic order.
+    pub(crate) fn iter_stores(&self) -> impl Iterator<Item = (&WarpId, &GraphStore)> {
+        self.stores.iter()
+    }
+
+    /// Returns a mutable skeleton store for `warp_id` (if present).
+    pub fn store_mut(&mut self, warp_id: &WarpId) -> Option<&mut GraphStore> {
+        self.stores.get_mut(warp_id)
+    }
+
+    /// Removes and returns the store for `warp_id` if it exists; otherwise returns a new empty store.
+    #[must_use]
+    pub(crate) fn take_or_create_store(&mut self, warp_id: WarpId) -> GraphStore {
+        self.stores
+            .remove(&warp_id)
+            .unwrap_or_else(|| GraphStore::new(warp_id))
+    }
+
+    /// Inserts or replaces the store and metadata for a warp instance.
+    ///
+    /// This is primarily used by patch replay and construction utilities.
+    pub(crate) fn upsert_instance(&mut self, instance: WarpInstance, mut store: GraphStore) {
+        debug_assert_eq!(
+            store.warp_id, instance.warp_id,
+            "GraphStore.warp_id must match WarpInstance.warp_id"
+        );
+        // Canonicalize the store's warp id to the instance id (the instance metadata is the source of truth).
+        store.warp_id = instance.warp_id;
+        self.stores.insert(instance.warp_id, store);
+        self.instances.insert(instance.warp_id, instance);
+    }
+
+    /// Deletes a warp instance, its store, and its metadata.
+    ///
+    /// Returns `true` if the instance existed.
+    pub(crate) fn delete_instance(&mut self, warp_id: &WarpId) -> bool {
+        let existed = self.instances.remove(warp_id).is_some();
+        let store_existed = self.stores.remove(warp_id).is_some();
+        debug_assert_eq!(
+            existed, store_existed,
+            "WarpState stores/instances desynced for warp_id: {warp_id:?}"
+        );
+        existed
+    }
+}
diff --git a/crates/warp-core/tests/atom_payload_digest_tests.rs b/crates/warp-core/tests/atom_payload_digest_tests.rs
new file mode 100644
index 00000000..121974db
--- /dev/null
+++ b/crates/warp-core/tests/atom_payload_digest_tests.rs
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: Apache-2.0
+// © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
+
+#![allow(missing_docs)]
+
+use bytes::Bytes;
+
+use warp_core::{
+    make_node_id, make_type_id, make_warp_id, AtomPayload, AttachmentKey, AttachmentValue, Engine,
+    GraphStore, NodeKey, NodeRecord, TickCommitStatus, WarpInstance, WarpOp, WarpTickPatchV1,
+};
+
+#[test]
+fn commit_hash_changes_when_payload_type_changes_even_if_bytes_match() {
+    // This is a safety/determinism invariant for typed atoms:
+    // same bytes + different type id must not collide at the boundary hash.
+    let root = make_node_id("root");
+    let node_ty = make_type_id("entity");
+    let bytes = Bytes::from_static(b"same-bytes");
+
+    let payload_a = AtomPayload::new(make_type_id("payload/a"), bytes.clone());
+    let payload_b = AtomPayload::new(make_type_id("payload/b"), bytes);
+
+    let mut store_a = GraphStore::default();
+    store_a.insert_node(root, NodeRecord { ty: node_ty });
+    store_a.set_node_attachment(root, Some(AttachmentValue::Atom(payload_a)));
+    let mut store_b = GraphStore::default();
+    store_b.insert_node(root, NodeRecord { ty: node_ty });
+    store_b.set_node_attachment(root, Some(AttachmentValue::Atom(payload_b)));
+
+    let engine_a = Engine::new(store_a, root);
+    let engine_b = Engine::new(store_b, root);
+    assert_ne!(
+        engine_a.snapshot().hash,
+        engine_b.snapshot().hash,
+        "commit hash must change when payload type_id changes"
+    );
+}
+
+#[test]
+fn tick_patch_digest_changes_when_payload_type_changes_even_if_bytes_match() {
+    let warp_id = make_warp_id("patch-digest-payload-type-test");
+    let root = make_node_id("root");
+    let node = make_node_id("node");
+    let node_ty = make_type_id("entity");
+    let bytes = Bytes::from_static(b"same-bytes");
+
+    let instance = WarpInstance {
+        warp_id,
+        root_node: root,
+        parent: None,
+    };
+    let node_key = NodeKey {
+        warp_id,
+        local_id: node,
+    };
+
+    let base_ops = vec![
+        WarpOp::UpsertWarpInstance {
+            instance: instance.clone(),
+        },
+        WarpOp::UpsertNode {
+            node: node_key,
+            record: NodeRecord { ty: node_ty },
+        },
+    ];
+
+    let op_a = WarpOp::SetAttachment {
+        key: AttachmentKey::node_alpha(node_key),
+        value: Some(AttachmentValue::Atom(AtomPayload::new(
+            make_type_id("payload/a"),
+            bytes.clone(),
+        ))),
+    };
+    let op_b = WarpOp::SetAttachment {
+        key: AttachmentKey::node_alpha(node_key),
+        value: Some(AttachmentValue::Atom(AtomPayload::new(
+            make_type_id("payload/b"),
+            bytes,
+        ))),
+    };
+
+    let policy_id = warp_core::POLICY_ID_NO_POLICY_V0;
+    let rule_pack_id = [0u8; 32];
+    let patch_a = WarpTickPatchV1::new(
+        policy_id,
+        rule_pack_id,
+        TickCommitStatus::Committed,
+        Vec::new(),
+        Vec::new(),
+        base_ops.iter().cloned().chain([op_a]).collect(),
+    );
+    let patch_b = WarpTickPatchV1::new(
+        policy_id,
+        rule_pack_id,
+        TickCommitStatus::Committed,
+        Vec::new(),
+        Vec::new(),
+        base_ops.into_iter().chain([op_b]).collect(),
+    );
+
+    assert_ne!(
+        patch_a.digest(),
+        patch_b.digest(),
+        "patch_digest must change when payload type_id changes"
+    );
+}
diff --git a/crates/warp-core/tests/duplicate_rule_registration_tests.rs b/crates/warp-core/tests/duplicate_rule_registration_tests.rs
index 5ba6a73c..6139c59d 100644
--- a/crates/warp-core/tests/duplicate_rule_registration_tests.rs
+++ b/crates/warp-core/tests/duplicate_rule_registration_tests.rs
@@ -20,13 +20,7 @@ fn registering_duplicate_rule_name_is_rejected() {
     let mut store = GraphStore::default();
     let root = make_node_id("dup-root");
     let world_ty = make_type_id("world");
-    store.insert_node(
-        root,
-        NodeRecord {
-            ty: world_ty,
-            payload: None,
-        },
-    );
+    store.insert_node(root, NodeRecord { ty: world_ty });
     let mut engine = Engine::new(store, root);
     engine.register_rule(warp_core::motion_rule()).unwrap();
     let err = engine.register_rule(warp_core::motion_rule()).unwrap_err();
@@ -43,13 +37,7 @@ fn registering_duplicate_rule_id_is_rejected() {
     let mut store = GraphStore::default();
     let root = make_node_id("dup-root2");
     let world_ty = make_type_id("world");
-    store.insert_node(
-        root,
-        NodeRecord {
-            ty: world_ty,
-            payload: None,
-        },
-    );
+    store.insert_node(root, NodeRecord { ty: world_ty });
     let mut engine = Engine::new(store, root);
     engine.register_rule(warp_core::motion_rule()).unwrap();
 
diff --git a/crates/warp-core/tests/engine_motion_negative_tests.rs b/crates/warp-core/tests/engine_motion_negative_tests.rs
index f5b90365..45acbb69 100644
--- a/crates/warp-core/tests/engine_motion_negative_tests.rs
+++ b/crates/warp-core/tests/engine_motion_negative_tests.rs
@@ -10,20 +10,19 @@
 //! invalid payload size results in `ApplyResult::NoMatch` at the apply boundary.
 use bytes::Bytes;
 use warp_core::{
-    decode_motion_payload, encode_motion_payload, make_node_id, make_type_id, ApplyResult, Engine,
-    GraphStore, NodeRecord, MOTION_RULE_NAME,
+    decode_motion_atom_payload, encode_motion_atom_payload, make_node_id, make_type_id,
+    motion_payload_type_id, ApplyResult, AtomPayload, AttachmentValue, Engine, GraphStore,
+    NodeRecord, MOTION_RULE_NAME,
 };
 
 fn run_motion_once(pos: [f32; 3], vel: [f32; 3]) -> ([f32; 3], [f32; 3]) {
     let ent = make_node_id("case");
     let ty = make_type_id("entity");
     let mut store = GraphStore::default();
-    store.insert_node(
+    store.insert_node(ent, NodeRecord { ty });
+    store.set_node_attachment(
         ent,
-        NodeRecord {
-            ty,
-            payload: Some(encode_motion_payload(pos, vel)),
-        },
+        Some(AttachmentValue::Atom(encode_motion_atom_payload(pos, vel))),
     );
     let mut engine = Engine::new(store, ent);
     engine
@@ -32,8 +31,14 @@ fn run_motion_once(pos: [f32; 3], vel: [f32; 3]) -> ([f32; 3], [f32; 3]) {
     let tx = engine.begin();
     let _ = engine.apply(tx, MOTION_RULE_NAME, &ent).expect("apply");
     engine.commit(tx).expect("commit");
-    let node = engine.node(&ent).expect("node exists");
-    decode_motion_payload(node.payload.as_ref().expect("payload")).expect("decode")
+    let payload = engine
+        .node_attachment(&ent)
+        .expect("node_attachment ok")
+        .expect("payload present");
+    let AttachmentValue::Atom(payload) = payload else {
+        panic!("expected Atom payload, got {payload:?}");
+    };
+    decode_motion_atom_payload(payload).expect("decode")
 }
 
 #[test]
@@ -44,12 +49,10 @@ fn motion_nan_propagates_and_rule_applies() {
     let vel = [0.0, f32::NAN, 2.0];
 
     let mut store = GraphStore::default();
-    store.insert_node(
+    store.insert_node(ent, NodeRecord { ty });
+    store.set_node_attachment(
         ent,
-        NodeRecord {
-            ty,
-            payload: Some(encode_motion_payload(pos, vel)),
-        },
+        Some(AttachmentValue::Atom(encode_motion_atom_payload(pos, vel))),
     );
 
     let mut engine = Engine::new(store, ent);
@@ -62,9 +65,14 @@ fn motion_nan_propagates_and_rule_applies() {
     assert!(matches!(res, ApplyResult::Applied));
     engine.commit(tx).expect("commit");
 
-    let node = engine.node(&ent).expect("node exists");
-    let (new_pos, new_vel) =
-        decode_motion_payload(node.payload.as_ref().expect("payload")).expect("decode");
+    let payload = engine
+        .node_attachment(&ent)
+        .expect("node_attachment ok")
+        .expect("payload present");
+    let AttachmentValue::Atom(payload) = payload else {
+        panic!("expected Atom payload, got {payload:?}");
+    };
+    let (new_pos, new_vel) = decode_motion_atom_payload(payload).expect("decode");
 
     // NaN arithmetic propagates; check using is_nan rather than bitwise.
     assert!(new_pos[0].is_nan(), "pos.x should be NaN after update");
@@ -85,12 +93,10 @@ fn motion_infinity_preserves_infinite_values() {
     let vel = [1.0, 2.0, 3.0];
 
     let mut store = GraphStore::default();
-    store.insert_node(
+    store.insert_node(ent, NodeRecord { ty });
+    store.set_node_attachment(
         ent,
-        NodeRecord {
-            ty,
-            payload: Some(encode_motion_payload(pos, vel)),
-        },
+        Some(AttachmentValue::Atom(encode_motion_atom_payload(pos, vel))),
     );
 
     let mut engine = Engine::new(store, ent);
@@ -103,9 +109,14 @@ fn motion_infinity_preserves_infinite_values() {
     assert!(matches!(res, ApplyResult::Applied));
     engine.commit(tx).expect("commit");
 
-    let node = engine.node(&ent).expect("node exists");
-    let (new_pos, new_vel) =
-        decode_motion_payload(node.payload.as_ref().expect("payload")).expect("decode");
+    let payload = engine
+        .node_attachment(&ent)
+        .expect("node_attachment ok")
+        .expect("payload present");
+    let AttachmentValue::Atom(payload) = payload else {
+        panic!("expected Atom payload, got {payload:?}");
+    };
+    let (new_pos, new_vel) = decode_motion_atom_payload(payload).expect("decode");
 
     assert!(new_pos[0].is_infinite() && new_pos[0].is_sign_positive());
     assert_eq!(new_pos[1].to_bits(), 3.0f32.to_bits());
@@ -121,12 +132,13 @@ fn motion_invalid_payload_size_returns_nomatch() {
     let ent = make_node_id("bad-payload-size");
     let ty = make_type_id("entity");
     let mut store = GraphStore::default();
-    store.insert_node(
+    store.insert_node(ent, NodeRecord { ty });
+    store.set_node_attachment(
         ent,
-        NodeRecord {
-            ty,
-            payload: Some(Bytes::from(vec![0u8; 10])),
-        },
+        Some(AttachmentValue::Atom(AtomPayload::new(
+            motion_payload_type_id(),
+            Bytes::from(vec![0u8; 10]),
+        ))),
     );
     let mut engine = Engine::new(store, ent);
     engine
@@ -231,12 +243,13 @@ fn motion_zero_length_payload_returns_nomatch() {
     let ent = make_node_id("bad-size-0");
     let ty = make_type_id("entity");
     let mut store = GraphStore::default();
-    store.insert_node(
+    store.insert_node(ent, NodeRecord { ty });
+    store.set_node_attachment(
         ent,
-        NodeRecord {
-            ty,
-            payload: Some(Bytes::from(vec![])),
-        },
+        Some(AttachmentValue::Atom(AtomPayload::new(
+            motion_payload_type_id(),
+            Bytes::from(vec![]),
+        ))),
     );
     let mut engine = Engine::new(store, ent);
     engine.register_rule(warp_core::motion_rule()).unwrap();
@@ -251,12 +264,13 @@ fn motion_boundary_payload_sizes() {
         let ent = make_node_id(&format!("bad-size-{}", len));
         let ty = make_type_id("entity");
         let mut store = GraphStore::default();
-        store.insert_node(
+        store.insert_node(ent, NodeRecord { ty });
+        store.set_node_attachment(
             ent,
-            NodeRecord {
-                ty,
-                payload: Some(Bytes::from(vec![0u8; len])),
-            },
+            Some(AttachmentValue::Atom(AtomPayload::new(
+                motion_payload_type_id(),
+                Bytes::from(vec![0u8; len]),
+            ))),
         );
         let mut engine = Engine::new(store, ent);
         engine.register_rule(warp_core::motion_rule()).unwrap();
@@ -273,17 +287,12 @@ fn motion_boundary_payload_sizes() {
 #[test]
 fn motion_exact_24_bytes_with_weird_bits_is_accepted_and_propagates() {
     // 24 bytes of 0xFF -> three NaNs for pos, three NaNs for vel
-    let weird = Bytes::from(vec![0xFFu8; 24]);
+    let weird = AtomPayload::new(motion_payload_type_id(), Bytes::from(vec![0xFFu8; 24]));
     let ent = make_node_id("weird-24");
     let ty = make_type_id("entity");
     let mut store = GraphStore::default();
-    store.insert_node(
-        ent,
-        NodeRecord {
-            ty,
-            payload: Some(weird),
-        },
-    );
+    store.insert_node(ent, NodeRecord { ty });
+    store.set_node_attachment(ent, Some(AttachmentValue::Atom(weird)));
     let mut engine = Engine::new(store, ent);
     engine.register_rule(warp_core::motion_rule()).unwrap();
     let tx = engine.begin();
@@ -291,8 +300,14 @@ fn motion_exact_24_bytes_with_weird_bits_is_accepted_and_propagates() {
     assert!(matches!(res, ApplyResult::Applied));
     engine.commit(tx).unwrap();
     let (pos, vel) = {
-        let node = engine.node(&ent).unwrap();
-        decode_motion_payload(node.payload.as_ref().unwrap()).unwrap()
+        let payload = engine
+            .node_attachment(&ent)
+            .expect("node_attachment ok")
+            .expect("payload present");
+        let AttachmentValue::Atom(payload) = payload else {
+            panic!("expected Atom payload, got {payload:?}");
+        };
+        decode_motion_atom_payload(payload).unwrap()
     };
     assert!(pos.iter().all(|v| v.is_nan()));
     assert!(vel.iter().all(|v| v.is_nan()));
@@ -303,15 +318,13 @@ fn motion_nan_idempotency_applies_twice_stays_nan() {
     let ent = make_node_id("nan-twice");
     let ty = make_type_id("entity");
     let mut store = GraphStore::default();
-    store.insert_node(
+    store.insert_node(ent, NodeRecord { ty });
+    store.set_node_attachment(
         ent,
-        NodeRecord {
-            ty,
-            payload: Some(encode_motion_payload(
-                [f32::NAN, f32::NAN, f32::NAN],
-                [0.0, 0.0, 0.0],
-            )),
-        },
+        Some(AttachmentValue::Atom(encode_motion_atom_payload(
+            [f32::NAN, f32::NAN, f32::NAN],
+            [0.0, 0.0, 0.0],
+        ))),
     );
     let mut engine = Engine::new(store, ent);
     engine.register_rule(warp_core::motion_rule()).unwrap();
@@ -322,8 +335,14 @@ fn motion_nan_idempotency_applies_twice_stays_nan() {
         engine.commit(tx).unwrap();
     }
     let (pos, vel) = {
-        let node = engine.node(&ent).unwrap();
-        decode_motion_payload(node.payload.as_ref().unwrap()).unwrap()
+        let payload = engine
+            .node_attachment(&ent)
+            .expect("node_attachment ok")
+            .expect("payload present");
+        let AttachmentValue::Atom(payload) = payload else {
+            panic!("expected Atom payload, got {payload:?}");
+        };
+        decode_motion_atom_payload(payload).unwrap()
     };
     assert!(pos.iter().all(|v| v.is_nan()));
     assert_eq!(vel, [0.0, 0.0, 0.0]);
diff --git a/crates/warp-core/tests/engine_motion_tests.rs b/crates/warp-core/tests/engine_motion_tests.rs
index b0c1ceca..71b8d1b2 100644
--- a/crates/warp-core/tests/engine_motion_tests.rs
+++ b/crates/warp-core/tests/engine_motion_tests.rs
@@ -3,24 +3,19 @@
 
 #![allow(missing_docs)]
 use warp_core::{
-    decode_motion_payload, encode_motion_payload, make_node_id, make_type_id, ApplyResult, Engine,
-    EngineError, GraphStore, NodeRecord, MOTION_RULE_NAME,
+    decode_motion_atom_payload, encode_motion_atom_payload, make_node_id, make_type_id,
+    ApplyResult, AttachmentValue, Engine, EngineError, GraphStore, NodeRecord, MOTION_RULE_NAME,
 };
 
 #[test]
 fn motion_rule_updates_position_deterministically() {
     let entity = make_node_id("entity-1");
     let entity_type = make_type_id("entity");
-    let payload = encode_motion_payload([1.0, 2.0, 3.0], [0.5, -1.0, 0.25]);
+    let payload = encode_motion_atom_payload([1.0, 2.0, 3.0], [0.5, -1.0, 0.25]);
 
     let mut store = GraphStore::default();
-    store.insert_node(
-        entity,
-        NodeRecord {
-            ty: entity_type,
-            payload: Some(payload),
-        },
-    );
+    store.insert_node(entity, NodeRecord { ty: entity_type });
+    store.set_node_attachment(entity, Some(AttachmentValue::Atom(payload)));
 
     let mut engine = Engine::new(store, entity);
     engine
@@ -36,14 +31,9 @@ fn motion_rule_updates_position_deterministically() {
 
     // Run a second engine with identical initial state and ensure hashes match.
     let mut store_b = GraphStore::default();
-    let payload_b = encode_motion_payload([1.0, 2.0, 3.0], [0.5, -1.0, 0.25]);
-    store_b.insert_node(
-        entity,
-        NodeRecord {
-            ty: entity_type,
-            payload: Some(payload_b),
-        },
-    );
+    let payload_b = encode_motion_atom_payload([1.0, 2.0, 3.0], [0.5, -1.0, 0.25]);
+    store_b.insert_node(entity, NodeRecord { ty: entity_type });
+    store_b.set_node_attachment(entity, Some(AttachmentValue::Atom(payload_b)));
 
     let mut engine_b = Engine::new(store_b, entity);
     engine_b
@@ -57,13 +47,14 @@ fn motion_rule_updates_position_deterministically() {
     assert_eq!(hash_after_first_apply, snap_b.hash);
 
     // Ensure the position actually moved.
-    let node = engine
-        .node(&entity)
-        .expect("entity exists")
-        .payload
-        .as_ref()
-        .and_then(decode_motion_payload)
-        .expect("payload decode");
+    let payload = engine
+        .node_attachment(&entity)
+        .expect("node_attachment ok")
+        .expect("payload present");
+    let AttachmentValue::Atom(payload) = payload else {
+        panic!("expected Atom payload, got {payload:?}");
+    };
+    let node = decode_motion_atom_payload(payload).expect("payload decode");
     assert_eq!(node.0, [1.5, 1.0, 3.25]);
     assert_eq!(node.1, [0.5, -1.0, 0.25]);
 }
@@ -74,13 +65,7 @@ fn motion_rule_no_match_on_missing_payload() {
     let entity_type = make_type_id("entity");
 
     let mut store = GraphStore::default();
-    store.insert_node(
-        entity,
-        NodeRecord {
-            ty: entity_type,
-            payload: None,
-        },
-    );
+    store.insert_node(entity, NodeRecord { ty: entity_type });
 
     let mut engine = Engine::new(store, entity);
     engine
@@ -95,23 +80,21 @@ fn motion_rule_no_match_on_missing_payload() {
     // Commit should be a no-op for state; hash remains identical and payload stays None.
     let snap = engine.commit(tx).expect("no-op commit");
     assert_eq!(snap.hash, before);
-    assert!(engine.node(&entity).unwrap().payload.is_none());
+    assert!(engine
+        .node_attachment(&entity)
+        .expect("node_attachment ok")
+        .is_none());
 }
 
 #[test]
 fn motion_rule_twice_is_deterministic_across_engines() {
     let entity = make_node_id("entity-1-twice");
     let entity_type = make_type_id("entity");
-    let payload = encode_motion_payload([1.0, 2.0, 3.0], [0.5, -1.0, 0.25]);
+    let payload = encode_motion_atom_payload([1.0, 2.0, 3.0], [0.5, -1.0, 0.25]);
 
     let mut store_a = GraphStore::default();
-    store_a.insert_node(
-        entity,
-        NodeRecord {
-            ty: entity_type,
-            payload: Some(payload.clone()),
-        },
-    );
+    store_a.insert_node(entity, NodeRecord { ty: entity_type });
+    store_a.set_node_attachment(entity, Some(AttachmentValue::Atom(payload.clone())));
     let mut engine_a = Engine::new(store_a, entity);
     engine_a
         .register_rule(warp_core::motion_rule())
@@ -123,13 +106,8 @@ fn motion_rule_twice_is_deterministic_across_engines() {
     }
 
     let mut store_b = GraphStore::default();
-    store_b.insert_node(
-        entity,
-        NodeRecord {
-            ty: entity_type,
-            payload: Some(payload),
-        },
-    );
+    store_b.insert_node(entity, NodeRecord { ty: entity_type });
+    store_b.set_node_attachment(entity, Some(AttachmentValue::Atom(payload)));
     let mut engine_b = Engine::new(store_b, entity);
     engine_b
         .register_rule(warp_core::motion_rule())
@@ -149,12 +127,13 @@ fn apply_unknown_rule_returns_error() {
     let entity_type = make_type_id("entity");
 
     let mut store = GraphStore::default();
-    store.insert_node(
+    store.insert_node(entity, NodeRecord { ty: entity_type });
+    store.set_node_attachment(
         entity,
-        NodeRecord {
-            ty: entity_type,
-            payload: Some(encode_motion_payload([0.0, 0.0, 0.0], [0.0, 0.0, 0.0])),
-        },
+        Some(AttachmentValue::Atom(encode_motion_atom_payload(
+            [0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0],
+        ))),
     );
 
     let mut engine = Engine::new(store, entity);
diff --git a/crates/warp-core/tests/permutation_commute_tests.rs b/crates/warp-core/tests/permutation_commute_tests.rs
index 135f7f9f..96cdd25d 100644
--- a/crates/warp-core/tests/permutation_commute_tests.rs
+++ b/crates/warp-core/tests/permutation_commute_tests.rs
@@ -2,7 +2,9 @@
 // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
 
 #![allow(missing_docs)]
-use warp_core::{encode_motion_payload, make_node_id, make_type_id, GraphStore, NodeRecord};
+use warp_core::{
+    encode_motion_atom_payload, make_node_id, make_type_id, AttachmentValue, GraphStore, NodeRecord,
+};
 mod common;
 use common::snapshot_hash_of;
 
@@ -14,13 +16,7 @@ fn n_permutation_commute_n3_and_n4() {
         let world_ty = make_type_id("world");
         let ent_ty = make_type_id("entity");
         let mut store = GraphStore::default();
-        store.insert_node(
-            root,
-            NodeRecord {
-                ty: world_ty,
-                payload: None,
-            },
-        );
+        store.insert_node(root, NodeRecord { ty: world_ty });
         let mut scopes = Vec::new();
         for i in 0..n {
             let id = make_node_id(&format!("entity-{i}"));
@@ -31,12 +27,13 @@ fn n_permutation_commute_n3_and_n4() {
                 3 => [1.0, 1.0, 0.0],
                 _ => unreachable!(),
             };
-            store.insert_node(
+            store.insert_node(id, NodeRecord { ty: ent_ty });
+            store.set_node_attachment(
                 id,
-                NodeRecord {
-                    ty: ent_ty,
-                    payload: Some(encode_motion_payload([0.0, 0.0, 0.0], v)),
-                },
+                Some(AttachmentValue::Atom(encode_motion_atom_payload(
+                    [0.0, 0.0, 0.0],
+                    v,
+                ))),
             );
             // Connect entity to root so snapshot reachability includes it.
             let edge = warp_core::EdgeRecord {
@@ -44,7 +41,6 @@ fn n_permutation_commute_n3_and_n4() {
                 from: root,
                 to: id,
                 ty: make_type_id("contains"),
-                payload: None,
             };
             store.insert_edge(root, edge);
             scopes.push(id);
diff --git a/crates/warp-core/tests/property_commute_tests.rs b/crates/warp-core/tests/property_commute_tests.rs
index 54862c79..7367c53e 100644
--- a/crates/warp-core/tests/property_commute_tests.rs
+++ b/crates/warp-core/tests/property_commute_tests.rs
@@ -2,7 +2,9 @@
 // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
 
 #![allow(missing_docs)]
-use warp_core::{encode_motion_payload, make_node_id, make_type_id, GraphStore, NodeRecord};
+use warp_core::{
+    encode_motion_atom_payload, make_node_id, make_type_id, AttachmentValue, GraphStore, NodeRecord,
+};
 mod common;
 use common::snapshot_hash_of;
 
@@ -16,26 +18,22 @@ fn independent_motion_rewrites_commute_on_distinct_nodes() {
     let b = make_node_id("entity-b");
 
     let mut store1 = GraphStore::default();
-    store1.insert_node(
-        root,
-        NodeRecord {
-            ty: world_ty,
-            payload: None,
-        },
-    );
-    store1.insert_node(
+    store1.insert_node(root, NodeRecord { ty: world_ty });
+    store1.insert_node(a, NodeRecord { ty: ent_ty });
+    store1.set_node_attachment(
         a,
-        NodeRecord {
-            ty: ent_ty,
-            payload: Some(encode_motion_payload([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])),
-        },
+        Some(AttachmentValue::Atom(encode_motion_atom_payload(
+            [0.0, 0.0, 0.0],
+            [1.0, 0.0, 0.0],
+        ))),
     );
-    store1.insert_node(
+    store1.insert_node(b, NodeRecord { ty: ent_ty });
+    store1.set_node_attachment(
         b,
-        NodeRecord {
-            ty: ent_ty,
-            payload: Some(encode_motion_payload([0.0, 0.0, 0.0], [0.0, 1.0, 0.0])),
-        },
+        Some(AttachmentValue::Atom(encode_motion_atom_payload(
+            [0.0, 0.0, 0.0],
+            [0.0, 1.0, 0.0],
+        ))),
     );
     // Make entities reachable from root via edges so snapshots include them.
     let edge_ty = make_type_id("edge");
@@ -47,7 +45,6 @@ fn independent_motion_rewrites_commute_on_distinct_nodes() {
             from: root,
             to: a,
             ty: edge_ty,
-            payload: None,
         },
     );
     store1.insert_edge(
@@ -57,7 +54,6 @@ fn independent_motion_rewrites_commute_on_distinct_nodes() {
             from: root,
             to: b,
             ty: edge_ty,
-            payload: None,
         },
     );
     let mut store2 = store1.clone();
diff --git a/crates/warp-core/tests/proptest_seed_pinning.rs b/crates/warp-core/tests/proptest_seed_pinning.rs
index 22f23db5..188f75f9 100644
--- a/crates/warp-core/tests/proptest_seed_pinning.rs
+++ b/crates/warp-core/tests/proptest_seed_pinning.rs
@@ -6,8 +6,8 @@ use proptest::prelude::*;
 use proptest::test_runner::{Config as PropConfig, RngAlgorithm, TestRng, TestRunner};
 
 use warp_core::{
-    decode_motion_payload, encode_motion_payload, make_node_id, make_type_id, ApplyResult, Engine,
-    GraphStore, NodeRecord, MOTION_RULE_NAME,
+    decode_motion_atom_payload, encode_motion_atom_payload, make_node_id, make_type_id,
+    ApplyResult, AttachmentValue, Engine, GraphStore, NodeRecord, MOTION_RULE_NAME,
 };
 
 // Demonstrates how to pin a deterministic seed for property tests so failures
@@ -42,12 +42,10 @@ fn proptest_seed_pinned_motion_updates() {
             let entity_ty = make_type_id("entity");
 
             let mut store = GraphStore::default();
-            store.insert_node(
+            store.insert_node(entity, NodeRecord { ty: entity_ty });
+            store.set_node_attachment(
                 entity,
-                NodeRecord {
-                    ty: entity_ty,
-                    payload: Some(encode_motion_payload(pos, vel)),
-                },
+                Some(AttachmentValue::Atom(encode_motion_atom_payload(pos, vel))),
             );
 
             let mut engine = Engine::new(store, entity);
@@ -60,9 +58,14 @@ fn proptest_seed_pinned_motion_updates() {
             prop_assert!(matches!(res, ApplyResult::Applied));
             engine.commit(tx).expect("commit");
 
-            let node = engine.node(&entity).expect("node exists");
-            let (new_pos, new_vel) =
-                decode_motion_payload(node.payload.as_ref().expect("payload")).expect("decode");
+            let payload = engine
+                .node_attachment(&entity)
+                .expect("node_attachment ok")
+                .expect("payload present");
+            let AttachmentValue::Atom(payload) = payload else {
+                panic!("expected Atom payload, got {payload:?}");
+            };
+            let (new_pos, new_vel) = decode_motion_atom_payload(payload).expect("decode");
 
             // Velocity is preserved; position += vel * dt (dt = 1.0).
             for i in 0..3 {
diff --git a/crates/warp-core/tests/reserve_gate_tests.rs b/crates/warp-core/tests/reserve_gate_tests.rs
index 15ca2b90..dcbc9631 100644
--- a/crates/warp-core/tests/reserve_gate_tests.rs
+++ b/crates/warp-core/tests/reserve_gate_tests.rs
@@ -2,7 +2,9 @@
 // © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots>
 
 #![allow(missing_docs)]
-use warp_core::{decode_motion_payload, make_node_id, make_type_id, NodeRecord};
+use warp_core::{
+    decode_motion_atom_payload, make_node_id, make_type_id, AttachmentValue, NodeRecord,
+};
 
 #[test]
 fn reserve_gate_aborts_second_on_port_conflict() {
@@ -12,13 +14,9 @@ fn reserve_gate_aborts_second_on_port_conflict() {
     // Create an entity node under root that we’ll target.
     let entity = make_node_id("reserve-entity");
     let entity_ty = make_type_id("entity");
-    engine.insert_node(
-        entity,
-        NodeRecord {
-            ty: entity_ty,
-            payload: None,
-        },
-    );
+    engine
+        .insert_node(entity, NodeRecord { ty: entity_ty })
+        .expect("insert_node");
 
     let tx = engine.begin();
     let _ = engine.apply(tx, warp_core::demo::ports::PORT_RULE_NAME, &entity);
@@ -26,13 +24,14 @@ fn reserve_gate_aborts_second_on_port_conflict() {
     let _snap = engine.commit(tx).expect("commit");
 
     // Exactly one executor should have run: pos.x == 1.0
-    let (pos, _vel) = engine
-        .node(&entity)
-        .unwrap()
-        .payload
-        .as_ref()
-        .and_then(decode_motion_payload)
+    let payload = engine
+        .node_attachment(&entity)
+        .expect("node_attachment ok")
         .expect("payload present");
+    let AttachmentValue::Atom(payload) = payload else {
+        panic!("expected Atom payload, got {payload:?}");
+    };
+    let (pos, _vel) = decode_motion_atom_payload(payload).expect("payload decode");
     assert!(
         (pos[0] - 1.0).abs() < 1e-6,
         "expected exactly one reservation to succeed"
diff --git a/crates/warp-core/tests/snapshot_reachability_tests.rs b/crates/warp-core/tests/snapshot_reachability_tests.rs
index e39276fb..f9fcb48e 100644
--- a/crates/warp-core/tests/snapshot_reachability_tests.rs
+++ b/crates/warp-core/tests/snapshot_reachability_tests.rs
@@ -3,7 +3,8 @@
 
 #![allow(missing_docs)]
 use warp_core::{
-    encode_motion_payload, make_edge_id, make_node_id, make_type_id, GraphStore, NodeRecord,
+    encode_motion_atom_payload, make_edge_id, make_node_id, make_type_id, AttachmentValue,
+    GraphStore, NodeRecord,
 };
 
 fn snapshot_hash(store: GraphStore, root: warp_core::NodeId) -> [u8; 32] {
@@ -18,33 +19,22 @@ fn unreachable_nodes_do_not_affect_hash() {
     let world_ty = make_type_id("world");
 
     let mut store_a = GraphStore::default();
-    store_a.insert_node(
-        root,
-        NodeRecord {
-            ty: world_ty,
-            payload: None,
-        },
-    );
+    store_a.insert_node(root, NodeRecord { ty: world_ty });
 
     let hash_a = snapshot_hash(store_a, root);
 
     // Add an unreachable entity elsewhere; hash should remain identical.
     let mut store_b = GraphStore::default();
-    store_b.insert_node(
-        root,
-        NodeRecord {
-            ty: world_ty,
-            payload: None,
-        },
-    );
+    store_b.insert_node(root, NodeRecord { ty: world_ty });
     let unreachable = make_node_id("ghost-entity");
     let ent_ty = make_type_id("entity");
-    store_b.insert_node(
+    store_b.insert_node(unreachable, NodeRecord { ty: ent_ty });
+    store_b.set_node_attachment(
         unreachable,
-        NodeRecord {
-            ty: ent_ty,
-            payload: Some(encode_motion_payload([0.0, 0.0, 0.0], [0.0, 0.0, 0.0])),
-        },
+        Some(AttachmentValue::Atom(encode_motion_atom_payload(
+            [0.0, 0.0, 0.0],
+            [0.0, 0.0, 0.0],
+        ))),
     );
 
     let hash_b = snapshot_hash(store_b, root);
@@ -56,13 +46,7 @@ fn reachable_edges_affect_hash() {
     let root = make_node_id("root2");
     let world_ty = make_type_id("world");
     let mut store = GraphStore::default();
-    store.insert_node(
-        root,
-        NodeRecord {
-            ty: world_ty,
-            payload: None,
-        },
-    );
+    store.insert_node(root, NodeRecord { ty: world_ty });
 
     // Initially only root is reachable; hash0
     let hash0 = snapshot_hash(store.clone(), root);
@@ -71,13 +55,7 @@ fn reachable_edges_affect_hash() {
     let child = make_node_id("child");
     let ent_ty = make_type_id("entity");
     let edge_ty = make_type_id("has");
-    store.insert_node(
-        child,
-        NodeRecord {
-            ty: ent_ty,
-            payload: None,
-        },
-    );
+    store.insert_node(child, NodeRecord { ty: ent_ty });
     store.insert_edge(
         root,
         warp_core::EdgeRecord {
@@ -85,7 +63,6 @@ fn reachable_edges_affect_hash() {
             from: root,
             to: child,
             ty: edge_ty,
-            payload: None,
         },
     );
 
diff --git a/crates/warp-core/tests/tick_receipt_tests.rs b/crates/warp-core/tests/tick_receipt_tests.rs
index 1ffec31c..9fd3ebbd 100644
--- a/crates/warp-core/tests/tick_receipt_tests.rs
+++ b/crates/warp-core/tests/tick_receipt_tests.rs
@@ -4,9 +4,10 @@
 #![allow(missing_docs)]
 
 use warp_core::{
-    encode_motion_payload, make_node_id, make_type_id, scope_hash, ConflictPolicy, Engine,
-    Footprint, GraphStore, Hash, NodeId, NodeRecord, PatternGraph, RewriteRule,
-    TickReceiptDisposition, TickReceiptEntry, TickReceiptRejection, TxId, MOTION_RULE_NAME,
+    encode_motion_atom_payload, make_node_id, make_type_id, scope_hash, AttachmentValue,
+    ConflictPolicy, Engine, Footprint, GraphStore, Hash, NodeId, NodeKey, NodeRecord, PatternGraph,
+    RewriteRule, TickReceiptDisposition, TickReceiptEntry, TickReceiptRejection, TxId, WarpId,
+    MOTION_RULE_NAME,
 };
 
 fn rule_id(name: &str) -> Hash {
@@ -38,7 +39,8 @@ fn compute_rewrites_digest(entries: &[TickReceiptEntry]) -> Hash {
     for entry in applied {
         hasher.update(&entry.rule_id);
         hasher.update(&entry.scope_hash);
-        hasher.update(&(entry.scope).0);
+        hasher.update(&(entry.scope.warp_id).0);
+        hasher.update(&(entry.scope.local_id).0);
     }
     hasher.finalize().into()
 }
@@ -68,13 +70,17 @@ fn fp_write_scope_and_other(_: &GraphStore, scope: &NodeId) -> Footprint {
     fp
 }
 
+fn root_warp_id() -> WarpId {
+    warp_core::make_warp_id("root")
+}
+
 /// Finds three synthetic rule ids (A, B, C) such that when applied to
 /// `(scope_a, scope_b, scope_a)` the deterministic scheduler order is `[A, B, C]`
 /// with `C` last.
 ///
 /// This ensures the first two candidates are accepted and the last candidate is
 /// rejected with two blockers for stable multi-blocker assertions.
-fn pick_rule_ids_for_blocker_test(scope_a: &NodeId, scope_b: &NodeId) -> (Hash, Hash, Hash) {
+fn pick_rule_ids_for_blocker_test(scope_a: &NodeKey, scope_b: &NodeKey) -> (Hash, Hash, Hash) {
     // Pick three distinct synthetic ids such that:
     // - the combined-write rule (C) sorts last by scope_hash
     // - the two single-write rules (A, B) sort before it
@@ -116,16 +122,11 @@ fn pick_rule_ids_for_blocker_test(scope_a: &NodeId, scope_b: &NodeId) -> (Hash,
 fn commit_with_receipt_records_accept_reject_and_matches_snapshot_digests() {
     let entity = make_node_id("tick-receipt-entity");
     let entity_type = make_type_id("entity");
-    let payload = encode_motion_payload([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
+    let payload = encode_motion_atom_payload([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
 
     let mut store = GraphStore::default();
-    store.insert_node(
-        entity,
-        NodeRecord {
-            ty: entity_type,
-            payload: Some(payload),
-        },
-    );
+    store.insert_node(entity, NodeRecord { ty: entity_type });
+    store.set_node_attachment(entity, Some(AttachmentValue::Atom(payload)));
 
     let mut engine = Engine::new(store, entity);
     engine
@@ -204,12 +205,21 @@ fn commit_with_receipt_records_multi_blocker_causality() {
 
     let ty = make_type_id("entity");
     let mut store = GraphStore::default();
-    store.insert_node(scope_a, NodeRecord { ty, payload: None });
-    store.insert_node(scope_b, NodeRecord { ty, payload: None });
+    store.insert_node(scope_a, NodeRecord { ty });
+    store.insert_node(scope_b, NodeRecord { ty });
 
     let mut engine = Engine::new(store, scope_a);
 
-    let (id_a, id_b, id_c) = pick_rule_ids_for_blocker_test(&scope_a, &scope_b);
+    let warp_id = root_warp_id();
+    let scope_a_key = NodeKey {
+        warp_id,
+        local_id: scope_a,
+    };
+    let scope_b_key = NodeKey {
+        warp_id,
+        local_id: scope_b,
+    };
+    let (id_a, id_b, id_c) = pick_rule_ids_for_blocker_test(&scope_a_key, &scope_b_key);
 
     const RULE_A: &str = "test/write-scope-a";
     const RULE_B: &str = "test/write-scope-b";
diff --git a/crates/warp-core/tests/tx_lifecycle_tests.rs b/crates/warp-core/tests/tx_lifecycle_tests.rs
index 8020bfd8..dbba32ef 100644
--- a/crates/warp-core/tests/tx_lifecycle_tests.rs
+++ b/crates/warp-core/tests/tx_lifecycle_tests.rs
@@ -3,24 +3,19 @@
 
 #![allow(missing_docs)]
 use warp_core::{
-    encode_motion_payload, make_node_id, make_type_id, EngineError, GraphStore, NodeRecord,
-    MOTION_RULE_NAME,
+    encode_motion_atom_payload, make_node_id, make_type_id, AttachmentValue, EngineError,
+    GraphStore, NodeRecord, MOTION_RULE_NAME,
 };
 
 #[test]
 fn tx_invalid_after_commit() {
     let entity = make_node_id("tx-lifecycle-entity");
     let entity_type = make_type_id("entity");
-    let payload = encode_motion_payload([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
+    let payload = encode_motion_atom_payload([0.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
 
     let mut store = GraphStore::default();
-    store.insert_node(
-        entity,
-        NodeRecord {
-            ty: entity_type,
-            payload: Some(payload),
-        },
-    );
+    store.insert_node(entity, NodeRecord { ty: entity_type });
+    store.set_node_attachment(entity, Some(AttachmentValue::Atom(payload)));
 
     let mut engine = warp_core::Engine::new(store, entity);
     engine
diff --git a/crates/warp-ffi/src/lib.rs b/crates/warp-ffi/src/lib.rs
index 92fd2932..55beecc7 100644
--- a/crates/warp-ffi/src/lib.rs
+++ b/crates/warp-ffi/src/lib.rs
@@ -13,8 +13,8 @@ use std::os::raw::c_char;
 use std::slice;
 
 use warp_core::{
-    build_motion_demo_engine, decode_motion_payload, encode_motion_payload, make_node_id,
-    make_type_id, ApplyResult, Engine, NodeId, NodeRecord, TxId, MOTION_RULE_NAME,
+    build_motion_demo_engine, decode_motion_atom_payload, encode_motion_atom_payload, make_node_id,
+    make_type_id, ApplyResult, AttachmentValue, Engine, NodeId, NodeRecord, TxId, MOTION_RULE_NAME,
 };
 
 /// Opaque engine pointer exposed over the C ABI.
@@ -102,18 +102,22 @@ pub unsafe extern "C" fn warp_engine_spawn_motion_entity(
 
     let node_id = make_node_id(label_str);
     let entity_type = make_type_id("entity");
-    let payload = encode_motion_payload([px, py, pz], [vx, vy, vz]);
+    let payload = encode_motion_atom_payload([px, py, pz], [vx, vy, vz]);
 
-    engine.inner.insert_node(
-        node_id,
-        NodeRecord {
-            ty: entity_type,
-            payload: Some(payload),
-        },
-    );
+    if engine
+        .inner
+        .insert_node_with_attachment(
+            node_id,
+            NodeRecord { ty: entity_type },
+            Some(AttachmentValue::Atom(payload)),
+        )
+        .is_err()
+    {
+        return false;
+    }
 
     unsafe {
-        (*out_handle).bytes = node_id.0;
+        (*out_handle).bytes = *node_id.as_bytes();
     }
     true
 }
@@ -210,15 +214,14 @@ pub unsafe extern "C" fn warp_engine_read_motion(
         Some(id) => id,
         None => return false,
     };
-    let record = match engine.inner.node(&node_id) {
-        Some(record) => record,
-        None => return false,
-    };
-    let payload = match record.payload.as_ref() {
-        Some(payload) => payload,
-        None => return false,
+    if engine.inner.node(&node_id).ok().flatten().is_none() {
+        return false;
+    }
+    let payload = match engine.inner.node_attachment(&node_id) {
+        Ok(Some(AttachmentValue::Atom(payload))) => payload,
+        _ => return false,
     };
-    let (position, velocity) = match decode_motion_payload(payload) {
+    let (position, velocity) = match decode_motion_atom_payload(payload) {
         Some(values) => values,
         None => return false,
     };
diff --git a/crates/warp-viewer/src/scene.rs b/crates/warp-viewer/src/scene.rs
index 05ac0ca5..a89dc065 100644
--- a/crates/warp-viewer/src/scene.rs
+++ b/crates/warp-viewer/src/scene.rs
@@ -263,23 +263,11 @@ fn build_sample_graph() -> GraphStore {
     let worm_ty = make_type_id("wormhole");
 
     let world = make_node_id("world");
-    store.insert_node(
-        world,
-        NodeRecord {
-            ty: world_ty,
-            payload: None,
-        },
-    );
+    store.insert_node(world, NodeRecord { ty: world_ty });
 
     for i in 0..8u8 {
         let id = make_node_id(&format!("region-{i}"));
-        store.insert_node(
-            id,
-            NodeRecord {
-                ty: region_ty,
-                payload: None,
-            },
-        );
+        store.insert_node(id, NodeRecord { ty: region_ty });
         store.insert_edge(
             world,
             EdgeRecord {
@@ -287,18 +275,11 @@ fn build_sample_graph() -> GraphStore {
                 from: world,
                 to: id,
                 ty: region_ty,
-                payload: None,
             },
         );
         for j in 0..3u8 {
             let leaf = make_node_id(&format!("leaf-{i}-{j}"));
-            store.insert_node(
-                leaf,
-                NodeRecord {
-                    ty: leaf_ty,
-                    payload: None,
-                },
-            );
+            store.insert_node(leaf, NodeRecord { ty: leaf_ty });
             store.insert_edge(
                 id,
                 EdgeRecord {
@@ -306,7 +287,6 @@ fn build_sample_graph() -> GraphStore {
                     from: id,
                     to: leaf,
                     ty: leaf_ty,
-                    payload: None,
                 },
             );
         }
@@ -322,7 +302,6 @@ fn build_sample_graph() -> GraphStore {
                 from: a_id,
                 to: b_id,
                 ty: worm_ty,
-                payload: None,
             },
         );
         store.insert_edge(
@@ -332,7 +311,6 @@ fn build_sample_graph() -> GraphStore {
                 from: b_id,
                 to: a_id,
                 ty: worm_ty,
-                payload: None,
             },
         );
     }
diff --git a/crates/warp-wasm/Cargo.toml b/crates/warp-wasm/Cargo.toml
index a0f54912..619d5fa6 100644
--- a/crates/warp-wasm/Cargo.toml
+++ b/crates/warp-wasm/Cargo.toml
@@ -17,12 +17,13 @@ crate-type = ["cdylib"]
 
 [features]
 default = []
-console-panic = ["console_error_panic_hook"]
+console-panic = ["console_error_panic_hook", "web-sys"]
 
 [dependencies]
 warp-core = { workspace = true }
 wasm-bindgen = "0.2.106"
 js-sys = "0.3.83"
+web-sys = { version = "0.3.83", optional = true, features = ["console"] }
 console_error_panic_hook = { version = "0.1.7", optional = true }
 
 [dev-dependencies]
diff --git a/crates/warp-wasm/src/lib.rs b/crates/warp-wasm/src/lib.rs
index 9705337b..6a87a9bb 100644
--- a/crates/warp-wasm/src/lib.rs
+++ b/crates/warp-wasm/src/lib.rs
@@ -12,8 +12,8 @@ use std::rc::Rc;
 
 use js_sys::Uint8Array;
 use warp_core::{
-    build_motion_demo_engine, decode_motion_payload, encode_motion_payload, make_node_id,
-    make_type_id, ApplyResult, Engine, NodeId, NodeRecord, TxId, MOTION_RULE_NAME,
+    build_motion_demo_engine, decode_motion_atom_payload, encode_motion_atom_payload, make_node_id,
+    make_type_id, ApplyResult, AttachmentValue, Engine, NodeId, NodeRecord, TxId, MOTION_RULE_NAME,
 };
 use wasm_bindgen::prelude::*;
 
@@ -153,17 +153,21 @@ impl WasmEngine {
         let mut engine = self.inner.borrow_mut();
         let node_id = make_node_id(label);
         let entity_type = make_type_id("entity");
-        let payload = encode_motion_payload(position.components(), velocity.components());
+        let payload = encode_motion_atom_payload(position.components(), velocity.components());
 
-        engine.insert_node(
+        if let Err(_err) = engine.insert_node_with_attachment(
             node_id,
-            NodeRecord {
-                ty: entity_type,
-                payload: Some(payload),
-            },
-        );
+            NodeRecord { ty: entity_type },
+            Some(AttachmentValue::Atom(payload)),
+        ) {
+            #[cfg(feature = "console-panic")]
+            web_sys::console::error_1(
+                &format!("spawn_motion_entity failed for node {node_id:?}: {_err:?}").into(),
+            );
+            return Uint8Array::new_with_length(0);
+        }
 
-        Uint8Array::from(node_id.0.as_slice())
+        Uint8Array::from(node_id.as_bytes().as_slice())
     }
 
     #[wasm_bindgen]
@@ -210,9 +214,15 @@ impl WasmEngine {
     pub fn read_motion(&self, entity_id: &[u8]) -> Option<Box<[f32]>> {
         let engine = self.inner.borrow();
         let node_id = bytes_to_node_id(entity_id)?;
-        let record = engine.node(&node_id)?;
-        let payload = record.payload.as_ref()?;
-        let (position, velocity) = decode_motion_payload(payload)?;
+        let payload = match engine.node_attachment(&node_id) {
+            Ok(Some(value)) => value,
+            Ok(None) => return None,
+            Err(_) => return None,
+        };
+        let AttachmentValue::Atom(payload) = payload else {
+            return None;
+        };
+        let (position, velocity) = decode_motion_atom_payload(payload)?;
         let mut data = Vec::with_capacity(6);
         data.extend_from_slice(&position);
         data.extend_from_slice(&velocity);
diff --git a/docs/adr/ADR-0001-warp-two-plane-skeleton-and-attachments.md b/docs/adr/ADR-0001-warp-two-plane-skeleton-and-attachments.md
new file mode 100644
index 00000000..f419b8eb
--- /dev/null
+++ b/docs/adr/ADR-0001-warp-two-plane-skeleton-and-attachments.md
@@ -0,0 +1,81 @@
+<!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
+<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
+# ADR-0001: Two-plane WARP representation in Echo (SkeletonGraph + Attachment Plane)
+
+Status: Accepted  
+Date: 2025-12-29
+
+## Context
+
+The WARP papers define a WARP state as a two-plane structure:
+
+- a **skeleton plane** (structural plane) used for matching, rewriting, scheduling, slicing, and determinism; and
+- an **attachment plane** (attachments over vertices/edges) where attachments may be atomic (depth-0) or recursively descended.
+
+Echo historically represented nodes/edges with optional opaque byte payloads. That created ambiguity:
+
+- Are we implementing a full WARP state, or only the skeleton projection `π(U)`?
+- Are payload bytes “atoms,” or can they contain hidden structural subgraphs?
+- Without strict typing, “same bytes, different meaning” can collide at the hash/digest boundary.
+
+Echo also requires that graph rewriting remain fast: core matching/indexing must not traverse or decode attachment structures unless explicitly requested by a rule.
+
+## Decision
+
+1) Echo’s core in-memory storage separates **SkeletonGraph** (structure) from the attachment plane (payloads).
+
+The rewrite hot path is defined over the skeleton projection `π(U)` (matching/indexing does not decode attachments unless a rule explicitly opts in).
+
+2) Attachments exist but are represented as **typed atoms** by default (depth-0):
+
+- `AtomPayload = { type_id: TypeId, bytes: Bytes }`
+
+3) Any semantic meaning of payload bytes is enforced at **typed boundaries** via strict codecs:
+
+- decoding is explicit and deterministic;
+- decoding does not occur in the core scheduler/matcher unless a rule chooses to decode.
+
+4) **No hidden edges**:
+
+- If a dependency matters for matching, causality, slicing, or rewrite applicability, it must be represented explicitly in the skeleton plane.
+- Payload bytes must not be used to smuggle graph structure the engine cannot see.
+
+5) Future recursion in attachments is represented as **flattened indirection** (future work):
+
+- recursion is modeled via explicit references (e.g., attachment-root references / skeleton-visible links), not nested Rust structs.
+
+## Laws (Invariants)
+
+Echo treats the following as project laws:
+
+- Skeleton rewrites never decode attachments.
+- Attachment decoding happens only in typed boundaries (rules/views), never in core matching/indexing.
+- `type_id` participates in canonical encoding/digest; identical bytes with different `type_id` must not collide.
+- No hidden edges in payload bytes.
+
+See also: `docs/warp-two-plane-law.md`.
+
+## Consequences
+
+Pros:
+
+- Preserves rewrite performance and determinism (hot path remains skeleton-only).
+- Makes attachment typing explicit and safe.
+- Clarifies terminology: SkeletonGraph vs. WarpState vs. `π(U)`.
+- Establishes a clean path to “WARPs all the way down” via explicit indirection.
+
+Cons:
+
+- Adds `TypeId` + codec plumbing for payloads.
+- Call sites must choose/define payload type identifiers (or use helper constructors).
+- Full descended attachments require additional design work (Stage B1).
+
+## Alternatives considered
+
+- Store nested WARP graphs directly inside payload bytes: rejected (hides structure from matching/slicing and causes correctness/perf hazards).
+- Represent recursion via recursive Rust types: rejected (poor fit for sharing, determinism, patch/slice semantics, and stable hashing).
+
+## Follow-ups
+
+- SPEC for Attachment Plane v0 (Atoms): codec boundary, deterministic error semantics, canonical encoding rules.
+- Stage B1 proposal: descended attachments using explicit references/links (no hidden edges).
diff --git a/docs/adr/ADR-0002-warp-instances-descended-attachments.md b/docs/adr/ADR-0002-warp-instances-descended-attachments.md
new file mode 100644
index 00000000..70b35b6b
--- /dev/null
+++ b/docs/adr/ADR-0002-warp-instances-descended-attachments.md
@@ -0,0 +1,119 @@
+<!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
+<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
+# ADR-0002: WarpInstances + Descended Attachments via Flattened Indirection
+
+Status: Accepted  
+Date: 2025-12-30  
+Owners: Echo / WARP core
+
+## Context
+
+The WARP theory defines a two-plane state object:
+
+- **Skeleton plane**: explicit graph structure used for matching, rewriting, scheduling, slicing, determinism.
+- **Attachment plane**: payloads over vertices/edges; attachments may be:
+  - atomic (depth 0), or
+  - recursively descended (“WARPs all the way down”).
+
+Echo v0 implemented depth-0 attachments as **typed atoms** (Stage B0). However, depth‑0 alone is not the canonical “graphs all the way down” object: we need a first-class way to represent descended attachments without:
+
+- nested recursive Rust heap structures,
+- hidden structure embedded inside payload bytes,
+- or recursive traversal/decoding in the rewrite hot path.
+
+## Decision
+
+We represent descended attachments as **flattened indirection** using **WarpInstances**.
+
+1) Introduce **WarpInstances** identified by `WarpId`.
+2) Instance-scope ids using 2D keys:
+   - `NodeKey = { warp_id: WarpId, local_id: NodeId }`
+   - `EdgeKey = { warp_id: WarpId, local_id: EdgeId }`
+3) Extend attachment values:
+   - `AttachmentValue = Atom(AtomPayload) | Descend(WarpId)`
+4) Make attachment slots first-class and engine-visible:
+   - `AttachmentKey { owner: AttachmentOwner(NodeKey|EdgeKey), plane: Alpha|Beta }`
+5) Keep the rewrite/match hot path skeleton-only:
+   - matching/indexing must not decode atoms or automatically traverse descended instances
+   - descent is explicit (`Descend(WarpId)`), not encoded inside bytes
+6) Authoring a descended instance is **atomic**:
+   - creating the child instance and setting `Descend(child_warp)` must be performed as a single canonical operation (`OpenPortal`).
+7) Multi-parent merges (DAG) require explicit resolution at the patch level:
+   - merge commits must resolve slot conflicts (including attachment slots) deterministically.
+
+## Laws (Non-Negotiable Invariants)
+
+### L1 — No hidden edges
+
+If structure participates in matching, causality/independence, slicing correctness, or replay correctness, it must be represented explicitly in the skeleton plane and/or via attachment slots.
+
+Payload bytes must not be treated as “embedded graphs” that the engine cannot see.
+
+### L2 — Skeleton rewrites never decode attachments
+
+Core matching/indexing/scheduling operates over the skeleton plane. Attachment decoding happens only when a rule/view explicitly chooses to decode.
+
+### L3 — Descended instances are reached only via attachment slots
+
+Cross-instance connectivity in v1 is represented exclusively by `AttachmentValue::Descend(WarpId)` through an `AttachmentKey`. No arbitrary edges across instances.
+
+### L4 — Descent-chain footprinting
+
+Execution inside a descended instance carries a `descent_stack: Vec<AttachmentKey>` from the root instance down to the current instance.
+
+Any match/exec within the descended instance MUST include READs of every attachment key in the descent stack.
+
+Rationale: changing any descent pointer changes reachability/meaning and must deterministically invalidate matches and scheduling decisions.
+
+### L5 — Slice closure includes the descent chain
+
+If a slice demands any slot within instance `W`, the slice must include producers for the attachment chain establishing reachability (root → … → W). This is achieved by:
+
+- treating attachment slots as slots (`SlotId::Attachment(AttachmentKey)`), and
+- ensuring descended execution reads the descent-chain slots (L4), so the generic Paper III slice algorithm pulls in the portal producers.
+
+### L6 — Portal authoring is atomic (OpenPortal)
+
+Creating a child instance and setting `Attachment[key] = Descend(child_warp)` MUST be atomic at the patch level.
+
+Slices/replay must not be able to observe:
+- a “dangling portal” (`Descend(child_warp)` without a corresponding `WarpInstance(child_warp)`), or
+- an “orphan instance” (a `WarpInstance` whose recorded `parent` slot does not point to it).
+
+### L7 — Merge commits resolve slot conflicts explicitly
+
+For any merge commit with multiple parents, if two or more parents write the same slot (including `SlotId::Attachment(...)`),
+the merge commit MUST contain an explicit resolution write in its patch.
+
+The merge is a first-class event, not implicit ancestry magic.
+
+## Tooling note: instance zoom vs wormholes
+
+Instance zoom/projection is a **state-structure** view derived from explicit `Descend(WarpId)` relationships.
+
+Wormholes remain a **history/payload** compression mechanism over tick ranges. These are intentionally distinct.
+
+## Consequences
+
+Pros:
+- Enables “WARPs all the way down” without recursive hot paths.
+- Makes descent visible and hashable (no hidden structure).
+- Preserves determinism and scales to large graphs.
+- Allows multi-scale tooling by projecting instances as macro-nodes (state zoom), without conflating with wormholes (history compression).
+
+Cons:
+- Requires instance-scoped keys and explicit attachment slot identity.
+- Adds small footprint overhead (bounded by descent depth).
+
+## Alternatives Considered
+
+1) Embed subgraphs inside payload bytes.
+   - Rejected: violates “no hidden edges”; breaks slicing/causality; unsafe typing boundary.
+
+2) Use recursive Rust data structures (`Box`, `Rc`, etc).
+   - Rejected: poor fit for cycles/sharing, determinism, and patch/slice boundary artifacts.
+
+## Follow-ups
+
+- Spec: Descended Attachments v1 (WarpInstances + attachment slots + footprint laws).
+- Tooling: state “zoom” views projecting instances and `Descend` links (explicitly not wormholes).
diff --git a/docs/aion-papers-bridge.md b/docs/aion-papers-bridge.md
index 4e513d28..adcfb051 100644
--- a/docs/aion-papers-bridge.md
+++ b/docs/aion-papers-bridge.md
@@ -49,7 +49,7 @@ These tables are intentionally “backlog-driving”: they identify what exists
 
 | Paper I concept | Echo status | Touchpoints (today) | Backlog / next step | Deviation notes |
 | --- | --- | --- | --- | --- |
-| WARP graph = atom **or** skeleton-with-attachments | Partial (skeleton + opaque payloads) | `crates/warp-core/src/graph.rs`, `crates/warp-core/src/record.rs` | Decide whether recursive “attachments are WARPs” become first-class types or remain a payload encoding convention | Current engine spike treats “attachments” as bytes; may be enough for game engines if payload schemas are disciplined. |
+| WARP graph = atom **or** skeleton-with-attachments | Partial (skeleton + typed atom attachments) | `crates/warp-core/src/graph.rs`, `crates/warp-core/src/record.rs`, `crates/warp-core/src/attachment.rs`, `docs/warp-two-plane-law.md` | Stage B1: represent descended attachments via explicit indirection (skeleton-visible links / refs), not “hidden graphs inside bytes” | Echo v0 treats attachments as **typed atoms** (`AtomPayload { type_id, bytes }`) and commits the payload `type_id` into boundary digests. Full “WARPs all the way down” (descended attachments) is not implemented yet by design. |
 | Depth / finite unfoldings | Not implemented explicitly | (N/A; conceptual) | If observers/tools need “unfold to depth k”, define a canonical encoding for nested payloads and add tooling helpers | Might stay in the tooling layer, not the core engine. |
 | Morphisms / category framing | Not implemented explicitly | (Docs only) | Identify which morphism fragments matter for engine/tooling APIs (likely: stable IDs + isomorphism criteria for hashing) | Echo currently uses hashes + canonical encodings as “practical morphisms”. |
 
@@ -98,12 +98,13 @@ These tables are intentionally “backlog-driving”: they identify what exists
 **Relevance to Echo:**
 
 - Echo’s “everything is a graph” story is Paper I’s substrate claim.
-- Echo’s current engine spike (`warp-core`) implements a *flat* typed graph store (`GraphStore` with node/edge records + payload bytes).
-- The WARP “attachments are themselves graphs” concept is currently represented *implicitly* (payload bytes can encode nested graphs / structured payloads), not as a first-class recursive structure in Rust types.
+- Echo’s current engine spike (`warp-core`) implements a *flat* skeleton graph (`GraphStore`) plus **depth-0 attachments** as typed atoms (`AtomPayload { type_id, bytes }`).
+- The WARP “attachments are themselves graphs” concept is **not implemented yet**. Echo’s project law forbids treating payload bytes as “hidden edges”; descended attachments must be represented via explicit, skeleton-visible indirection when implemented (Stage B1).
 
 **Echo touchpoints:**
 
-- `crates/warp-core/src/graph.rs` + `crates/warp-core/src/record.rs` are the current concrete “graph store” substrate.
+- `crates/warp-core/src/graph.rs` is the current SkeletonGraph implementation.
+- `crates/warp-core/src/record.rs` + `crates/warp-core/src/attachment.rs` define node/edge records and depth-0 typed atom attachments.
 - `crates/echo-graph` is the canonical *tool/wire* graph shape.
 
 ## Paper II — Deterministic evolution: ticks, footprints, and receipts
diff --git a/docs/architecture/TERMS_WARP_STATE_INSTANCES_PORTALS_WORMHOLES.md b/docs/architecture/TERMS_WARP_STATE_INSTANCES_PORTALS_WORMHOLES.md
new file mode 100644
index 00000000..eac30d60
--- /dev/null
+++ b/docs/architecture/TERMS_WARP_STATE_INSTANCES_PORTALS_WORMHOLES.md
@@ -0,0 +1,169 @@
+<!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
+<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
+# Terms: WARP State, SkeletonGraph, Instances, Portals, Wormholes
+
+Status: Canonical Terminology (Project Law)  
+Date: 2025-12-30  
+Scope: Echo / WARP implementation
+
+This document exists to prevent terminology collisions across:
+- the published WARP papers (AIΩN Foundations series),
+- older book drafts, and
+- the Echo codebase.
+
+If a term in code/docs conflicts with this file, **this file wins**.
+
+---
+
+## Core objects
+
+### WARP state (`WarpState`)
+
+Meaning: the theoretical two-plane state object.
+
+`WarpState` is:
+- **SkeletonGraph** `G` (explicit structure)
+- **Attachment plane** `A` (payloads over skeleton vertices/edges)
+
+Informally: `U = (G, A)` and `π(U) = G`.
+
+In code today:
+- `warp_core::WarpState` is the multi-instance container.
+- Each instance’s skeleton + attachments live in `warp_core::GraphStore`.
+
+### SkeletonGraph
+
+Meaning: the explicit structural graph that rewriting/scheduling/slicing operates on.
+
+SkeletonGraph contains:
+- nodes, edges, ports (if applicable)
+- adjacency / incidence relations
+- stable ids
+
+Law: SkeletonGraph is the **hot path**. Matching/indexing must remain skeleton-only (no attachment decode, no recursive traversal).
+
+### Attachment plane
+
+Meaning: attachments assigned to skeleton elements, stored separately from the skeleton.
+
+Attachment values:
+- `Atom(TypeId, Bytes)` (depth-0)
+- `Descend(WarpId)` (indirection to a child instance)
+
+Law: attachments are not “arbitrary bytes with implied structure.” Structure that matters must be explicit (see “No Hidden Edges”).
+
+---
+
+## Depth
+
+### Depth 0
+
+Meaning: a `WarpState` where all attachment values are atoms.
+
+This is a valid state of the model (not “missing features”): atoms are the base case.
+
+### Descended attachments (“WARPs all the way down”)
+
+Meaning: an attachment slot value is `Descend(child_warp)` which points to a child instance.
+
+Important: descended attachments are represented via **flattened indirection**, not nested Rust graphs/structs.
+
+---
+
+## Instances / layers
+
+### `WarpInstance`
+
+Meaning: a namespace/layer for a skeleton graph + its attachments.
+
+Instances are identified by `WarpId`.
+
+Each instance has:
+- `warp_id`
+- `root_node` (entry point into its skeleton)
+- `parent: Option<AttachmentKey>` (the attachment slot that descends into it; `None` for the root instance)
+
+Law: matching/indexing is scoped to a single instance.
+
+### `NodeKey` / `EdgeKey`
+
+Meaning: instance-scoped identifiers.
+
+- `NodeKey = { warp_id, local_id }`
+- `EdgeKey = { warp_id, local_id }`
+
+This prevents cross-instance collisions and makes patching/slicing precise.
+
+---
+
+## Portals
+
+### Portal
+
+Meaning: the relationship created when an attachment slot is set to `Descend(child_warp)`.
+
+Portals are not “edges hidden in bytes.” Portals are explicit, engine-visible indirection.
+
+### `OpenPortal` (canonical operation)
+
+Meaning: the atomic authoring op that:
+- establishes/creates the child instance, and
+- sets the `Descend(child_warp)` attachment value
+
+Law: portals must be authored atomically. No dangling portals. No orphan instances.
+
+---
+
+## Wormholes (reserved term)
+
+### Wormhole
+
+Meaning (reserved): **history/provenance payload compression** over tick ranges.
+
+Wormholes are a history/payload artifact (worldline compression), not a state-structure feature.
+
+Law: do not use “wormhole” to refer to instance zoom, portals, or descended attachments.
+
+---
+
+## Views / projections
+
+### Instance zoom / projection (state-scale)
+
+Meaning: tooling view that shows instances as macro-nodes and portals as macro-edges.
+
+This is derived from explicit `Descend(WarpId)` structure (state), not tick history (payload).
+
+Law: instance zoom is not wormholes.
+
+---
+
+## Merge + slicing (DAG)
+
+### Merge commit
+
+Meaning: a commit with multiple parents.
+
+Law: slot conflicts must be resolved explicitly by the merge patch (no implicit “parent order wins” semantics).
+
+### Slice
+
+Meaning: a provenance subgraph sufficient to replay/justify demanded outputs.
+
+Slicing may be:
+- worldline slice (single-parent fast path), or
+- DAG slice (multi-parent general case)
+
+Law: if a demanded slot is in a descended instance, the slice must include the portal chain establishing reachability.
+
+---
+
+## Project laws (quick list)
+
+1) No hidden edges: never smuggle structure inside atom bytes.
+2) Skeleton hot path: skeleton rewrites never decode attachments.
+3) Typed atoms: Atom = (TypeId, Bytes); TypeId participates in canonical identity.
+4) Flattened recursion: Descend uses indirection (WarpId), not nested structs.
+5) Atomic portals: create child + set Descend in one canonical op (`OpenPortal`).
+6) Merge explicitness: multi-parent conflicts resolved by merge patch writes.
+7) Wormhole reserved: wormhole = history/payload tick-range compression only.
diff --git a/docs/decision-log.md b/docs/decision-log.md
index 361bb635..116395e3 100644
--- a/docs/decision-log.md
+++ b/docs/decision-log.md
@@ -6,6 +6,10 @@
 
 | Date | Context | Decision | Rationale | Consequence |
 | ---- | ------- | -------- | --------- | ----------- |
+| 2025-12-30 | Stage B1 usability/docs: make descent-chain law and portal-chain slicing concrete | Expand `docs/spec-warp-core.md` with worked Stage B1 examples (descent-chain reads → `Footprint.a_read` → patch `in_slots`, and portal-chain inclusion in Paper III slicing). Add `Engine::with_state(...) -> Result<Engine, EngineError>` so tools/tests can initialize an engine from an externally constructed multi-instance `WarpState` without exposing internal maps. | Stage B1 correctness depends on subtle invariants that are easy to “get almost right” without a concrete example; documentation must prevent “looks right, doesn’t slice.” A state-based engine constructor is the smallest ergonomic primitive that enables multi-instance fixtures and patch-replay workflows while keeping the rewrite hot path unchanged. | Contributors can reproduce and reason about B1 behavior quickly; multi-instance demos/tests can be built via patch replay + `Engine::with_state` rather than internal mutation; the portal-chain dependency is explicit in both docs and slice behavior. |
+| 2025-12-30 | Review follow-ups: receipts + wasm/ffi ergonomics + GraphStore deletes | Keep tick receipts self-describing and instance-scoped: document `TickReceiptEntry.scope` as a `NodeKey` (`warp_id`, `local_id`) and include both components in the receipt digest encoding. Add `Engine::insert_node_with_attachment` for atomic demo/bootstrap insertion and use it from WASM/FFI boundaries. Remove `delete_node_cascade`’s inbound `O(total_edges)` scan by maintaining reverse inbound edge indexes (`edges_to` + `edge_to_index`). | Receipts are used as deterministic audit artifacts: scope must be unambiguous once WARP is multi-instance. WASM/FFI callers need deterministic failure behavior and acceptable debugging signals. Delete-heavy workloads should not accidentally hit `O(total_edges)` traps when the store already maintains reverse indexes. | Receipt docs and digest encoding are clearer and stable; WASM spawn logs errors behind `console-panic` and returns a clear sentinel; FFI spawn avoids partial init; node deletion now scales with the number of incident edges rather than total graph size. |
+| 2025-12-30 | Post-merge touch-ups (Stage B1): API encapsulation + semantics | Add stable id byte accessors (`NodeId::as_bytes`, `WarpId::as_bytes`), add `GraphStore::has_edge`, and tighten `Engine` accessors so “unknown warp store” is distinguished from “missing node/attachment” (`Result<Option<_>, EngineError>`). Make root-store mutation helpers (`insert_node`, `set_node_attachment`) return `Result` (no silent mutation drops). | Prevent callers from depending on tuple-field internals (`.0`) and reduce coupling to internal map fields. The engine must be deterministic and explicit about failure modes; “drop writes in release” is a correctness hazard. Returning `Result` keeps clippy/CI gates strict without using panics/`expect`. | Call sites in wasm/ffi/benches/tests were updated to handle `Result`; patch/receipt hashing uses the stable accessors; docs now use explicit “node-attachment plane / edge-attachment plane” terminology. CI remains green under `-D warnings -D missing_docs`. |
+| 2025-12-29 | Paper I/II two-plane semantics (SkeletonGraph + attachment plane) | Define Echo’s `warp-core` store as the **SkeletonGraph** (`π(U)`), and represent attachment-plane payloads as **typed atoms**: `AtomPayload { type_id: TypeId, bytes: Bytes }` stored on `NodeRecord`/`EdgeRecord`. Update canonical hashing and tick patch encoding so payload `type_id` participates in `state_root`/`patch_digest`, and codify project laws: “skeleton rewrites never decode attachments” + “no hidden edges in payload bytes.” | Echo must be a faithful reference implementation of WARP’s two-plane semantics: the skeleton plane is rewrite-visible and provenance/slicing-relevant, while attachments are separate. Untyped payload bytes create safety hazards and allow “same bytes, different meaning” collisions at the boundary. Keeping matching/indexing skeleton-only preserves performance and prevents provenance bugs caused by hidden structure. | Terminology is clarified (SkeletonGraph vs. WarpState); payload meaning is explicit and hash-committed; decode failure is deterministic and conservative (“rule does not apply” by default); Stage B1 descended attachments are unblocked via explicit indirection rather than nested structs or payload-smuggled edges. |
 | 2025-12-29 | Paper III boundary semantics (delta patches + slicing) | Implement `WarpTickPatchV1` as a **delta patch** (canonical `ops` + conservative `in_slots`/`out_slots` over unversioned slots); define value-versioning *by interpretation* along a worldline (`ValueVersionId := (slot_id, tick_index)`), not embedded in the patch; upgrade commit hashing to **v2** so `commit_id` commits only to `(parents, state_root, patch_digest, policy_id, version)` and treats plan/decision/rewrites digests + receipts as diagnostics. | Paper III’s slice/holography results require a replayable boundary artifact; recipe patches bind replay to executor semantics and do not archive cleanly across languages or evolution. Unversioned slots keep patch hashes position-independent; SSA-style value versioning is recovered from patch position in `P`. Committing only to the patch delta prevents consensus from being coupled to scheduler/planner quirks. | Commit ids become “this transformation” (delta) rather than “this scheduling narrative”; slicing becomes trivial once `producer(slot@i)` is defined by patch position; planner/receipt metadata remains available for debugging without becoming consensus-critical. |
 | 2025-12-29 | Paper II tick receipts: blocking causality | Extend `TickReceipt` to record a blocking-causality witness for `Rejected(FootprintConflict)` entries (indices of the applied candidates that blocked it, in canonical plan order); keep `decision_digest` encoding stable (digest commits only to accept/reject outcomes, not blocker metadata). | Paper II’s receipts are meant to explain *why* candidates were rejected and support deterministic debugging/provenance. Blocker indices provide a minimal poset edge list without destabilizing commit hashes as explanations evolve. | Tools/tests can surface “blocked by …” explanations; receipts now carry poset edges for footprint conflicts; commit ids remain stable for identical accept/reject outcomes. |
 | 2025-12-29 | warp-core follow-ups (#151/#152) | Remove “TODO-vibes” by making `policy_id` an explicit `Engine` parameter (default `POLICY_ID_NO_POLICY_V0`) and add a reverse edge index (`EdgeId -> from`) so tick patch replay no longer scans all edge buckets for `UpsertEdge`/migration. | `policy_id` is part of the deterministic boundary and must be configurable, not a hardcoded magic number; patch replay must scale beyond toy graphs. Maintaining the reverse index keeps replay `O(bucket_edges)` and avoids history-sensitive state roots caused by empty edge buckets. | Callers can set policy semantics explicitly; tick patch replay is no longer `O(total_edges)` per migrated edge; state hashing remains a function of graph content, not mutation history. |
@@ -312,3 +316,53 @@ The following entries use a heading + bullets format for richer context.
   - Maintain a store invariant that `edges_from` contains no empty buckets (prune buckets when the last edge is removed) so `state_root` remains a function of graph content, not mutation history.
 - Rationale: Paper III boundary artifacts (delta patches) must stay replayable and scalable; committing to `policy_id` only makes sense if callers can set it explicitly; and replay must not devolve into quadratic behavior for large graphs.
 - Consequence: Patch replay performance scales with local bucket size; policy semantics are explicit at the boundary; and identical graph states hash identically regardless of whether they were reached via add/remove edge sequences.
+
+## 2025-12-29 — warp-core Stage B0: two-plane attachments as typed atoms
+
+- Context: Echo needed to align with the WARP papers’ two-plane semantics (SkeletonGraph + attachment plane) without slowing rewrite matching/indexing.
+- Decisions:
+  - Define attachment payloads as typed, opaque atoms: `AtomPayload { type_id: TypeId, bytes: Bytes }`.
+  - Keep node/edge records skeleton-only (`NodeRecord`, `EdgeRecord`); store attachment values separately in an attachment plane.
+  - Introduce a strict codec boundary (`Codec<T>`, `DecodeError`, `CodecRegistry`) for rules/views/tooling; core matching/indexing does not decode.
+  - Deterministic decode failure semantics (v0): type mismatch or strict decode failure ⇒ “rule does not apply”.
+  - Commit `type_id` into canonical encodings/digests (state_root and patch_digest) to prevent “same bytes, different meaning” collisions.
+  - Lock the law: no hidden edges in payload bytes (dependencies must be skeleton-visible).
+- Rationale: preserve determinism and performance while making attachment semantics explicit and safe.
+- Consequence: payload typing becomes a first-class boundary artifact; the rewrite hot path remains skeleton-only; future recursion can be layered without reinterpreting bytes as structure.
+  - Docs: `docs/spec/SPEC-0001-attachment-plane-v0-atoms.md` (spec) and `docs/warp-two-plane-law.md` (project law).
+
+## 2025-12-30 — warp-core Stage B1: WarpInstances + descended attachments (flattened indirection)
+
+- Context: Depth-0 atoms alone are “WARP graphs of depth 0”; to model “WARPs all the way down” we needed explicit recursion without recursive hot-path traversal.
+- Decisions:
+  - Introduce instance-scoped identity (`WarpId`, `NodeKey`, `EdgeKey`) and multi-instance state (`WarpState`, `WarpInstance`).
+  - Extend the attachment plane with explicit indirection: `AttachmentValue::Descend(WarpId)` (no encoding of structure inside bytes).
+  - Make attachment slots first-class resources for causality:
+    - `AttachmentKey` is a stable slot identity.
+    - Footprints/scheduler treat attachment reads/writes as conflict resources.
+    - Execution inside a descended instance must READ the descent-chain attachment keys.
+  - Update the deterministic boundary:
+    - `state_root` hashes reachable instance content across `Descend` links (skeleton + attachments + instance headers).
+    - tick patch ops include instance metadata and attachment edits; patch digest encoding bumped to v2.
+  - Add a minimal slice helper test validating that descendant work pulls in the portal producer (Paper III slice closure).
+- Rationale: make recursion explicit, hashable, sliceable, and scheduler-visible while keeping matching/indexing skeleton-only within an instance.
+- Consequence: Echo can represent recursive WARP state via flattened indirection; descended-instance rewriting stays fast and deterministic; slicing remains trivial over `in_slots`/`out_slots` without decoding atoms.
+  - Docs: `docs/spec/SPEC-0002-descended-attachments-v1.md` (spec), `docs/spec-merkle-commit.md` (state_root reachability), and `docs/spec-warp-tick-patch.md` (patch digest v2 / portal ops).
+
+## 2025-12-30 — warp-core Stage B1.1: Atomic portals (OpenPortal) + merge/DAG slicing semantics
+
+- Context: Stage B1 made descended attachments explicit, but portal authoring could still be expressed as multiple independent edits across ticks (create instance vs set `Descend`), which risks slices that include the portal write but omit the instance creation history.
+- Decisions:
+  - Add a single canonical tick patch operation for portal authoring: `WarpOp::OpenPortal { key, child_warp, child_root, init }`.
+    - `OpenPortal` atomically establishes instance metadata and sets the `Descend(child_warp)` attachment slot.
+    - Patch replay validates “no dangling portal” and “no orphan instance” invariants.
+  - Bump `TickReceipt` digest format version to `2u16` after switching scope encoding to instance-scoped `NodeKey` so receipts remain self-describing across format evolution.
+  - Update the patch diff constructor (`diff_state`) to emit `OpenPortal` for newly created descended instances when the `parent` slot is set to `Descend(child_warp)`, avoiding separate instance + attachment ops.
+  - Specify multi-parent merge semantics explicitly:
+    - merge commits must include an explicit merge patch that resolves all conflicting slot writes (including attachment slots); no implicit “parent order wins”.
+    - DAG slicing generalizes worldline slicing by treating merge patches as first-class resolution events.
+  - Add a canonical terminology doc to pin “instances/portals vs wormholes” and prevent future term collisions.
+- Rationale: Keep the Paper III replay boundary honest: the patch must be the boundary artifact, slices must not be able to omit required portal/instance creation history, and merges must be deterministically authored.
+- Consequence: Portal authoring becomes clean and inevitable instead of a long-term swamp; tooling can build an honest instance-zoom view over explicit portals; merge/DAG slicing semantics are specified (implementation work remains).
+  - Docs: `docs/spec/SPEC-0002-descended-attachments-v1.md` (merge commits + DAG slicing), `docs/architecture/TERMS_WARP_STATE_INSTANCES_PORTALS_WORMHOLES.md` (terminology lock), and `docs/branch-merge-playbook.md` (conflict resolution playbook).
+  - Tests: `crates/warp-core/src/tick_patch.rs` (patch replay rejects dangling portals and orphan instances).
diff --git a/docs/docs-index.md b/docs/docs-index.md
index f2b53cc6..d91c47b3 100644
--- a/docs/docs-index.md
+++ b/docs/docs-index.md
@@ -19,6 +19,12 @@
 | `spec-concurrency-and-authoring.md` | Parallel core & single-threaded scripting model |
 | `spec-networking.md` | Deterministic event replication modes |
 | `aion-papers-bridge.md` | Map AIΩN Foundations (WARP papers) onto Echo’s backlog and document deviations |
+| `warp-two-plane-law.md` | Project law: define SkeletonGraph vs attachment plane, π(U), depth-0 atoms, and “no hidden edges” |
+| `adr/ADR-0001-warp-two-plane-skeleton-and-attachments.md` | ADR: formalize two-plane representation (SkeletonGraph + Attachment Plane) and the core invariants |
+| `adr/ADR-0002-warp-instances-descended-attachments.md` | ADR: WarpInstances and descended attachments via flattened indirection (no hidden edges, no recursive hot path) |
+| `spec/SPEC-0001-attachment-plane-v0-atoms.md` | Spec: attachment plane v0 (typed atoms), codec boundary, and deterministic decode failure semantics |
+| `spec/SPEC-0002-descended-attachments-v1.md` | Spec: descended attachments v1 (WarpInstances, SlotId::Attachment, descent-chain footprint law, worldline slicing) |
+| `architecture/TERMS_WARP_STATE_INSTANCES_PORTALS_WORMHOLES.md` | Canonical terminology: WarpState vs SkeletonGraph, instances/portals, and wormholes (reserved for history compression) |
 | `phase1-plan.md` | Phase 1 implementation roadmap & demo targets |
 | `spec-warp-core.md` | WARP core format and runtime |
 | `spec-warp-tick-patch.md` | Tick patch boundary artifact (delta ops, in/out slots, patch_digest) |
diff --git a/docs/execution-plan.md b/docs/execution-plan.md
index 540314f4..c8725697 100644
--- a/docs/execution-plan.md
+++ b/docs/execution-plan.md
@@ -35,6 +35,150 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s
 
 ## Today’s Intent
 
+> 2025-12-30 — PR #159: Address CodeRabbit actionables (IN PROGRESS)
+
+- Goal: close the remaining review items on PR #159 (docs + demo hygiene).
+- Scope:
+  - Add Evidence blocks (commit SHAs) for completed 2025-12-30 entries below.
+  - Clarify “no hidden edges” enforcement references in `docs/guide/warp-primer.md`.
+  - Clarify Paper I notation context (`U`, `π(U)`) in `docs/spec-warp-core.md`.
+  - Minor demo hygiene: document init/update behavior in the port demo executor.
+- Exit criteria: `cargo test --workspace` + `cargo clippy --workspace --all-targets -- -D warnings -D missing_docs` green; CodeRabbit re-review clean.
+
+> 2025-12-30 — Add WARP primer + wire into “Start here” (COMPLETED)
+
+- Goal: make WARP approachable to newcomers and pin canonical “start here” docs order.
+- Scope:
+  - Add `docs/guide/warp-primer.md`.
+  - Link it as step 1 from README + `docs/spec-warp-core.md`.
+  - Keep formatting markdownlint-friendly (esp. MD022 heading spacing).
+- Exit criteria: docs read cleanly; CodeRabbit markdown nits avoided.
+- Evidence:
+  - Commit: `1b40f66` (docs primer + links)
+  - Docs: `docs/guide/warp-primer.md`, `docs/spec-warp-core.md`
+  - README: `README.md` (Start here list)
+
+> 2025-12-30 — PR #159: Address CodeRabbit majors (warp-core tour examples) (COMPLETED)
+
+- Goal: close remaining “Major” review gaps by making Stage B1 behavior concrete and easy to adopt.
+- Scope:
+  - Expand `docs/spec-warp-core.md` with a worked Stage B1 example showing:
+    - portal authoring (`OpenPortal`) + `Descend(WarpId)` semantics,
+    - `Engine::apply_in_warp` usage with a `descent_stack`,
+    - how descent-chain reads become `Footprint.a_read` and therefore `in_slots`,
+    - Paper III worldline slicing that includes the portal chain.
+  - Add a minimal `Engine` constructor for initializing from an existing `WarpState` (needed to make examples concrete without exposing internals).
+  - Close remaining review gaps on portal invariants by enforcing and testing:
+    - no dangling `Descend(WarpId)` portals without a corresponding `WarpInstance`,
+    - no orphan instances (`WarpInstance.parent` must be realized by an attachment slot).
+- Exit criteria: `cargo fmt --all`, `cargo test --workspace`, and `cargo clippy --workspace --all-targets -- -D warnings -D missing_docs` green.
+- Evidence:
+  - Commits: `875690c`, `7a02123`, `846723d`, `27e490a`
+  - Docs: `docs/spec-warp-core.md` (Stage B1 quickstart + worked examples)
+  - Implementation: `crates/warp-core/src/engine_impl.rs`, `crates/warp-core/src/tick_patch.rs`
+  - Tests: portal invariant tests in `crates/warp-core/src/tick_patch.rs`
+
+> 2025-12-30 — Touch-ups (receipts/docs + wasm/ffi ergonomics + graph delete perf) (COMPLETED)
+
+- Goal: address follow-up review nits and a concrete graph-store performance trap without changing deterministic semantics.
+- Scope:
+  - Clarify `TickReceiptEntry.scope` semantics (it is a `NodeKey`, not a `NodeId`) and keep receipt digest encoding stable.
+  - Make the WASM boundary less opaque: replace `ok()??` with explicit matches; log spawn failures behind `console-panic` and return a clear sentinel (`Uint8Array` length 0).
+  - Add `Engine::insert_node_with_attachment` for atomic bootstrapping of demo nodes (avoids partial init if an invariant is violated).
+  - Eliminate `delete_node_cascade`’s `O(total_edges)` inbound scan by maintaining a reverse inbound index (`edges_to`) plus `EdgeId -> to` index.
+  - Clarify `docs/spec-warp-tick-patch.md` that the section 1.2 op list is semantic and does not imply encoding tag order.
+- Exit criteria: `cargo fmt --all`, `cargo test --workspace`, and `cargo clippy --workspace --all-targets -- -D warnings -D missing_docs` green.
+- Evidence:
+  - Commits: `8282a29`, `ef89bc7`, `a070374`, `31e0836`, `889932d`
+  - Implementation: `crates/warp-core/src/receipt.rs`, `crates/warp-wasm/src/lib.rs`, `crates/warp-ffi/src/lib.rs`, `crates/warp-core/src/graph.rs`
+  - Docs: `docs/spec-warp-tick-patch.md`
+
+> 2025-12-30 — Touch-ups: encapsulation + ergonomics + micro-perf (COMPLETED)
+
+- Goal: address review touch-ups to keep public APIs stable, avoid internal field coupling, and remove sharp edges in docs/tests/benchmarks.
+- Scope:
+  - Clarify attachment-plane terminology in `docs/warp-two-plane-law.md`.
+  - Improve worldline slice work-queue allocation (`Vec::with_capacity`) for large patch sets.
+  - Add small encapsulation helpers (`GraphStore::has_edge`, `WarpId::as_bytes`, `NodeId::as_bytes`) and update callers to avoid `.0` tuple-field indexing.
+  - Tighten `Engine` accessors: distinguish “unknown warp store” vs “missing node/attachment” via `Result<Option<...>, EngineError>`.
+  - Make root-store mutation methods (`insert_node`, `set_node_attachment`) return `Result` and surface invariant violation (`UnknownWarp`) rather than silently dropping writes.
+  - Improve diagnostics in motion benchmarks’ panic paths.
+- Exit criteria: `cargo test --workspace` + `cargo clippy --workspace --all-targets -- -D warnings -D missing_docs` green; docs guard updated.
+- Evidence:
+  - Commits: `31e0836`, `889932d`
+  - Implementation: `crates/warp-core/src/engine_impl.rs`, `crates/warp-core/src/graph.rs`, `crates/warp-core/src/tick_patch.rs`, `crates/warp-core/src/ident.rs`
+  - Benches: `crates/warp-benches/benches/motion_throughput.rs`
+
+> 2025-12-30 — Stage B1.1: Atomic portals + merge/DAG slicing semantics (COMPLETED)
+
+- Goal: make descended attachments “slice-safe” by introducing an atomic portal authoring op (`OpenPortal`), then lock down merge semantics and terminology to prevent long-term drift.
+- Scope:
+  - Add `WarpOp::OpenPortal { key, child_warp, child_root, init }` as the canonical portal authoring operation.
+  - Update patch replay validation to forbid dangling portals / orphan instances.
+  - Update `diff_state` to emit `OpenPortal` for new descended instances, preventing portal/instance creation from being separable across ticks.
+  - Document merge semantics (explicit conflict resolution) and DAG slicing algorithm.
+  - Add a terminology law doc to pin “instance zoom vs wormholes”.
+- Exit criteria: `cargo test --workspace` + `cargo clippy --workspace --all-targets -- -D warnings -D missing_docs` green; docs guard updated.
+- Evidence:
+  - Implementation:
+    - `crates/warp-core/src/tick_patch.rs` (`WarpOp::OpenPortal`, replay validation, diff_state portal canonicalization)
+  - Docs:
+    - `docs/adr/ADR-0002-warp-instances-descended-attachments.md` (atomic portals + merge law)
+    - `docs/spec/SPEC-0002-descended-attachments-v1.md` (OpenPortal + merge/DAG slicing + zoom tooling note)
+    - `docs/spec-warp-tick-patch.md` (OpenPortal op encoding)
+    - `docs/architecture/TERMS_WARP_STATE_INSTANCES_PORTALS_WORMHOLES.md` (terminology law)
+
+> 2025-12-30 — Stage B1: WarpInstances + descended attachments (COMPLETED)
+
+- Goal: implement “WARPs all the way down” without recursive traversal in the rewrite hot path by modeling descent as flattened indirection (WarpInstances).
+- Scope:
+  - Introduce WarpInstances:
+    - `WarpId`
+    - `WarpInstance { warp_id, root_node, parent: Option<AttachmentKey> }`
+  - Make ids instance-scoped:
+    - `NodeKey { warp_id, local_id }`
+    - `EdgeKey { warp_id, local_id }`
+  - Upgrade attachments from depth-0 atoms to `AttachmentValue = Atom(AtomPayload) | Descend(WarpId)` and make attachment slots first-class:
+    - `AttachmentKey { owner: NodeKey|EdgeKey, plane: Alpha|Beta }`
+    - `SlotId::Attachment(AttachmentKey)` (tick patches + slicing)
+  - Enforce the Paper I/II “no hidden edges” and descent-chain correctness laws:
+    - Matching/indexing stays skeleton-only within an instance.
+    - Any match/exec within a descended instance must READ every `AttachmentKey` in the descent stack (so changing a descent pointer deterministically invalidates the match).
+  - Slicing integration: a demanded value in instance `W` must pull in the attachment chain (root → W) producers via `SetAttachment(...Descend...)` ops, with no decoding of atoms.
+- Exit criteria: `cargo test --workspace` + `cargo clippy --workspace --all-targets -- -D warnings -D missing_docs` green; new ADR + SPEC for Stage B1; decision log updated.
+- Evidence:
+  - Implementation:
+    - `crates/warp-core/src/warp_state.rs` (WarpState/WarpInstance)
+    - `crates/warp-core/src/attachment.rs` (AttachmentKey/Value incl. `Descend`)
+    - `crates/warp-core/src/snapshot.rs` (state_root reachability across instances via `Descend`)
+    - `crates/warp-core/src/engine_impl.rs` (apply_in_warp + descent_chain reads)
+    - `crates/warp-core/src/footprint.rs` + `crates/warp-core/src/scheduler.rs` (attachment conflicts)
+    - `crates/warp-core/src/tick_patch.rs` (SlotId::Attachment, instance ops, patch_digest v2)
+  - Docs:
+    - `docs/adr/ADR-0002-warp-instances-descended-attachments.md`
+    - `docs/spec/SPEC-0002-descended-attachments-v1.md`
+    - `docs/spec-warp-tick-patch.md` (updated to v2 encoding)
+    - `docs/spec-merkle-commit.md` (updated state_root encoding)
+    - `docs/warp-two-plane-law.md` (updated to reflect B1 reality)
+  - Tests:
+    - `crates/warp-core/src/tick_patch.rs` (portal-chain slice test)
+    - `crates/warp-core/src/scheduler.rs` (descent-chain conflict test)
+
+> 2025-12-29 — WARP two-plane semantics: typed atom attachments (COMPLETED)
+
+- Goal: align Echo’s `warp-core` implementation with Paper I/II “two-plane” semantics without slowing the rewrite hot path.
+- Scope:
+  - Treat `GraphStore` as the **SkeletonGraph** (π(U)): the structure used for matching, rewriting, scheduling, slicing, and hashing.
+  - Model attachment-plane payloads as **typed atoms** (depth-0): `AtomPayload { type_id: TypeId, bytes: Bytes }`.
+  - Update snapshot hashing + tick patch canonical encoding so payload `type_id` participates in digests (no “same bytes, different meaning” collisions).
+  - Introduce a minimal codec boundary (`Codec<T>` + registry concept) for typed decode/encode at rule/view boundaries; core matching/indexing remains skeleton-only unless a rule explicitly decodes.
+  - Document the project laws (“no hidden edges in payload bytes”, “skeleton rewrites never decode attachments”) and record the decision in ADR + SPEC form.
+- Exit criteria: `cargo test --workspace` + `cargo clippy --workspace --all-targets -- -D warnings -D missing_docs` green; docs guard updated (`docs/decision-log.md` + new ADR/SPEC + law doc).
+- Evidence:
+  - Implementation: `crates/warp-core/src/attachment.rs`, `crates/warp-core/src/record.rs`, `crates/warp-core/src/snapshot.rs`, `crates/warp-core/src/tick_patch.rs`.
+  - Docs: `docs/warp-two-plane-law.md`, `docs/adr/ADR-0001-warp-two-plane-skeleton-and-attachments.md`, `docs/spec/SPEC-0001-attachment-plane-v0-atoms.md`.
+  - Tests: new digest/type-id assertions in `crates/warp-core/tests/atom_payload_digest_tests.rs`; workspace tests + clippy rerun green.
+
 > 2025-12-29 — Follow-up: tick patch hygiene (COMPLETED)
 
 - Goal: clean up `tick_patch` sharp edges so the patch boundary stays deterministic, well-documented, and resistant to misuse.
diff --git a/docs/guide/warp-primer.md b/docs/guide/warp-primer.md
new file mode 100644
index 00000000..d9b6f49c
--- /dev/null
+++ b/docs/guide/warp-primer.md
@@ -0,0 +1,274 @@
+<!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
+<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
+---
+title: WARP Primer
+---
+
+# WARP Primer (Echo)
+
+This is a newcomer-friendly explanation of what “WARP” means in the Echo codebase.
+You do not need to know the papers or any category theory to follow this.
+
+If you only remember one thing:
+
+> Echo treats **structure** (the graph) and **data** (attachments) as separate planes.
+> Rewriting, scheduling, hashing, and slicing happen over the **structure plane** unless a rule explicitly opts into decoding data.
+
+---
+
+## 1. The basic idea: state is a graph, changes are rewrites
+
+Echo’s core state is a directed graph:
+
+- **Nodes** represent “things”.
+- **Edges** represent relationships.
+
+A “tick” (one step of time) runs a set of deterministic rewrite rules:
+
+- Each rule can match a local pattern around a chosen **scope** node.
+- If it matches, it emits edits to the graph.
+- Multiple candidate rewrites may be proposed; the engine deterministically chooses a conflict-free subset to apply.
+
+This gives you:
+
+- deterministic simulation
+- replayable history
+- auditability (“why did this happen?”)
+- slicing (“what minimal history explains this output?”)
+
+---
+
+## 2. Two planes: SkeletonGraph + Attachment Plane
+
+WARP state in Echo is “two-plane”:
+
+```text
+WarpState := (SkeletonGraph, Attachments)
+π(WarpState) = SkeletonGraph
+```
+
+### 2.1 Skeleton plane (the hot path)
+
+The **SkeletonGraph** is the explicit structure:
+
+- node identities and node records
+- edge identities and edge records
+- adjacency relationships
+
+This is the hot path:
+
+- matching/indexing
+- scheduling (conflict detection)
+- canonical hashing (`state_root`)
+- tick patch generation and replay
+- slicing
+
+In code, the skeleton plane for a single instance is stored in `warp_core::GraphStore`.
+
+### 2.2 Attachment plane (typed atoms by default)
+
+Attachments are payloads “over” skeleton elements (nodes/edges).
+
+Echo represents a depth-0 attachment as a **typed atom**:
+
+- `AtomPayload { type_id: TypeId, bytes: Bytes }`
+
+Key property:
+
+- The `type_id` is part of the deterministic boundary.
+  - same bytes with a different meaning is not allowed to collide.
+
+Attachment bytes are opaque to the engine unless a rule explicitly decodes them.
+
+### 2.3 The “no hidden edges” law
+
+Attachments must not be used to smuggle graph structure that the engine cannot see.
+
+If something matters for:
+
+- match applicability
+- scheduling conflicts / causality
+- slicing correctness
+- replay correctness
+
+…then it must be represented as explicit skeleton structure (or explicit attachment slot identity),
+not buried inside payload bytes.
+
+This is how Echo avoids building a system that “looks like WARP” but produces incorrect slices.
+
+Canonical statement: `docs/warp-two-plane-law.md`.
+
+Enforcement in `warp-core` is by construction:
+
+- The only engine-recognized “structure inside data” mechanism is `AttachmentValue::Descend(WarpId)` (explicit portals).
+  - `AtomPayload.bytes` are treated as opaque data; the engine never interprets them as skeleton structure.
+- Matching/indexing/scheduling operate on `GraphStore` skeleton structure.
+  - Attachments are only read/decoded if a rule explicitly calls attachment APIs.
+- Typed decode failure is deterministic at the rule boundary.
+  - For example, invalid motion payload bytes result in `ApplyResult::NoMatch` (rule does not apply):
+    `crates/warp-core/tests/engine_motion_negative_tests.rs`.
+- Attachment identity includes `type_id` at the deterministic boundary:
+  `crates/warp-core/tests/atom_payload_digest_tests.rs`.
+
+---
+
+## 3. “Graphs all the way down” without recursive Rust structs (Stage B1)
+
+If you literally store “a graph inside a node payload”, you get two problems:
+
+- matching/scheduling can’t see structure inside bytes (violates “no hidden edges”)
+- recursive traversal/decoding in the hot loop destroys performance and determinism ergonomics
+
+Echo’s solution is **flattened indirection**:
+
+- Attachments are still atoms by default.
+- An attachment may also be `Descend(child_warp_id)`.
+
+That `Descend(...)` does not contain a graph.
+It points to another graph **instance** that lives alongside the current one.
+
+Terminology note: “portals/instances” are *state recursion*. “Wormholes” are a different concept
+(tick-range compression in the history/provenance plane). See
+`docs/architecture/TERMS_WARP_STATE_INSTANCES_PORTALS_WORMHOLES.md`.
+
+### 3.1 WarpInstances (namespaces / layers)
+
+Stage B1 introduces multiple graph instances:
+
+- Each instance has a `WarpId`.
+- Each instance has its own `GraphStore`.
+
+This gives you “recursive state” without recursive storage:
+
+- The overall state is `warp_core::WarpState` (many instances).
+- Each instance is a normal `GraphStore` (fast skeleton operations).
+
+### 3.2 Instance-scoped identity: `NodeId` vs `NodeKey`
+
+Inside one instance, nodes are identified by `NodeId`.
+Across instances, you must use `NodeKey`:
+
+- `NodeKey { warp_id, local_id: NodeId }`
+
+Same idea for edges (`EdgeId` vs `EdgeKey`).
+
+This prevents accidental collisions where two instances both have a “node A”.
+
+---
+
+## 4. Portals and descent chains (why recursion stays slice-safe)
+
+A **portal** is simply an attachment slot whose value is `Descend(child_warp_id)`.
+
+But to make replay and slicing correct, portals have strict invariants:
+
+- No **dangling portal**: you may not observe `Descend(child)` without the corresponding child instance existing.
+- No **orphan instance**: a child instance that declares a `parent` portal must be realized by that portal slot pointing back to it.
+
+### 4.1 Atomic portal authoring (`OpenPortal`)
+
+To enforce those invariants at the boundary artifact level, portal authoring is atomic:
+
+- `WarpOp::OpenPortal { key, child_warp, child_root, init }`
+
+This single op:
+
+1) ensures the child instance exists and is consistent
+2) ensures the child root node exists (create or validate)
+3) sets the portal slot to `Descend(child_warp)`
+
+This prevents history where “the portal was set” but “the child universe never existed” (or vice versa).
+
+### 4.2 Descent-chain footprinting
+
+The subtle correctness law is:
+
+- Any rewrite executed *inside* a descended instance must record READs of the portal chain that makes that instance reachable.
+
+Echo enforces this in `Engine::apply_in_warp(tx, warp_id, rule, scope, descent_stack)`:
+
+- callers pass `descent_stack: &[AttachmentKey]` (root → … → current)
+- the engine injects those keys into the rewrite footprint as attachment reads
+
+Why:
+
+- changing any portal pointer changes reachability / meaning
+- therefore it must invalidate matches deterministically
+
+Downstream, those reads become `SlotId::Attachment(...)` entries in the tick patch `in_slots`,
+which makes slicing automatically include the portal chain.
+
+---
+
+## 5. What happens in a tick (runtime pipeline)
+
+At a high level, `warp-core` runs:
+
+1) **Begin** a transaction
+2) **Apply** rules to enqueue candidate rewrites
+3) **Commit**:
+   - deterministically schedule a conflict-free subset
+   - execute them
+   - emit deterministic boundary artifacts
+
+Conflict detection is based on **Footprints** (read/write sets over nodes/edges/attachments/ports).
+
+---
+
+## 6. The boundary artifacts (history is first-class)
+
+Each committed tick can emit:
+
+- **Snapshot**
+  - commits to `state_root` (canonical hash of reachable state)
+  - commits to `patch_digest` (the replayable delta boundary)
+- **TickReceipt**
+  - records which candidates were applied vs rejected (and why)
+  - can include within-tick blocking causality
+- **WarpTickPatchV1**
+  - the replayable delta (“these canonical ops happened”)
+  - conservative `in_slots` / `out_slots` for slicing
+
+The key design stance is:
+
+> The patch is the boundary artifact for “what happened”.
+> Receipts and planner digests are deterministic diagnostics, not consensus reality.
+
+---
+
+## 7. Slicing (why the portal-chain law matters)
+
+Slicing answers:
+
+> “Given a final slot/value, what subset of tick patches must I keep to replay just the dependency cone for it?”
+
+If every patch has:
+
+- `out_slots` = what it produced
+- `in_slots` = what it depended on
+
+…then slicing is just a backwards walk:
+
+- find the last tick that produced the target slot
+- include that tick
+- enqueue its `in_slots`
+- repeat
+
+Stage B1 makes this work for descended instances because:
+
+- work inside a child instance reads the portal chain slots
+- therefore the slice includes the portal-opening tick(s)
+
+---
+
+## 8. Where to go next (Start here path)
+
+Recommended reading order:
+
+1) `docs/guide/warp-primer.md` — you are here (what WARP means in Echo).
+2) `docs/spec-warp-core.md` — `warp-core` crate tour and API map.
+3) `docs/warp-two-plane-law.md` — the hard laws (structure vs data, no hidden edges).
+4) `docs/spec-merkle-commit.md` — state hashing + commit header semantics.
+5) `docs/spec-warp-tick-patch.md` — tick patch boundary artifact (delta ops, hashing).
+6) `docs/spec/SPEC-0002-descended-attachments-v1.md` — WarpInstances, portals, merge/DAG slicing semantics.
+   (Terminology note: wormholes are *history compression*, not state descent; see the terms doc above.)
diff --git a/docs/spec-merkle-commit.md b/docs/spec-merkle-commit.md
index 31fb7d7b..abecab5d 100644
--- a/docs/spec-merkle-commit.md
+++ b/docs/spec-merkle-commit.md
@@ -4,28 +4,60 @@
 
 This document precisely defines the two hashes produced by the engine when recording state and provenance.
 
-- state_root: BLAKE3 of the canonical encoding of the reachable graph under the current root.
+- state_root: BLAKE3 of the canonical encoding of the reachable WARP state under the current root (including attachments and descended instances).
 - commit hash (commit_id): BLAKE3 of a header that includes state_root, parent commit(s), the tick patch digest (boundary delta), plus a policy id.
 
-## 1. Canonical Graph Encoding (state_root)
+## 1. Canonical State Encoding (state_root)
 
-Inputs: GraphStore, root NodeId.
+Inputs: `WarpState`, root `NodeKey`.
 
 Deterministic traversal:
-- Reachability: BFS from root following outbound edges; only reachable nodes and edges are included.
-- Node order: ascending NodeId (lexicographic over 32-byte ids).
-- Edge order: for each source node, include only edges whose destination is reachable; sort by ascending EdgeId.
+- Reachability: deterministic BFS from the root `NodeKey` across instances:
+  - within an instance: follow outbound skeleton edges
+  - across instances: follow any `AttachmentValue::Descend(child_warp)` encountered on:
+    - reachable nodes (α plane), and
+    - reachable edges (β plane)
+  - descending enqueues the `WarpInstance.root_node` of `child_warp`
+- Warp order: reachable `WarpId` values in ascending order (lexicographic over 32-byte ids).
+- Node order: ascending `NodeId` within each warp (lexicographic over 32-byte ids), filtered to reachable nodes in that warp.
+- Edge order: per reachable source bucket, sort edges by ascending `EdgeId`, and include only edges whose destination node is reachable.
 
 Encoding (little-endian where applicable):
-- Root id: 32 bytes.
-- For each node (in order):
-  - node_id (32), node.ty (32), payload_len (u64 LE), payload bytes.
-- For each source (in order):
-  - from_id (32), edge_count (u64 LE) of included edges.
-    - edge_count is a 64-bit little-endian integer and may be 0 when a source
-      node has no outbound edges included by reachability/ordering rules.
-  - For each edge (in order):
-    - edge.id (32), edge.ty (32), edge.to (32), payload_len (u64 LE), payload bytes.
+- Root key:
+  - `root_warp_id: 32`
+  - `root_node_id: 32`
+- For each reachable warp (in order):
+  - Instance header:
+    - `warp_id: 32`
+    - `root_node_id: 32` (the instance-local root)
+    - `parent_key_opt`:
+      - `present: u8` (`0` = None, `1` = Some)
+      - when present: `attachment_key` (see below)
+  - For each reachable node in that warp (in order):
+    - `node_id: 32`
+    - `node_type_id: 32`
+    - `attachment_value_opt` for the node’s α-plane slot (see below)
+  - For each reachable source bucket in that warp (in order):
+    - `from_node_id: 32`
+    - `edge_count: u64 LE` (number of included edges after reachability filter)
+    - For each included edge (in order):
+      - `edge_id: 32`
+      - `edge_type_id: 32`
+      - `to_node_id: 32`
+      - `attachment_value_opt` for the edge’s β-plane slot (see below)
+
+Where:
+- `attachment_key` encodes the identity of an attachment slot:
+  - `owner_tag: u8` (`1` = Node, `2` = Edge)
+  - `plane_tag: u8` (`1` = Alpha, `2` = Beta)
+  - `owner_warp_id: 32`
+  - `owner_local_id: 32` (raw `NodeId` or `EdgeId` bytes)
+- `attachment_value_opt` encodes the value stored in an attachment slot:
+  - `present: u8` (`0` = None, `1` = Some)
+  - when present:
+    - `value_tag: u8` (`1` = Atom, `2` = Descend)
+    - if Atom: `payload_type_id: 32`, `payload_len: u64 LE`, then raw bytes
+    - if Descend: `child_warp_id: 32`
 
 Hash: blake3(encoding) → 32-byte digest.
 
@@ -46,7 +78,7 @@ Hash: blake3(encode(header)) → commit_id.
 `patch_digest` commits to the tick patch boundary artifact: a replayable delta
 patch with canonical ops and conservative in/out slot sets.
 
-Canonical encoding for the tick patch (v1) is defined in `docs/spec-warp-tick-patch.md`.
+Canonical encoding for the tick patch (v2) is defined in `docs/spec-warp-tick-patch.md`.
 
 ---
 
diff --git a/docs/spec-warp-core.md b/docs/spec-warp-core.md
index 58b5fb2a..332ef7b6 100644
--- a/docs/spec-warp-core.md
+++ b/docs/spec-warp-core.md
@@ -1,140 +1,542 @@
 <!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
 <!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
-# WARP Graph Core Specification v2
-
-## 1. Purpose
-WARP graph (Worldline Algebra for Recursive Provenance; “WARP”) is a typed, deterministic graph-rewriting engine implemented in Rust. It provides atomic, in-place edits of WARP graphs with deterministic local scheduling and snapshot isolation. WARP is the substrate for the Echo engine: runtime, assets, networking, and tools all operate on the same living graph.
-
-## 2. Core Principles
-| Principle | Description |
-| --- | --- |
-| Everything is a Graph | Nodes, edges, and rewrite rules are graphs. |
-| Recursive | Graphs contain subgraphs without limit. |
-| Typed | Every record carries a type hash and schema metadata. |
-| DPOi Graph Rewriting | Deterministic parallel-order Double Pushout rewrites. |
-| Atomic In-Place Editing | Transactions mutate the live graph with snapshot isolation. |
-| Confluence | Independent rewrite sequences converge to identical canonical graphs. |
-| Snapshots, not Logs | Snapshots emitted from live graph; append-only history optional for archival. |
-| Deterministic Scheduling | Rule application order derived from rule id + scope hash. |
-| QCA-Ready | Rules can encode reversible/superposed transformations for quantum simulations. |
-
-## 3. Runtime Model
-### 3.1 Core Structures (Rust)
-```rust
-struct WarpEngine {
-    graph: GraphStore,
-    scheduler: DeterministicScheduler,
-    rules: Vec<RewriteRule>,
-}
+# `warp-core` — WARP Core Runtime & API Tour
 
-struct GraphStore {
-    nodes: HashMap<Hash, NodeRecord>,
-    edges: HashMap<Hash, EdgeRecord>,
-}
+This document is a **tour of the `warp-core` crate**: the core data model,
+deterministic boundary artifacts, and the runtime APIs that higher layers (`warp-ffi`,
+`warp-wasm`, tools, and eventually the full Echo runtime) build on.
 
-struct RewriteRule {
-    id: Hash,
-    left: PatternGraph,
-    right: PatternGraph,
-    constraint: Option<GuardExpr>,
-}
-```
+If you only remember one thing:
 
-### 3.2 Atomic Transactions
-```rust
-engine.begin_tx();
-engine.apply(rule_set);
-engine.commit();   // atomically swap pointers; emit snapshot hash
+> `warp-core` is where “WARP is real” — deterministic state, deterministic ticks,
+> deterministic hashes, and replayable deltas.
+
+Related docs (recommended, in order):
+
+1. `docs/guide/warp-primer.md` — newcomer-friendly WARP overview (start here).
+2. `docs/warp-two-plane-law.md` — project law for SkeletonGraph vs attachment plane.
+3. `docs/spec-merkle-commit.md` — state_root vs commit_id and what is committed.
+4. `docs/spec-warp-tick-patch.md` — tick patch boundary artifact (Paper III).
+5. `docs/spec/SPEC-0002-descended-attachments-v1.md` — WarpInstances / descended attachments (Stage B1).
+
+---
+
+## 1. Mental model (what the crate implements)
+
+`warp-core` implements a **two-plane** WARP state object:
+
+- **Skeleton plane (structure):** nodes + edges + boundary ports (matching/scheduling operates here).
+- **Attachment plane (payloads):** typed atoms (depth-0) and explicit descent indirection (Stage B1).
+
+In Paper I notation, this is “WarpState `U`” and its projection `π(U)`:
+
+```text
+U := (SkeletonGraph, Attachments)  // full state (both planes)
+π(U) = SkeletonGraph                // skeleton projection (hot path)
 ```
-All reads during a transaction see a consistent snapshot (snapshot isolation).
 
-### 3.3 Deterministic Local Scheduler
-- Each rewrite computes a scope hash from affected nodes/edges.
-- Scheduler orders scopes lexicographically; conflicts resolved by rule priority + hash.
-- Concurrent rewrites on disjoint scopes execute in parallel, ensuring confluence.
+The hot path is intentionally defined over `π(U)`:
+
+- matching/indexing never decodes attachments
+- attachments are decoded only when a rule explicitly chooses to do so
+
+The crate also defines the deterministic “history boundary artifacts” used for audit/replay/slicing:
+
+- **Snapshot** (state root + commit id)
+- **TickReceipt** (Paper II: accept/reject outcomes + optional blocking witness)
+- **WarpTickPatchV1** (Paper III: replayable delta ops + conservative in/out slots)
+
+---
+
+## 2. Public API surface (what `warp-core` exports)
+
+The stable public surface is intentionally exposed via re-exports from
+`crates/warp-core/src/lib.rs`. At a high level:
+
+- **Identifiers:** `Hash`, `NodeId`, `EdgeId`, `TypeId`, `WarpId`, plus instance-scoped keys `NodeKey`, `EdgeKey`.
+- **State & storage:** `GraphStore`, `WarpState`, `WarpInstance`.
+- **Attachments:** `AtomPayload`, `AttachmentKey`, `AttachmentValue`, and the codec boundary (`Codec`, `CodecRegistry`, `DecodeError`).
+- **Rules:** `RewriteRule`, `PatternGraph`, `ConflictPolicy`.
+- **Scheduling & MWMR:** `Footprint`, `PortKey`.
+- **Runtime:** `Engine`, `EngineError`, `ApplyResult`.
+- **Boundary artifacts:** `Snapshot`, `TickReceipt`, `WarpTickPatchV1`, `WarpOp`, `SlotId`.
+- **Utilities:** demo builders (`build_motion_demo_engine`), payload helpers (`encode_motion_atom_payload`, etc.).
+
+This doc describes those pieces and how they fit.
+
+---
+
+## 3. Identifiers (stable bytes, stable meaning)
+
+All core ids are **32-byte values** (`type Hash = [u8; 32]`) and are treated as
+raw bytes at the deterministic boundary.
+
+Key types (from `ident.rs`):
+
+- `NodeId(Hash)` — local node identity inside one warp instance.
+- `EdgeId(Hash)` — local edge identity inside one warp instance.
+- `WarpId(Hash)` — namespacing identity for Stage B1 WarpInstances (“layers”).
+- `TypeId(Hash)` — meaning tag for either skeleton typing (node/edge record types) or attachment atoms.
+
+Stage B1 adds *instance-scoped keys*:
+
+- `NodeKey { warp_id: WarpId, local_id: NodeId }`
+- `EdgeKey { warp_id: WarpId, local_id: EdgeId }`
+
+Interpretation rule:
+
+- A `NodeId` is not globally unique across all instances; **`NodeKey` is**.
+
+Construction helpers:
+
+- `make_node_id(label)`, `make_edge_id(label)`, `make_type_id(label)`, `make_warp_id(label)`
+  produce deterministic ids by BLAKE3 hashing domain-separated labels.
+
+---
+
+## 4. Storage model: `GraphStore` (one instance) + `WarpState` (many instances)
+
+### 4.1 `GraphStore`: per-instance storage
+
+`GraphStore` is the in-memory store for one warp instance (one `warp_id`):
+
+- Skeleton plane:
+  - `nodes: BTreeMap<NodeId, NodeRecord>`
+  - `edges_from: BTreeMap<NodeId, Vec<EdgeRecord>>` (adjacency buckets)
+  - `edges_to: BTreeMap<NodeId, Vec<EdgeId>>` (reverse adjacency, used for fast deletes)
+- Attachment plane (stored separately, but co-located in the struct):
+  - `node_attachments: BTreeMap<NodeId, AttachmentValue>` (node-attachment plane)
+  - `edge_attachments: BTreeMap<EdgeId, AttachmentValue>` (edge-attachment plane)
+- Reverse indexes:
+  - `edge_index: BTreeMap<EdgeId, NodeId>` (EdgeId → from)
+  - `edge_to_index: BTreeMap<EdgeId, NodeId>` (EdgeId → to)
+
+Design intent:
+
+- `BTreeMap` is used to guarantee deterministic key iteration.
+- Edge buckets preserve insertion order, but deterministic processes (hashing, patch diff)
+  explicitly sort by `EdgeId` when order matters.
+
+### 4.2 `WarpState`: multi-instance WARP state (Stage B1)
+
+`WarpState` is the Stage B1 two-level container:
+
+- `stores: BTreeMap<WarpId, GraphStore>`
+- `instances: BTreeMap<WarpId, WarpInstance>`
+
+`WarpInstance` is the metadata record that makes descended attachments sliceable:
+
+- `warp_id: WarpId`
+- `root_node: NodeId` (local id inside that instance’s store)
+- `parent: Option<AttachmentKey>` (the attachment slot that descends into this instance; `None` for the root instance)
+
+Important: `WarpInstance.parent` is what enables “include the portal chain” slicing
+without searching the entire attachment plane.
+
+---
+
+## 5. Attachments: typed atoms + explicit descent
+
+Attachments exist in a distinct plane and are addressed by first-class slot keys.
+
+### 5.1 Slot identity: `AttachmentKey`
+
+An attachment slot is identified by:
+
+- `AttachmentOwner` = `Node(NodeKey)` or `Edge(EdgeKey)`
+- `AttachmentPlane` = `Alpha` (node-owned) or `Beta` (edge-owned)
+
+Invariant (project law):
+
+- node-owned attachments use **Alpha**
+- edge-owned attachments use **Beta**
+
+### 5.2 Attachment values
+
+`AttachmentValue` is:
+
+- `Atom(AtomPayload)` — depth-0 typed bytes
+- `Descend(WarpId)` — Stage B1 “flattened indirection” to another instance
+
+`AtomPayload` is:
+
+- `type_id: TypeId`
+- `bytes: Bytes`
+
+Determinism rules:
+
+- canonical encodings and hashes include the payload `type_id` as well as the bytes
+- attachment bytes must not hide structural dependencies (“no hidden edges”)
+
+### 5.3 Codec boundary (safe typing)
+
+The engine does not decode attachments in matching/indexing. Typed boundaries use:
+
+- `trait Codec<T> { const TYPE_ID: TypeId; fn encode_canon(&T)->Bytes; fn decode_strict(&Bytes)->Result<T, DecodeError>; }`
+- `AtomPayload::decode_for_match` encodes the v0 decode-failure policy:
+  - type mismatch or decode error ⇒ “rule does not apply”
+
+---
+
+## 6. Rewrite model: rules + footprints + deterministic scheduling
+
+### 6.1 `RewriteRule`
+
+A registered rule is a `RewriteRule`:
+
+- `id: Hash` (rule family id; stable and deterministic)
+- `name: &'static str` (human name used by `Engine::apply`)
+- `matcher: fn(&GraphStore, &NodeId) -> bool`
+- `executor: fn(&mut GraphStore, &NodeId)`
+- `compute_footprint: fn(&GraphStore, &NodeId) -> Footprint`
+- `factor_mask: u64` (fast prefilter; must remain conservative)
+- `conflict_policy: ConflictPolicy` (`Abort`, `Retry`, `Join`)
+
+Today’s spike uses `matcher`/`executor` directly; the `PatternGraph` field is
+kept for forward compatibility with richer pattern systems.
+
+### 6.2 `Footprint`: MWMR independence contract
+
+`Footprint` is the engine’s “resources touched” summary. It is used for:
+
+- conflict detection (write/write, write/read, boundary port overlap)
+- explaining rejections (TickReceipt blocker witness)
+- conservative in/out slot derivation for tick patches
+
+Stage B1 adds attachment slots to the footprint sets so descent-chain reads can be recorded.
+
+### 6.3 Deterministic ordering: `scope_hash`
+
+`scope_hash(rule_id, scope: &NodeKey) -> Hash` is the canonical “scheduler sort key” seed.
+This is exported so tests and tooling can compute the same order as the engine.
+
+---
+
+## 7. Runtime: `Engine` and the tick pipeline
+
+### 7.1 Transactions
+
+`Engine` is a single-process deterministic engine:
+
+1. `let tx = engine.begin();`
+2. `engine.apply(tx, RULE_NAME, &scope_node_id)?;` (enqueue candidates)
+3. `let snapshot = engine.commit(tx)?;` (deterministically reserve + execute + hash)
+
+`Engine::apply_in_warp` is the Stage B1 variant that targets a specific instance (`warp_id`)
+and carries a `descent_stack: &[AttachmentKey]` for the descent-chain footprint law.
+
+### 7.2 Commit outputs: `Snapshot`, `TickReceipt`, `WarpTickPatchV1`
+
+There are two commit APIs:
+
+- `Engine::commit(tx) -> Result<Snapshot, EngineError>`
+- `Engine::commit_with_receipt(tx) -> Result<(Snapshot, TickReceipt, WarpTickPatchV1), EngineError>`
+
+The `commit_with_receipt` API is the “full boundary artifact” path: it produces the
+delta patch and the receipt alongside the snapshot id.
+
+---
+
+## 8. Deterministic boundary artifacts (hashing, replay, slicing)
+
+### 8.1 `Snapshot`: `state_root` + `commit_id`
+
+`Snapshot.hash` is the deterministic commit id (`commit_id`).
+
+Commit hash v2 commits to:
+
+- explicit `parents`
+- `state_root` (graph-only, canonical)
+- `patch_digest` (replayable delta)
+- `policy_id`
+
+Plan/decision/rewrites digests remain deterministic diagnostics but are *not* committed by v2.
+See `docs/spec-merkle-commit.md` for the canonical encoding.
+
+### 8.2 `TickReceipt`: Paper II outcomes
+
+`TickReceipt` records, in canonical plan order, whether each candidate was:
+
+- `Applied`, or
+- `Rejected(FootprintConflict)`
+
+Additionally, `TickReceipt::blocked_by(i)` returns a sorted list of prior indices
+that blocked candidate `i` (a minimal blocking-causality witness).
+
+### 8.3 `WarpTickPatchV1`: Paper III replayable delta
+
+The tick patch is the “what happened” boundary artifact:
+
+- conservative `in_slots` / `out_slots` (unversioned `SlotId`s)
+- canonical delta ops (`WarpOp`) such as `UpsertNode`, `DeleteEdge`, `SetAttachment`, `OpenPortal`, etc.
+- `digest()` commits to the canonical v2 patch encoding (see `docs/spec-warp-tick-patch.md`)
+
+Worldline slicing uses the Paper III interpretation rule:
+
+- the patch does not embed versions; versions are recovered by patch position in `P = (μ0…μn-1)`.
+
+---
+
+## 9. Stage B1 recursion: WarpInstances + portals
+
+`warp-core` supports “WARPs all the way down” without recursive traversal:
+
+- descended attachments are explicit `AttachmentValue::Descend(WarpId)`
+- the child instance is a separate namespace (`WarpInstance` + `GraphStore`)
+- portals are authored atomically in patches via `WarpOp::OpenPortal`
+
+Crucial correctness law:
+
+- any match/exec inside a descended instance must READ the attachment keys in its descent chain
+  (so changing a portal pointer deterministically invalidates matches)
+
+### 9.1 Worked example: descent-chain reads become `Footprint.a_read`
+
+The engine enforces the law in `Engine::apply_in_warp` by *injecting* the descent
+chain into the footprint before the candidate is enqueued:
 
-## 4. Binary Representation
-- NodeRecord/EdgeRecord/PayloadArena remain as in v1.
-- Additional SnapshotHeader metadata stored alongside snapshots:
 ```rust
-struct SnapshotHeader {
-    Hash parent;
-    u64 tx_id;
-    u64 rule_count;
-    u64 timestamp; // logical tick, not wall time
+let mut footprint = (rule.compute_footprint)(store, scope);
+// Stage B1 law: any match/exec inside a descended instance must READ
+// every attachment slot in the descent chain.
+for key in descent_stack {
+    footprint.a_read.insert(*key);
 }
 ```
 
-## 5. Rewriting Semantics
-1. Match: find injective morphism of left pattern in host graph.
-2. Check: evaluate guard constraints.
-3. Delete: remove nodes/edges in left not in interface K.
-4. Add: insert nodes/edges from right not in K.
-5. Commit: update graph store atomically; emit new snapshot hash.
+This is intentionally independent of whether the rule decodes attachments:
+
+- it is a **reachability / meaning** dependency, not “data was parsed”
+- it makes portal changes invalidate matches deterministically, even when the rule
+  is otherwise “pure skeleton”
+
+Downstream effects:
+
+- `Footprint.a_read` contributes to the tick patch `in_slots` (as `SlotId::Attachment(key)`),
+  because `commit_with_receipt` derives conservative `in_slots/out_slots` from footprints.
+- This keeps Paper III slicing correct: uses within a descendant instance depend on the
+  portal chain slots that establish the instance.
+
+### 9.2 Worked example: Paper III slicing includes the portal chain
 
-Rewrite operations are stored as `RewriteRecord` nodes for audit and replay.
+`warp-core` ships a unit test that models the intended history shape:
 
-## 6. Confluence Layer
-- Peers apply identical ordered rule sets to local graphs.
-- Rewrite transactions broadcast as `{tx_id, rule_id, scope_hash, snapshot_hash}`.
-- Deterministic merge ensures all peers converge on the same snapshot hash.
-- Conflicts resolved via rule precedence and canonical ordering; paradoxes quarantined if constraints fail.
+- tick 0: `OpenPortal` produces `SlotId::Attachment(portal_key)`
+- tick 1: an op inside the child instance produces a node slot and **reads** `portal_key`
 
-## 7. Snapshots & Persistence
-- Snapshots are immutable, content-addressed views of the live graph.
-- Exportable via `warp snapshot`; can be streamed over network.
-- Append-only archival logs optional for audit / rollback recovery.
+Worldline slicing for the child-produced node correctly returns both ticks (include the portal chain):
 
-## 8. API Surface
-### Rust
 ```rust
-engine.register_rule(rule);
-engine.begin_tx();
-engine.rewrite("physics:update");
-let snap = engine.snapshot();
+let worldline = [patch0_open_portal, patch1_child_write];
+let ticks = slice_worldline_indices(&worldline, SlotId::Node(child_node_key));
+assert_eq!(ticks, vec![0, 1]);
 ```
 
-### Rhai
-```rhai
-warp.apply("update/transform", #{ entity: id })
+See: `crates/warp-core/src/tick_patch.rs` (`slice_includes_portal_chain_for_descended_instance`).
+
+---
+
+## 10. Where to read code (module tour)
+
+Start here (in order):
+
+- `crates/warp-core/src/lib.rs` — public exports and crate-level invariants.
+- `crates/warp-core/src/ident.rs` — id types, constructors, stable byte accessors.
+- `crates/warp-core/src/record.rs` — skeleton records (`NodeRecord`, `EdgeRecord`).
+- `crates/warp-core/src/graph.rs` — `GraphStore` layout and invariants.
+- `crates/warp-core/src/attachment.rs` — typed atoms, descent values, codec boundary.
+- `crates/warp-core/src/footprint.rs` — MWMR footprint tracking + port keys.
+- `crates/warp-core/src/scheduler.rs` — deterministic ordering + reservation.
+- `crates/warp-core/src/engine_impl.rs` — transaction lifecycle and commit pipeline.
+- `crates/warp-core/src/snapshot.rs` — canonical state hashing + commit id encoding.
+- `crates/warp-core/src/receipt.rs` — Paper II tick receipts.
+- `crates/warp-core/src/tick_patch.rs` — Paper III delta patches + replay/slicing helpers.
+- `crates/warp-core/src/warp_state.rs` — WarpInstances container.
+
+---
+
+## 11. Quickstart examples
+
+### 11.1 Minimal (single instance)
+
+For a working “known-good” bootstrap, use the demo helper:
+
+```rust
+use warp_core::{
+    build_motion_demo_engine, encode_motion_atom_payload, make_node_id, make_type_id,
+    ApplyResult, AttachmentValue, NodeRecord, MOTION_RULE_NAME,
+};
+
+let mut engine = build_motion_demo_engine();
+
+// Insert an entity with a valid motion payload.
+let entity_id = make_node_id("entity");
+let entity_type = make_type_id("entity");
+let payload = encode_motion_atom_payload([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
+engine
+    .insert_node_with_attachment(
+        entity_id,
+        NodeRecord { ty: entity_type },
+        Some(AttachmentValue::Atom(payload)),
+    )
+    .unwrap();
+
+let tx = engine.begin();
+let res = engine.apply(tx, MOTION_RULE_NAME, &entity_id).unwrap();
+assert!(matches!(res, ApplyResult::Applied));
+let (snapshot, receipt, patch) = engine.commit_with_receipt(tx).unwrap();
+assert_eq!(snapshot.patch_digest, patch.digest());
+assert_eq!(snapshot.decision_digest, receipt.digest());
 ```
 
-### TypeScript (WASM)
-```ts
-await warp.apply("ui/paint");
-const graph = await warp.snapshot();
+For provenance-grade outputs, use `commit_with_receipt` and store the `TickReceipt`
+and `WarpTickPatchV1` alongside the snapshot hash.
+
+### 11.2 Stage B1: portals + `apply_in_warp` + slicing
+
+The minimal “B1-shaped” workflow is:
+
+1) establish a portal (`OpenPortal`) from a node-owned attachment slot (Alpha plane) to a child `WarpId`  
+2) apply a rewrite inside the child warp using `Engine::apply_in_warp` with a `descent_stack` containing that portal key  
+3) verify the tick patch `in_slots` includes the portal slot, and slicing pulls in the portal-opening tick
+
+```rust
+use warp_core::{
+    slice_worldline_indices, ApplyResult, AttachmentKey, ConflictPolicy, Engine, Footprint,
+    GraphStore, NodeId, NodeKey, NodeRecord, PortalInit, SchedulerKind, SlotId, TickCommitStatus,
+    WarpOp, WarpState, WarpTickPatchV1, WarpInstance, POLICY_ID_NO_POLICY_V0, RewriteRule,
+    make_node_id, make_type_id, make_warp_id,
+};
+
+fn b1_rule_match(_store: &GraphStore, _scope: &NodeId) -> bool {
+    true
+}
+
+fn b1_rule_exec(store: &mut GraphStore, _scope: &NodeId) {
+    let child_node = make_node_id("child-node");
+    store.insert_node(
+        child_node,
+        NodeRecord {
+            ty: make_type_id("ChildTy"),
+        },
+    );
+}
+
+fn b1_rule_footprint(_store: &GraphStore, _scope: &NodeId) -> Footprint {
+    let mut fp = Footprint::default();
+    // Conservative: record the write so patch out_slots is slice-safe.
+    fp.n_write.insert_node(&make_node_id("child-node"));
+    fp
+}
+
+fn insert_child_node_rule() -> RewriteRule {
+    RewriteRule {
+        id: [9u8; 32],
+        name: "demo/b1-insert-child-node",
+        left: warp_core::PatternGraph { nodes: vec![] },
+        matcher: b1_rule_match,
+        executor: b1_rule_exec,
+        compute_footprint: b1_rule_footprint,
+        factor_mask: 0,
+        conflict_policy: ConflictPolicy::Abort,
+        join_fn: None,
+    }
+}
+
+let root_warp = make_warp_id("root");
+let child_warp = make_warp_id("child");
+
+let root_node = make_node_id("root-node");
+let child_root = make_node_id("child-root");
+let child_node = make_node_id("child-node");
+
+let root_key = NodeKey {
+    warp_id: root_warp,
+    local_id: root_node,
+};
+let child_root_key = NodeKey {
+    warp_id: child_warp,
+    local_id: child_root,
+};
+let child_node_key = NodeKey {
+    warp_id: child_warp,
+    local_id: child_node,
+};
+
+// The portal lives in the node-owned (Alpha) attachment plane.
+let portal_key = AttachmentKey::node_alpha(root_key);
+
+// Step 0: build an initial multi-instance state via patch replay (no engine internals required).
+let mut state = WarpState::new();
+
+let init_root = WarpTickPatchV1::new(
+    POLICY_ID_NO_POLICY_V0,
+    [0u8; 32], // demo rule_pack_id
+    TickCommitStatus::Committed,
+    vec![],
+    vec![SlotId::Node(root_key)],
+    vec![
+        WarpOp::UpsertWarpInstance {
+            instance: WarpInstance {
+                warp_id: root_warp,
+                root_node: root_node,
+                parent: None,
+            },
+        },
+        WarpOp::UpsertNode {
+            node: root_key,
+            record: NodeRecord {
+                ty: make_type_id("RootTy"),
+            },
+        },
+    ],
+);
+init_root.apply_to_state(&mut state).unwrap();
+
+let open_portal = WarpTickPatchV1::new(
+    POLICY_ID_NO_POLICY_V0,
+    [0u8; 32],
+    TickCommitStatus::Committed,
+    vec![],
+    vec![SlotId::Attachment(portal_key), SlotId::Node(child_root_key)],
+    vec![WarpOp::OpenPortal {
+        key: portal_key,
+        child_warp,
+        child_root,
+        init: PortalInit::Empty {
+            root_record: NodeRecord {
+                ty: make_type_id("ChildRootTy"),
+            },
+        },
+    }],
+);
+open_portal.apply_to_state(&mut state).unwrap();
+
+// Step 1: initialize an engine from that multi-instance state.
+let mut engine = Engine::with_state(state, root_key, SchedulerKind::Radix, POLICY_ID_NO_POLICY_V0)
+    .unwrap();
+engine.register_rule(insert_child_node_rule()).unwrap();
+
+// Step 2: apply inside the child warp.
+let tx = engine.begin();
+let res = engine
+    .apply_in_warp(tx, child_warp, "demo/b1-insert-child-node", &child_root, &[portal_key])
+    .unwrap();
+assert!(matches!(res, ApplyResult::Applied));
+let (_snapshot, _receipt, patch1) = engine.commit_with_receipt(tx).unwrap();
+
+// The descent stack is enforced as an attachment read.
+assert!(patch1.in_slots().contains(&SlotId::Attachment(portal_key)));
+assert!(patch1.out_slots().contains(&SlotId::Node(child_node_key)));
+
+// Step 3: worldline slicing pulls in the portal chain.
+let worldline = vec![open_portal, patch1];
+let ticks = slice_worldline_indices(&worldline, SlotId::Node(child_node_key));
+assert_eq!(ticks, vec![0, 1]);
 ```
 
-## 9. Determinism Invariants
-1. Rule application order determined solely by `(rule_id, scope_hash)`.
-2. Identical initial graph + rewrite history ⇒ identical snapshot hash.
-3. Snapshots/logging/network replication side-effect free; no hidden state.
-4. Rewrites commute when scopes disjoint; engine enforces via DPOi scheduler.
-5. Confluent peers always converge on same canonical graph.
-
-## 10. Networking & Confluence Protocol
-- Transactions streamed as rewrite packets referencing parent snapshot hash.
-- Peers validate hash chains, apply rules locally via deterministic scheduler.
-- Quantum/probabilistic rewrites tagged with amplitudes; simulated deterministically.
-
-## 11. Tooling Hooks
-| Tool | Role |
-| --- | --- |
-| `warp` CLI | apply rules, emit snapshots, verify confluence |
-| Echo Studio | visualize live graph, rewrites, merges |
-| Analyzer | verify rule commutativity, determinism proofs |
-
-## 12. Security & Capabilities
-- Capability tokens restrict rule classes (e.g., `world:rewrite`, `asset:import`).
-- Atomic transactions validated pre-commit.
-- Snapshots optionally signed (Ed25519); peers reject invalid hashes.
-
-## 13. Roadmap
-| Phase | Deliverable |
-| --- | --- |
-| 1.5 | `warp-core` with typed DPOi engine and deterministic scheduler |
-| 2.0 | Integrate Echo ECS & assets as rewrite schemas |
-| 2.5 | Implement Confluence networking layer |
-| 3.0 | Tooling for live editing, graph diff visualization, snapshot verification |
-
-**TL;DR:** WARP v2 is a deterministic, typed DPOi graph-rewriting engine with atomic in-place updates and confluent distributed synchronization. Echo’s runtime, assets, and tools run atop the same living graph.
+Notes:
+
+- `Engine::apply_in_warp(..., descent_stack)` is the *only* place the engine needs to “know about recursion”
+  for correctness: the hot path still matches within an instance skeleton only.
+- If you don’t record descent-chain reads, you can build a system that “looks right” but produces incorrect slices.
diff --git a/docs/spec-warp-tick-patch.md b/docs/spec-warp-tick-patch.md
index faffbf59..f8b2c84e 100644
--- a/docs/spec-warp-tick-patch.md
+++ b/docs/spec-warp-tick-patch.md
@@ -1,6 +1,6 @@
 <!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
 <!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
-# WARP Tick Patch Spec (v1)
+# WARP Tick Patch Spec (v2)
 
 This document defines the **tick patch** boundary artifact used for Paper III-style replay and slicing.
 
@@ -16,6 +16,10 @@ Echo policy (locked decisions):
     - `ValueVersionId := (slot_id, tick_index)` where `tick_index` is the patch’s position in `P`.
 - **Commit hash v2 commits only to the patch digest:** `commit_id` commits to the replayable delta, not to planner/scheduler narration.
 
+Patch format note:
+- The Rust struct name is still `WarpTickPatchV1`, but the canonical byte encoding committed by
+  `patch_digest` is versioned. This spec defines patch encoding **version 2**.
+
 ---
 
 ## 1. Data Model
@@ -24,33 +28,48 @@ Echo policy (locked decisions):
 
 `SlotId` identifies a location whose value can change over ticks.
 
-V1 slots:
-- `Node(NodeId)` — the full node record at `NodeId` (type id + payload bytes).
-- `Edge(EdgeId)` — the full edge record at `EdgeId` (from/to/type/payload).
+V2 slots:
+- `Node(NodeKey)` — the skeleton node record at `NodeKey` (instance-scoped).
+- `Edge(EdgeKey)` — the skeleton edge record at `EdgeKey` (instance-scoped).
+- `Attachment(AttachmentKey)` — an attachment slot value (Atom payload or `Descend` link).
 - `Port(PortKey)` — a boundary port value (opaque key).
 
 Notes:
-- V1 treats node/edge records as atomic “values” at their slots. Finer-grained slots (component fields, attachment fragments) can be introduced later.
+- `NodeKey = { warp_id: WarpId, local_id: NodeId }`.
+- `EdgeKey = { warp_id: WarpId, local_id: EdgeId }`.
+- Attachment-plane payload atoms are typed: `AtomPayload { type_id: TypeId, bytes: Bytes }`.
+- `NodeRecord.ty` / `EdgeRecord.ty` are **skeleton type ids** (schema typing for structure).
+- `AtomPayload.type_id` is an **attachment-plane type id** (meaning tag for the bytes).
+- V2 treats node/edge records and attachment slot values as atomic “values” at their slots.
+  Finer-grained slots (component fields, attachment fragments) can be introduced later.
 - The patch does **not** embed version identifiers. Versioning is derived from tick index at slice time.
 
 ### 1.2 WarpOp (delta edits)
 
-`WarpOp` is a canonical edit operation on the graph store.
+`WarpOp` is a canonical edit operation on the WARP state.
+
+V2 op set (minimal but recursion-ready):
+- `OpenPortal { key: AttachmentKey, child_warp: WarpId, child_root: NodeId, init: PortalInit }`
+- `UpsertWarpInstance { instance: WarpInstance }`
+- `DeleteWarpInstance { warp_id: WarpId }`
+- `UpsertNode { node: NodeKey, record: NodeRecord }`
+- `DeleteNode { node: NodeKey }`
+- `UpsertEdge { warp_id: WarpId, record: EdgeRecord }`
+- `DeleteEdge { warp_id: WarpId, from: NodeId, edge_id: EdgeId }`
+- `SetAttachment { key: AttachmentKey, value: Option<AttachmentValue> }`
 
-Minimal V1 op set:
-- `UpsertNode { node: NodeId, record: NodeRecord }`
-- `DeleteNode { node: NodeId }`
-- `UpsertEdge { record: EdgeRecord }`
-- `DeleteEdge { from: NodeId, edge_id: EdgeId }`
+Note: the order of ops in this list is semantic only; the canonical encoding tag order is defined in section 3.2 and is authoritative.
 
 Semantic intent:
 - Ops are deterministic edits that, when applied in order, transform `U_i` into `U_{i+1}`.
 - Ops are a replay contract and must be stable across languages.
+- Attachment-plane edits must be explicit ops (`SetAttachment`), never “hidden” inside node/edge record bytes.
+- Descended attachments should be authored atomically via `OpenPortal` (so portal creation and instance creation cannot be separated across ticks).
 
 ### 1.3 WarpTickPatchV1 (μ)
 
 Canonical patch fields:
-- `version: u16 = 1`
+- `version: u16 = 2`
 - `policy_id: u32`
 - `rule_pack_id: Hash` (pin for the producing rule-pack; does not affect replay semantics)
 - `commit_status: u8`
@@ -58,7 +77,7 @@ Canonical patch fields:
   - `2` = Aborted (reserved for future transactional semantics)
 - `in_slots: Set<SlotId>` (sorted, deduped)
 - `out_slots: Set<SlotId>` (sorted, deduped)
-- `ops: Vec<WarpOp>` (canonical order)
+- `ops: Vec<WarpOp>` (canonical order; deduped by canonical sort key)
 
 #### rule_pack_id (v1)
 
@@ -86,23 +105,34 @@ Non-canonical optional metadata (not part of `patch_digest` unless explicitly up
 Slots must be sorted in ascending order with a stable type tag ordering:
 1) Node
 2) Edge
-3) Port
+3) Attachment
+4) Port
 
 Within each tag:
-- NodeId / EdgeId compare lexicographically on their 32-byte hash values.
-- PortKey compares as `u64` in little-endian numeric order.
+- `WarpId`, `NodeId`, `EdgeId`, and `TypeId` compare lexicographically on their 32-byte values.
+- `NodeKey` sorts by (`warp_id`, `local_id`).
+- `EdgeKey` sorts by (`warp_id`, `local_id`).
+- `AttachmentKey` sorts by (`owner_tag`, `plane_tag`, `owner.warp_id`, `owner.local_id`).
+- `PortKey` compares as `u64` in little-endian numeric order.
 
 ### 2.2 Op ordering
 
-`ops` must be emitted in canonical order:
-1) `DeleteEdge` by (`from`, `edge_id`)
-2) `DeleteNode` by (`node`)
-3) `UpsertNode` by (`node`)
-4) `UpsertEdge` by (`record.from`, `record.id`)
+`ops` must be emitted in canonical order (`WarpOp::sort_key`):
+1) `OpenPortal` by (`owner_tag`, `plane_tag`, `owner.warp_id`, `owner.local_id`)
+2) `UpsertWarpInstance` by (`warp_id`)
+3) `DeleteWarpInstance` by (`warp_id`)
+4) `DeleteEdge` by (`warp_id`, `from`, `edge_id`)
+5) `DeleteNode` by (`node.warp_id`, `node.local_id`)
+6) `UpsertNode` by (`node.warp_id`, `node.local_id`)
+7) `UpsertEdge` by (`warp_id`, `record.from`, `record.id`)
+8) `SetAttachment` by (`owner_tag`, `plane_tag`, `owner.warp_id`, `owner.local_id`)
 
 Rationale:
+- Portals are authored atomically (instance creation + `Descend` link) and must be applied before any ops that depend on the child instance.
+- Instance metadata is written before instance-scoped content ops.
 - Deletes occur before upserts (safe for replay).
 - Nodes are created/updated before edges that reference them.
+- Attachment writes occur after skeleton writes for the same owners.
 - Deterministic, position-independent patch digests require a canonical op order.
 
 ---
@@ -111,7 +141,7 @@ Rationale:
 
 `patch_digest` is the BLAKE3 digest of the canonical byte encoding of the patch **core**:
 
-- `patch_version: u16 = 1`
+- `patch_version: u16 = 2`
 - `policy_id: u32`
 - `rule_pack_id: 32 bytes`
 - `commit_status: u8`
@@ -121,19 +151,83 @@ Rationale:
 
 Encoding rules:
 - All list lengths are `u64` little-endian.
-- All ids (`NodeId`, `EdgeId`, `TypeId`, and `Hash`) are raw 32-byte values.
-- Payload bytes are encoded as: `len: u64 LE` then raw bytes.
-- Slot encoding:
-  - `tag: u8` then tag-specific bytes:
-    - Node: `node_id: 32`
-    - Edge: `edge_id: 32`
-    - Port: `port_key: u64 LE`
-- Op encoding:
-  - `tag: u8` then tag-specific bytes:
-    - UpsertNode: `node_id: 32`, `type_id: 32`, `payload_len: u64`, `payload_bytes`
-    - DeleteNode: `node_id: 32`
-    - UpsertEdge: `from: 32`, `edge_id: 32`, `to: 32`, `type_id: 32`, `payload_len: u64`, `payload_bytes`
-    - DeleteEdge: `from: 32`, `edge_id: 32`
+- All ids (`WarpId`, `NodeId`, `EdgeId`, `TypeId`, and `Hash`) are raw 32-byte values.
+
+### 3.1 Slot encoding
+
+- `tag: u8` then tag-specific bytes:
+  - Node (tag `1`): `warp_id: 32`, `node_id: 32`
+  - Edge (tag `2`): `warp_id: 32`, `edge_id: 32`
+  - Attachment (tag `3`): `attachment_key` (defined below)
+  - Port (tag `4`): `port_key: u64 LE`
+
+### 3.2 Op encoding
+
+- `tag: u8` then tag-specific bytes:
+  - UpsertWarpInstance (tag `1`):
+    - `warp_id: 32`, `root_node: 32`, `parent_key_opt`
+  - DeleteWarpInstance (tag `2`):
+    - `warp_id: 32`
+  - UpsertNode (tag `3`):
+    - `warp_id: 32`, `node_id: 32`, `node_type_id: 32`
+  - DeleteNode (tag `4`):
+    - `warp_id: 32`, `node_id: 32`
+  - UpsertEdge (tag `5`):
+    - `warp_id: 32`, `from: 32`, `edge_id: 32`, `to: 32`, `edge_type_id: 32`
+  - DeleteEdge (tag `6`):
+    - `warp_id: 32`, `from: 32`, `edge_id: 32`
+  - SetAttachment (tag `7`):
+    - `attachment_key`
+    - `attachment_value_opt`
+  - OpenPortal (tag `8`):
+    - `attachment_key` (the portal slot)
+    - `child_warp_id: 32`
+    - `child_root_node_id: 32`
+    - `portal_init`
+
+Implementation note:
+- These op tag bytes are part of the patch format for hashing and do not define replay ordering.
+  Replay ordering is defined separately by canonical op ordering (section 2.2).
+
+### 3.3 attachment_key
+
+`attachment_key` encodes the identity of an attachment slot:
+
+- `owner_tag: u8` (`1` = Node, `2` = Edge)
+- `plane_tag: u8` (`1` = Alpha, `2` = Beta)
+- `owner_warp_id: 32`
+- `owner_local_id: 32` (raw `NodeId` or `EdgeId` bytes)
+
+### 3.4 parent_key_opt
+
+`parent_key_opt`:
+
+- `present: u8` (`0` = None, `1` = Some)
+- when present: `attachment_key`
+
+### 3.5 attachment_value_opt
+
+`attachment_value_opt`:
+
+- `present: u8` (`0` = None, `1` = Some)
+- when present:
+  - `value_tag: u8` (`1` = Atom, `2` = Descend)
+  - if Atom:
+    - `payload_type_id: 32`
+    - `payload_len: u64 LE`
+    - `payload_bytes`
+  - if Descend:
+    - `child_warp_id: 32`
+
+### 3.6 portal_init
+
+`portal_init` encodes the initialization policy for `OpenPortal`:
+
+- `init_tag: u8`
+  - `0` = RequireExisting (require child instance + root already exist)
+  - `1` = Empty (create root if missing)
+- if `init_tag == 1`:
+  - `root_node_type_id: 32` (the `NodeRecord.ty` for the created root node)
 
 ---
 
@@ -164,3 +258,7 @@ Slice extraction for a target value version `s@n`:
 Correctness requires conservatism:
 - Over-approximate `in_slots` is acceptable (slices get larger but remain correct).
 - Under-approximate `in_slots` is forbidden (slices become incorrect).
+
+Descended attachments (Stage B1) integrate by construction:
+- execution inside a descended instance reads the attachment keys in its descent chain,
+  so the slice for a descendant-produced slot pulls in the portal producers via those reads.
diff --git a/docs/spec/SPEC-0001-attachment-plane-v0-atoms.md b/docs/spec/SPEC-0001-attachment-plane-v0-atoms.md
new file mode 100644
index 00000000..2ca44055
--- /dev/null
+++ b/docs/spec/SPEC-0001-attachment-plane-v0-atoms.md
@@ -0,0 +1,162 @@
+<!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
+<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
+# SPEC-0001: Attachment Plane v0 — Typed Atoms (Depth-0)
+
+Status: Accepted (Implemented)  
+Owner: Echo / WARP  
+Last reviewed: 2025-12-30
+
+## Goal
+
+Define a safe, deterministic, performance-preserving representation of attachments in Echo:
+
+- Attachments are **typed atoms** by default (depth-0).
+- The skeleton rewrite engine remains **skeleton-only** on the hot path.
+- Higher layers can decode attachments via strict, deterministic codecs.
+
+This spec is the “Stage B0” foundation for “WARPs all the way down” without slowing rewriting.
+
+## Definitions
+
+### SkeletonGraph
+
+The structural graph used for matching, rewriting, scheduling, patching, hashing, and slicing.
+
+In code today: `warp_core::GraphStore`.
+
+### WarpState
+
+Conceptually:
+
+```
+U := (G, A)
+```
+
+- `G` is the SkeletonGraph.
+- `A` is the attachment plane.
+
+### AtomPayload (depth-0)
+
+Depth-0 means: all attachments are atoms. No descended substructure is represented.
+
+In code today:
+
+```
+AtomPayload {
+  type_id: TypeId,
+  bytes: Bytes,
+}
+```
+
+### Projection π(U)
+
+`π` forgets attachments:
+
+```
+π(G, A) = G
+```
+
+Echo’s core rewrite hot path operates over `π(U)` unless a rule explicitly chooses to decode attachments.
+
+## Requirements
+
+### R1 — Typed atoms
+
+Nodes/edges MAY carry an attachment payload. If present, it MUST be a typed atom:
+
+- `AttachmentValue::Atom(AtomPayload)`
+
+Representation rule:
+- Node/edge *records* (`NodeRecord`, `EdgeRecord`) are skeleton-only.
+- Attachments are stored separately in the attachment plane:
+  - node/α plane: `GraphStore.node_attachments: BTreeMap<NodeId, AttachmentValue>`
+  - edge/β plane: `GraphStore.edge_attachments: BTreeMap<EdgeId, AttachmentValue>`
+
+### R2 — Payload type_id participates in canonical encoding
+
+Canonical encodings and digests that include payload bytes MUST also include the payload `type_id`.
+
+Rationale: “same bytes, different meaning” collisions are forbidden at the deterministic boundary.
+
+### R3 — No hidden edges
+
+Payload bytes MUST NOT be treated as skeleton structure.
+
+If structure must be visible to rewriting, scheduling, slicing, or causality reasoning, it must be represented explicitly in the SkeletonGraph.
+
+### R4 — Decoding is a boundary concern
+
+Core matching/indexing MUST NOT decode payload bytes by default.
+
+Decoding occurs only in typed boundaries (rules, views, inspectors) when explicitly requested.
+
+### R5 — Deterministic decode failure semantics (v0)
+
+Echo v0 chooses:
+
+- **(A) decode failure ⇒ rule does not apply**
+
+Meaning:
+
+- payload `type_id` mismatch ⇒ matcher must return `false`
+- strict decode failure ⇒ matcher must return `false`
+
+This policy is conservative and prevents “partial effects” when payloads are malformed.
+
+## Canonical encoding rules (boundary safety)
+
+### State hashing (state_root)
+
+`state_root` is computed from a canonical reachable-only traversal.
+
+When encoding a node or edge payload:
+
+- emit a 1-byte presence tag (`0` = None, `1` = Some)
+- when present: emit `payload.type_id` (32 bytes), then `payload_len: u64 LE`, then raw payload bytes
+
+See: `crates/warp-core/src/snapshot.rs`.
+
+### Tick patch digest (patch_digest)
+
+`patch_digest` commits to the replayable delta ops. Any op that encodes payload bytes MUST use the same typed-atom encoding rule as above (presence tag + `type_id` + length + bytes).
+
+See: `crates/warp-core/src/tick_patch.rs` and `docs/spec-warp-tick-patch.md`.
+
+## API surface
+
+### Atom payload type
+
+- `AtomPayload { type_id, bytes }`
+- Helpers:
+  - `AtomPayload::new(type_id, bytes)`
+  - `AtomPayload::decode_with<C, T>() -> Result<T, DecodeError>`
+  - `AtomPayload::decode_for_match<C, T>() -> Option<T>` (implements policy R5)
+
+### Codec boundary
+
+Minimal codec trait:
+
+```
+trait Codec<T> {
+  const TYPE_ID: TypeId;
+  fn encode_canon(value: &T) -> Bytes;
+  fn decode_strict(bytes: &Bytes) -> Result<T, DecodeError>;
+}
+```
+
+Notes:
+
+- Core rewriting does not depend on decoding.
+- A small dynamic registry (`CodecRegistry`) exists for tooling layers that want runtime decoding keyed by `TypeId`.
+
+See: `crates/warp-core/src/attachment.rs`.
+
+## Non-goals (v0)
+
+- Descended attachments (`Descend(...)`, attachment-root references, recursive traversal).
+- Automatic decoding/traversal in the core rewrite engine.
+- Treating payload bytes as embedded graphs.
+
+## Follow-ups (Stage B1)
+
+- Stage B1 is specified in `docs/spec/SPEC-0002-descended-attachments-v1.md`.
diff --git a/docs/spec/SPEC-0002-descended-attachments-v1.md b/docs/spec/SPEC-0002-descended-attachments-v1.md
new file mode 100644
index 00000000..01422751
--- /dev/null
+++ b/docs/spec/SPEC-0002-descended-attachments-v1.md
@@ -0,0 +1,231 @@
+<!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
+<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
+# SPEC-0002: Descended Attachments v1 — WarpInstances + Flattened Indirection
+
+Status: Draft (Implemented)  
+Date: 2025-12-30  
+Related:
+- `docs/adr/ADR-0002-warp-instances-descended-attachments.md`
+- `docs/spec/SPEC-0001-attachment-plane-v0-atoms.md`
+- `docs/warp-two-plane-law.md`
+
+## Goal
+
+Provide a first-class mechanism for descended attachments (“WARPs all the way down”) that:
+
+- preserves rewrite hot-path performance (skeleton-only matching/indexing),
+- avoids hidden edges and recursive heap traversal,
+- supports correct determinism, conflict detection (footprints), and slicing.
+
+## Non-Goals (v1)
+
+- Fine-grained slicing inside atom bytes (attachment “fragments”).
+- Automatic recursive traversal of attachments during matching.
+- Arbitrary cross-instance edges (only descent via attachment slots).
+- Wormholes (history compression) — explicitly out of scope.
+
+## Definitions
+
+### Planes
+
+- Skeleton plane: explicit graph structure (nodes, edges, ports).
+- Attachment plane: a mapping of attachment slots to values.
+
+### Depth
+
+- Depth 0: all attachment values are `Atom(...)`.
+- Descended: some attachment values are `Descend(warp_id)` pointing to another WarpInstance.
+
+## Data Model
+
+### Identifiers (instance-scoped)
+
+- `WarpId`: identifies a WARP instance (a “layer” / namespace).
+- `NodeId`, `EdgeId`: local identifiers within an instance.
+- `NodeKey = { warp_id: WarpId, local_id: NodeId }`
+- `EdgeKey = { warp_id: WarpId, local_id: EdgeId }`
+
+### Attachment identity
+
+- `AttachmentPlane = Alpha | Beta`
+  - Alpha = vertex/node plane (`α`)
+  - Beta = edge plane (`β`)
+- `AttachmentOwner = Node(NodeKey) | Edge(EdgeKey)`
+- `AttachmentKey = { owner: AttachmentOwner, plane: AttachmentPlane }`
+
+### Attachment value
+
+`AttachmentValue`:
+
+- `Atom(AtomPayload)` where `AtomPayload = { type_id: TypeId, bytes: Bytes }`
+- `Descend(WarpId)` (flattened indirection to another instance)
+
+### Warp instance record
+
+`WarpInstance`:
+
+- `warp_id: WarpId`
+- `root_node: NodeId` (local id within the instance store)
+- `parent: Option<AttachmentKey>`
+  - `None` for the root instance
+  - `Some(k)` for descended instances reached via attachment slot `k`
+
+The `parent` field enables deterministic “include the portal chain” slicing without searching the whole attachment plane.
+
+## Operations
+
+### O1: OpenPortal (atomic authoring)
+
+`OpenPortal(key: AttachmentKey, child_warp: WarpId, child_root: NodeId, init: PortalInit)`
+
+This is the canonical atomic operation for descended attachments. It MUST:
+1) ensure `WarpInstance(child_warp)` exists with:
+   - `parent = Some(key)`
+   - `root_node = child_root`
+2) ensure the child root node exists (via `init`)
+3) set `Attachment[key] = Descend(child_warp)`
+
+Validation invariants (post-apply):
+- If `Attachment[key] == Descend(child_warp)`, then `WarpInstance(child_warp)` MUST exist.
+- `WarpInstance(child_warp).parent == Some(key)`
+- `WarpInstance(child_warp).root_node == child_root`
+
+PortalInit (v1):
+- `Empty { root_record }` => create the child instance/root node if missing
+- `RequireExisting` => require that the child instance/root node already exist
+
+ID note (recommended, not required by this spec): `child_warp` should be deterministically authorable without randomness, but MUST be recorded in the op for replay and verification.
+
+### O2: SetAttachment (atoms and clears)
+
+Attachment slots may also be updated directly:
+
+- `SetAttachment(key, Some(Atom(...)))`
+- `SetAttachment(key, None)` (clears)
+
+Setting `Descend(child_warp)` via a generic SetAttachment is discouraged in v1; prefer `OpenPortal` so portal creation and instance creation cannot be separated across ticks.
+
+## Slot Model (for patches / footprints / slicing)
+
+To keep slicing replayable without decoding atoms, attachments are treated as first-class slots:
+
+- `SlotId::Node(NodeKey)` — skeleton node record
+- `SlotId::Edge(EdgeKey)` — skeleton edge record
+- `SlotId::Attachment(AttachmentKey)` — attachment slot value (Atom or Descend)
+- `SlotId::Port(PortKey)` — boundary port value (opaque key)
+
+## Footprint Semantics (descent-chain law)
+
+### F1: Descent stack
+
+Execution context carries:
+
+- `descent_stack: Vec<AttachmentKey>`
+
+This is the chain of attachment keys from the root instance down to the current instance.
+
+### F2: Mandatory descent-chain reads
+
+Any match/exec within a descended instance MUST include:
+
+- READ `SlotId::Attachment(k)` for every `k` in `descent_stack`
+
+This is required even if the rule does not otherwise reference those attachments.
+
+Rationale: changing a descent pointer changes reachability/meaning and must invalidate matches deterministically.
+
+### F3: Normal reads/writes
+
+Reads/writes inside the current instance are tracked normally:
+
+- reading nodes/edges/ports => READ those slots
+- mutating nodes/edges/ports => WRITE those slots
+- setting attachments => WRITE `SlotId::Attachment(key)`
+
+## Slicing Semantics (Worldline + DAG)
+
+Paper III’s payload `P` is linear, while Echo history is a DAG.
+
+This spec defines slicing with respect to a chosen **worldline path**:
+
+- `P = (μ0, …, μn-1)` is the patch sequence along that worldline.
+
+### S1: Slice closure includes the portal chain
+
+If a slice demands any slot within instance `W`, the slice must include:
+
+- producers for the demanded slots within `W`, and
+- producers for the attachment chain from root → … → `W`:
+  - the `OpenPortal(...)` ops (and/or `SetAttachment(...Descend...)` legacy) that establish each descent step.
+
+Mechanism:
+
+- descended execution reads the descent stack (F2), so the generic Paper III slice algorithm over `in_slots`/`out_slots` pulls in the chain producers.
+
+### S2: Conservatism rule
+
+- Over-approximate `in_slots` is acceptable (slices get larger but remain correct).
+- Under-approximate `in_slots` is forbidden (slices become incorrect).
+
+### S3: DAG slicing (merge commits)
+
+Worldline slicing is a fast path for single-parent history. For multi-parent history (merge commits), slicing must treat the merge patch as a first-class event.
+
+Given a target commit `C` with parents `P1..Pn` and an initial demand set `D` (unversioned slots):
+
+1) Include in the slice any ops in `C` that write slots in `D`.
+2) For each demanded slot not written in `C`, follow parent provenance:
+   - if exactly one parent can produce it, follow that parent for that slot
+   - if multiple parents can produce it, the merge patch MUST resolve it (M1); otherwise the slice is invalid
+3) For each included op, union its read-set into `D` and repeat until closure.
+4) Portal-chain closure (Stage B1):
+   - if any demanded slot is within a descendant instance `W`, include the portal chain establishing reachability (root → … → W)
+   - in practice this is achieved by the descent-chain footprint law (F2), which ensures portal slots are read and pulled into the slice.
+
+## Matching / Performance Constraints
+
+- Matching/indexing is skeleton-only and scoped to a single `warp_id` instance.
+- No automatic traversal into descended instances during match.
+- Attachment decoding is forbidden in the hot path.
+
+## State Zoom (Tooling, explicitly NOT wormholes)
+
+Tooling may provide a “state zoom” projection (Instance Graph):
+
+- Nodes: `WarpId` (WarpInstances)
+- Edges: derived from explicit `Descend(WarpId)` portals (e.g., via OpenPortal ops or scanning attachment slots)
+- Optional summaries (derived/cache):
+  - node/edge counts per instance
+  - root ids / root hashes
+  - latest tick touching the instance
+
+This is a projection of state structure, not a history compression mechanism.
+
+Wormholes remain a history/payload compression mechanism (tick-range / patch-range compression) and are intentionally distinct from instance zoom.
+
+## Merge Semantics (Multi-Parent DAG)
+
+Echo history is a commit DAG. This spec requires:
+
+### M1: Merge commits are first-class (explicit resolution)
+
+A merge commit with parents `P1..Pn` MUST provide an explicit merge patch that resolves all slot conflicts deterministically.
+
+Definition: a slot conflict exists if two or more parents contain writes to the same `SlotId` (including `SlotId::Attachment(...)`) along the ancestry relevant to the merge.
+
+Requirement:
+- If a conflict exists for slot `S`, the merge patch MUST contain a final write to `S`.
+- If parents’ writes are disjoint for all slots in scope, the merge patch MAY be empty.
+
+Rationale:
+- We do not allow “implicit winner-by-parent-order” semantics to leak into determinism or slicing.
+- Merge is an authored event, not implicit ancestry magic.
+
+## Acceptance Criteria
+
+1) Changing a portal slot value (including `OpenPortal` / `Descend`) invalidates previously valid matches inside descendant instances (via descent-chain reads).
+2) A slice that demands a slot inside a descendant instance includes the portal chain establishing reachability (root → … → W).
+3) Matching/indexing code paths do not decode atom bytes.
+4) Hash identity changes if `TypeId` changes even with identical bytes.
+5) OpenPortal is atomic: replay never observes `Descend(child_warp)` without a valid `WarpInstance(child_warp)` and root node.
+6) Merge commits with conflicting parent writes to the same slot are invalid unless the merge patch writes the resolved final slot value.
diff --git a/docs/warp-two-plane-law.md b/docs/warp-two-plane-law.md
new file mode 100644
index 00000000..5be039bb
--- /dev/null
+++ b/docs/warp-two-plane-law.md
@@ -0,0 +1,137 @@
+<!-- SPDX-License-Identifier: Apache-2.0 OR MIND-UCAL-1.0 -->
+<!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
+# WARP Two-Plane Law (SkeletonGraph + Attachment Plane)
+
+Echo implements WARP as a **two-plane** state object:
+
+- **Skeleton plane**: explicit graph structure (nodes, edges, ports).
+- **Attachment plane**: payloads *over* skeleton vertices/edges.
+
+This doc exists to prevent a recurring confusion:
+
+> “WARP” does not mean “arbitrary bytes that we *pretend* are a subgraph.”
+>
+> WARP means “graph structure is explicit and rewrite-visible; attachments are separate, typed, and opaque by default.”
+
+---
+
+## Definitions (terms we mean precisely in this repo)
+
+### SkeletonGraph
+
+**SkeletonGraph** is the structural graph used for:
+
+- matching and indexing
+- scheduling and independence checks (footprints)
+- delta patch generation / replay (`WarpTickPatchV1`)
+- canonical hashing (`state_root`)
+- slicing (Paper III) over `in_slots` / `out_slots`
+
+**In code today:** the skeleton plane is stored in `warp_core::GraphStore`:
+- nodes: `GraphStore.nodes` (`NodeId -> NodeRecord`)
+- edges: `GraphStore.edges_from` (`NodeId -> Vec<EdgeRecord>`)
+
+### WarpState
+
+Conceptually, a WARP state is:
+
+```text
+U := (G, A)
+```
+
+Where:
+
+- `G` is the SkeletonGraph (structure).
+- `A` is the attachment plane (payloads attached to skeleton vertices/edges).
+
+### Projection π(U)
+
+`π` is the “forgetful projection” that drops attachments:
+
+```text
+π : WarpState → SkeletonGraph
+π(G, A) = G
+```
+
+Echo’s rewrite hot path is intentionally defined over `π(U)` (the skeleton).
+
+---
+
+## Attachment plane v0: depth-0 atoms only
+
+In Paper I terms, depth-0 attachments are **atoms** `Atom(p)`.
+
+In Echo v0, an atom is represented as:
+
+- `AtomPayload { type_id: TypeId, bytes: Bytes }`
+
+Where:
+
+- `type_id` is a deterministic meaning tag (part of the boundary hash/digest).
+- `bytes` are opaque to the core engine.
+
+**In code today:**
+
+- `NodeRecord` / `EdgeRecord` are **skeleton-plane only** (no payload fields).
+- Attachments are stored separately on `GraphStore`:
+  - node-attachment plane: `GraphStore.node_attachments: BTreeMap<NodeId, AttachmentValue>`
+  - edge-attachment plane: `GraphStore.edge_attachments: BTreeMap<EdgeId, AttachmentValue>`
+- Depth-0 payloads are `AttachmentValue::Atom(AtomPayload)`.
+
+---
+
+## Project laws (non-negotiable invariants)
+
+### L1 — Skeleton rewrites never decode attachments
+
+Core matching/indexing/scheduling must not parse or descend attachments.
+
+Attachment decode is allowed only when a **rule/view explicitly chooses to decode**.
+
+### L2 — No hidden edges in payload bytes
+
+If a dependency matters for:
+
+- matching,
+- causality / independence,
+- slicing correctness,
+- replay correctness,
+
+…then it must be represented as explicit skeleton structure.
+
+Payload bytes must not “smuggle” structural dependencies the engine cannot see.
+
+### L3 — Typed atoms participate in digests
+
+Any canonical encoding/digest that includes payload bytes must also include the payload `type_id`.
+
+This prevents collisions of the form:
+
+> same bytes, different meaning → same hash (forbidden)
+
+### L4 — Deterministic decode failure semantics (v0)
+
+Echo’s v0 default is conservative:
+
+- type mismatch or strict decode failure ⇒ “rule does not apply” (`NoMatch`)
+
+Rules may choose to surface a stronger policy later (e.g., transactional abort),
+but “silent partial effects” are forbidden.
+
+---
+
+## Descended attachments (Stage B1)
+
+Echo supports “WARPs all the way down” by making descent **explicit and skeleton-visible**.
+
+The shape is **flattened indirection**, not nested Rust structs:
+
+- Attachments remain separate from the skeleton plane (typed, opaque by default).
+- Descent is `AttachmentValue::Descend(WarpId)` (not encoded inside bytes).
+- Each descended attachment points to a **WarpInstance** (a namespaced skeleton store),
+  tracked in `warp_core::WarpState`.
+- Portals are authored atomically at the patch boundary (`OpenPortal`) so slices/replay cannot observe a dangling `Descend` link without the corresponding instance.
+
+Correctness law: execution inside a descended instance must READ the attachment
+keys in its descent chain (so changing a portal pointer deterministically
+invalidates matches and scheduling decisions).
