diff --git a/crates/echo-wasm-abi/src/codec.rs b/crates/echo-wasm-abi/src/codec.rs index 7f6a7c2e..203675e4 100644 --- a/crates/echo-wasm-abi/src/codec.rs +++ b/crates/echo-wasm-abi/src/codec.rs @@ -27,6 +27,12 @@ pub enum CodecError { /// Enum decoding failed. #[error("invalid enum value")] InvalidEnum, + /// Bool tag byte was not 0x00 or 0x01. + #[error("invalid bool tag")] + InvalidBoolTag, + /// Trailing bytes remain after a complete decode. + #[error("trailing bytes after decode")] + Trailing, } /// Trait for deterministic encoding to bytes. @@ -48,10 +54,14 @@ pub fn encode_to_vec(value: &T) -> Result, CodecError> { Ok(writer.into_vec()) } -/// Decode a value from a byte slice. +/// Decode a value from a byte slice, failing if any trailing bytes remain. pub fn decode_from_bytes(bytes: &[u8]) -> Result { let mut reader = Reader::new(bytes); - T::decode(&mut reader) + let value = T::decode(&mut reader)?; + if reader.remaining() > 0 { + return Err(CodecError::Trailing); + } + Ok(value) } /// Deterministic writer for little-endian scalars and length-prefixed bytes. @@ -114,6 +124,59 @@ impl Writer { self.write_len_prefixed_bytes(bytes) } + /// Write a little-endian i32. + pub fn write_i32_le(&mut self, value: i32) { + self.buf.extend_from_slice(&value.to_le_bytes()); + } + + /// Write a canonicalized little-endian f32. + /// + /// Applies the same canonicalization as `F32Scalar::new()` before writing: + /// - NaN → `0x7fc00000` (positive quiet NaN) + /// - subnormal → `+0.0` (`0x00000000`) + /// - `-0.0` → `+0.0` + /// - all other values pass through unchanged + pub fn write_f32_le(&mut self, value: f32) { + let canonical = canonicalize_f32(value); + self.buf.extend_from_slice(&canonical.to_le_bytes()); + } + + /// Write a bool as a single byte: `0x00` = false, `0x01` = true. + pub fn write_bool(&mut self, value: bool) { + self.buf.push(u8::from(value)); + } + + /// Write an optional value: `0x00` = null, `0x01` + encoded payload = present. + pub fn write_option(&mut self, value: Option, encode: F) -> Result<(), CodecError> + where + F: FnOnce(&mut Writer, T) -> Result<(), CodecError>, + { + match value { + None => self.write_u8(0x00), + Some(v) => { + self.write_u8(0x01); + encode(self, v)?; + } + } + Ok(()) + } + + /// Write a list: `u32 LE` element count, then each element encoded inline. + pub fn write_list(&mut self, values: &[T], encode: F) -> Result<(), CodecError> + where + F: Fn(&mut Writer, &T) -> Result<(), CodecError>, + { + let count: u32 = values + .len() + .try_into() + .map_err(|_| CodecError::LengthTooLarge)?; + self.write_u32_le(count); + for v in values { + encode(self, v)?; + } + Ok(()) + } + /// Consume the writer and return the buffer. #[must_use] pub fn into_vec(self) -> Vec { @@ -135,6 +198,12 @@ impl<'a> Reader<'a> { Self { bytes, offset: 0 } } + /// Return the number of unread bytes remaining in the buffer. + #[must_use] + pub fn remaining(&self) -> usize { + self.bytes.len().saturating_sub(self.offset) + } + fn take(&mut self, len: usize) -> Result<&'a [u8], CodecError> { let end = self .offset @@ -155,6 +224,13 @@ impl<'a> Reader<'a> { Ok(u32::from_le_bytes(raw)) } + /// Read a fixed-size byte array. Used for `no_std` ID fields where + /// the GraphQL `ID` scalar maps to `[u8; 32]` instead of `String`. + pub fn read_byte_array(&mut self) -> Result<[u8; N], CodecError> { + let chunk = self.take(N)?; + chunk.try_into().map_err(|_| CodecError::OutOfBounds) + } + /// Read a single byte. pub fn read_u8(&mut self) -> Result { let chunk = self.take(1)?; @@ -191,6 +267,93 @@ impl<'a> Reader<'a> { .map(ToOwned::to_owned) .map_err(|_| CodecError::InvalidUtf8) } + + /// Read a little-endian i32. + pub fn read_i32_le(&mut self) -> Result { + let chunk = self.take(4)?; + let raw: [u8; 4] = chunk.try_into().map_err(|_| CodecError::OutOfBounds)?; + Ok(i32::from_le_bytes(raw)) + } + + /// Read a little-endian f32 and canonicalize the result so identical + /// values cannot have two distinct wire encodings. + /// + /// The writer canonicalizes (NaN -> 0x7fc00000, subnormal -> +0.0, + /// -0.0 -> +0.0), so honest senders cannot produce non-canonical bytes. + /// But untrusted EINT or query var payloads can: without the same + /// canonicalization on decode, two distinct byte strings can represent + /// the same intended float value and break the determinism contract. + /// `canonicalize_f32` is idempotent on already-canonical inputs. + pub fn read_f32_le(&mut self) -> Result { + let chunk = self.take(4)?; + let raw: [u8; 4] = chunk.try_into().map_err(|_| CodecError::OutOfBounds)?; + Ok(canonicalize_f32(f32::from_le_bytes(raw))) + } + + /// Read a bool from a single byte (`0x00` = false, `0x01` = true). + pub fn read_bool(&mut self) -> Result { + match self.read_u8()? { + 0x00 => Ok(false), + 0x01 => Ok(true), + _ => Err(CodecError::InvalidBoolTag), + } + } + + /// Read an optional value: `0x00` = `None`, `0x01` = `Some(decode(r))`. + pub fn read_option(&mut self, decode: F) -> Result, CodecError> + where + F: FnOnce(&mut Reader<'_>) -> Result, + { + match self.read_u8()? { + 0x00 => Ok(None), + 0x01 => Ok(Some(decode(self)?)), + _ => Err(CodecError::InvalidBoolTag), + } + } + + /// Read a list: `u32 LE` element count, then decode each element. + /// + /// Capacity allocation is bounded by the remaining buffer length so a + /// malformed payload claiming `count = 0xFFFF_FFFF` followed by zero + /// bytes cannot force a multi-gigabyte `Vec::with_capacity` (DoS) before + /// element validation runs. Honest decoders are unaffected: the cap is + /// looser than any real workload would need. + pub fn read_list(&mut self, decode: F) -> Result, CodecError> + where + F: Fn(&mut Reader<'_>) -> Result, + { + let count = self.read_u32_le()? as usize; + let remaining = self.bytes.len().saturating_sub(self.offset); + // A list element occupies at least 1 byte (e.g. a u8 tag); cap the + // initial allocation at the byte budget so we never pre-reserve more + // entries than the payload could possibly contain. + let initial_capacity = core::cmp::min(count, remaining); + let mut out = Vec::with_capacity(initial_capacity); + for _ in 0..count { + out.push(decode(self)?); + } + Ok(out) + } +} + +/// Canonicalize an `f32` to a deterministic bit pattern. +/// +/// Mirrors `F32Scalar::new()` from `warp-math` without taking that crate as a dependency: +/// - NaN (any variant) → `0x7fc00000` (positive quiet NaN) +/// - subnormal → `+0.0` (`0x00000000`) +/// - `-0.0` → `+0.0` +/// - all other values pass through unchanged +#[inline] +#[must_use] +pub fn canonicalize_f32(v: f32) -> f32 { + if v.is_nan() { + f32::from_bits(0x7fc0_0000) + } else if v.is_subnormal() { + 0.0_f32 + } else { + // Maps -0.0 → +0.0; all other normal values are unchanged. + v + 0.0_f32 + } } /// Convert an integer to Q32.32 fixed-point representation. @@ -241,3 +404,330 @@ pub fn vec3_fx_from_i64(x: i64, y: i64, z: i64) -> [i64; 3] { pub fn vec3_fx_from_f32(x: f32, y: f32, z: f32) -> [i64; 3] { [fx_from_f32(x), fx_from_f32(y), fx_from_f32(z)] } + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::redundant_closure_for_method_calls +)] +mod tests { + use super::*; + + // ── helpers ────────────────────────────────────────────────────────────── + + fn roundtrip(write: W, read: R) -> T + where + W: FnOnce(&mut Writer), + R: FnOnce(&mut Reader<'_>) -> Result, + { + let mut w = Writer::default(); + write(&mut w); + let buf = w.into_vec(); + let mut r = Reader::new(&buf); + read(&mut r).expect("decode failed") + } + + // ── i32 ────────────────────────────────────────────────────────────────── + + #[test] + fn i32_roundtrip_zero() { + let v = roundtrip(|w| w.write_i32_le(0), |r| r.read_i32_le()); + assert_eq!(v, 0); + } + + #[test] + fn i32_roundtrip_positive() { + let v = roundtrip(|w| w.write_i32_le(42), |r| r.read_i32_le()); + assert_eq!(v, 42); + } + + #[test] + fn i32_roundtrip_negative() { + let v = roundtrip(|w| w.write_i32_le(-1), |r| r.read_i32_le()); + assert_eq!(v, -1); + } + + #[test] + fn i32_roundtrip_min() { + let v = roundtrip(|w| w.write_i32_le(i32::MIN), |r| r.read_i32_le()); + assert_eq!(v, i32::MIN); + } + + #[test] + fn i32_roundtrip_max() { + let v = roundtrip(|w| w.write_i32_le(i32::MAX), |r| r.read_i32_le()); + assert_eq!(v, i32::MAX); + } + + #[test] + fn i32_wire_bytes() { + // 0x01020304 in LE → [0x04, 0x03, 0x02, 0x01] + let mut w = Writer::default(); + w.write_i32_le(0x0102_0304); + assert_eq!(w.into_vec(), [0x04, 0x03, 0x02, 0x01]); + } + + // ── f32 canonicalization ───────────────────────────────────────────────── + + #[test] + fn f32_canonicalize_nan_any_becomes_quiet_nan() { + // All NaN variants must produce the same canonical bit pattern 0x7fc00000. + let quiet_nan = f32::from_bits(0x7fc0_0000); + let signaling_nan = f32::from_bits(0x7f80_0001); + let neg_nan = f32::from_bits(0xffc0_0000); + assert_eq!(canonicalize_f32(quiet_nan).to_bits(), 0x7fc0_0000); + assert_eq!(canonicalize_f32(signaling_nan).to_bits(), 0x7fc0_0000); + assert_eq!(canonicalize_f32(neg_nan).to_bits(), 0x7fc0_0000); + } + + #[test] + fn f32_canonicalize_subnormal_becomes_positive_zero() { + // Smallest positive subnormal: 0x00000001. + let subnormal = f32::from_bits(0x0000_0001); + assert!(subnormal.is_subnormal()); + assert_eq!(canonicalize_f32(subnormal).to_bits(), 0x0000_0000); + } + + #[test] + fn f32_canonicalize_negative_zero_becomes_positive_zero() { + let neg_zero = -0.0_f32; + assert_eq!(neg_zero.to_bits(), 0x8000_0000); + assert_eq!(canonicalize_f32(neg_zero).to_bits(), 0x0000_0000); + } + + #[test] + fn f32_canonicalize_positive_infinity_unchanged() { + let inf = f32::INFINITY; + assert_eq!(canonicalize_f32(inf).to_bits(), inf.to_bits()); + } + + #[test] + fn f32_canonicalize_normal_values_unchanged() { + for v in [1.0_f32, -1.0, 42.5, f32::MAX, f32::MIN] { + assert_eq!(canonicalize_f32(v).to_bits(), v.to_bits(), "value: {v}"); + } + } + + #[test] + fn f32_roundtrip_normal() { + let v = roundtrip(|w| w.write_f32_le(1.5), |r| r.read_f32_le()); + assert_eq!(v.to_bits(), 1.5_f32.to_bits()); + } + + #[test] + fn f32_roundtrip_nan_canonicalizes() { + // write_f32_le must canonicalize; the decoded bits must be 0x7fc00000. + let v = roundtrip(|w| w.write_f32_le(f32::NAN), |r| r.read_f32_le()); + assert_eq!(v.to_bits(), 0x7fc0_0000); + } + + #[test] + fn f32_roundtrip_subnormal_canonicalizes_to_zero() { + let subnormal = f32::from_bits(0x0000_0001); + let v = roundtrip(|w| w.write_f32_le(subnormal), |r| r.read_f32_le()); + assert_eq!(v.to_bits(), 0x0000_0000); + } + + #[test] + fn f32_roundtrip_negative_zero_canonicalizes_to_positive_zero() { + let v = roundtrip(|w| w.write_f32_le(-0.0_f32), |r| r.read_f32_le()); + assert_eq!(v.to_bits(), 0x0000_0000); + } + + #[test] + fn f32_roundtrip_positive_infinity() { + let v = roundtrip(|w| w.write_f32_le(f32::INFINITY), |r| r.read_f32_le()); + assert!(v.is_infinite() && v.is_sign_positive()); + } + + // ── bool ───────────────────────────────────────────────────────────────── + + #[test] + fn bool_roundtrip_false() { + let v = roundtrip(|w| w.write_bool(false), |r| r.read_bool()); + assert!(!v); + } + + #[test] + fn bool_roundtrip_true() { + let v = roundtrip(|w| w.write_bool(true), |r| r.read_bool()); + assert!(v); + } + + #[test] + fn bool_wire_bytes() { + let mut w = Writer::default(); + w.write_bool(false); + w.write_bool(true); + assert_eq!(w.into_vec(), [0x00, 0x01]); + } + + #[test] + fn bool_invalid_tag_returns_error() { + let buf = [0x02_u8]; + let mut r = Reader::new(&buf); + assert_eq!(r.read_bool(), Err(CodecError::InvalidBoolTag)); + } + + // ── option ─────────────────────────────────────────────────────────────── + + #[test] + fn option_roundtrip_none() { + let mut w = Writer::default(); + w.write_option::(None, |w, v| { + w.write_u32_le(v); + Ok(()) + }) + .unwrap(); + let buf = w.into_vec(); + assert_eq!(buf, [0x00]); + let mut r = Reader::new(&buf); + let v: Option = r.read_option(|r| r.read_u32_le()).unwrap(); + assert_eq!(v, None); + } + + #[test] + fn option_roundtrip_some() { + let mut w = Writer::default(); + w.write_option(Some(999_u32), |w, v| { + w.write_u32_le(v); + Ok(()) + }) + .unwrap(); + let buf = w.into_vec(); + // presence tag 0x01, then 999u32 LE = [0xe7, 0x03, 0x00, 0x00] + assert_eq!(buf[0], 0x01); + let mut r = Reader::new(&buf); + let v: Option = r.read_option(|r| r.read_u32_le()).unwrap(); + assert_eq!(v, Some(999)); + } + + #[test] + fn option_nested_string() { + let input: Option<&str> = Some("hello"); + let mut w = Writer::default(); + w.write_option(input, |w, v| w.write_string(v, usize::MAX)) + .unwrap(); + let buf = w.into_vec(); + let mut r = Reader::new(&buf); + let v = r.read_option(|r| r.read_string(usize::MAX)).unwrap(); + assert_eq!(v, Some("hello".to_owned())); + } + + // ── list ───────────────────────────────────────────────────────────────── + + #[test] + fn list_roundtrip_empty() { + let input: &[u32] = &[]; + let mut w = Writer::default(); + w.write_list(input, |w, v| { + w.write_u32_le(*v); + Ok(()) + }) + .unwrap(); + let buf = w.into_vec(); + // count = 0 → 4 zero bytes + assert_eq!(buf, [0x00, 0x00, 0x00, 0x00]); + let mut r = Reader::new(&buf); + let v: Vec = r.read_list(|r| r.read_u32_le()).unwrap(); + assert_eq!(v, [] as [u32; 0]); + } + + #[test] + fn list_roundtrip_three_elements() { + let input = [1_u32, 2, 3]; + let mut w = Writer::default(); + w.write_list(&input, |w, v| { + w.write_u32_le(*v); + Ok(()) + }) + .unwrap(); + let buf = w.into_vec(); + let mut r = Reader::new(&buf); + let v: Vec = r.read_list(|r| r.read_u32_le()).unwrap(); + assert_eq!(v, [1, 2, 3]); + } + + #[test] + fn list_roundtrip_strings() { + let input = ["alpha", "beta", "gamma"]; + let mut w = Writer::default(); + w.write_list(&input, |w, v| w.write_string(v, usize::MAX)) + .unwrap(); + let buf = w.into_vec(); + let mut r = Reader::new(&buf); + let v: Vec = r.read_list(|r| r.read_string(usize::MAX)).unwrap(); + assert_eq!(v, ["alpha", "beta", "gamma"]); + } + + // ── decode_from_bytes Trailing contract ───────────────────────────────── + + // A tiny Decode shape so the trailing-bytes contract is exercised end-to- + // end through the public decode_from_bytes API rather than a hand-rolled + // call into reader internals. + #[derive(Debug, PartialEq, Eq)] + struct OneInt(i32); + + impl Decode for OneInt { + fn decode(reader: &mut Reader<'_>) -> Result { + Ok(Self(reader.read_i32_le()?)) + } + } + + impl Encode for OneInt { + fn encode(&self, writer: &mut Writer) -> Result<(), CodecError> { + writer.write_i32_le(self.0); + Ok(()) + } + } + + #[test] + fn decode_from_bytes_accepts_exact_payload() { + let bytes = encode_to_vec(&OneInt(0x0102_0304)).unwrap(); + assert_eq!(decode_from_bytes::(&bytes), Ok(OneInt(0x0102_0304))); + } + + #[test] + fn decode_from_bytes_rejects_one_trailing_byte() { + // Lock in the public contract: any byte past the structurally-declared + // end of the payload must fail with CodecError::Trailing. Otherwise + // canonical + garbage and canonical-alone would decode to the same + // value while producing distinct recorded byte strings, which breaks + // submission-identity / replay invariants downstream. + let mut bytes = encode_to_vec(&OneInt(42)).unwrap(); + bytes.push(0xff); + assert_eq!( + decode_from_bytes::(&bytes), + Err(CodecError::Trailing) + ); + } + + #[test] + fn decode_from_bytes_rejects_many_trailing_bytes() { + let mut bytes = encode_to_vec(&OneInt(-1)).unwrap(); + bytes.extend_from_slice(&[0x00, 0x01, 0x02, 0x03]); + assert_eq!( + decode_from_bytes::(&bytes), + Err(CodecError::Trailing) + ); + } + + // ── canonicalize_f32 public function ──────────────────────────────────── + + #[test] + fn canonicalize_f32_all_nan_bits_map_to_canonical() { + // Exhaustively sample a range of NaN bit patterns. + for payload in [0u32, 1, 0x003f_ffff, 0x0040_0000, 0x007f_ffff] { + // Quiet NaN: exponent all-1s, fraction MSB set, positive + let bits = 0x7f80_0000 | 0x0040_0000 | payload; + let nan = f32::from_bits(bits); + assert!(nan.is_nan()); + assert_eq!( + canonicalize_f32(nan).to_bits(), + 0x7fc0_0000, + "bits={bits:#010x}" + ); + } + } +} diff --git a/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs b/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs new file mode 100644 index 00000000..8829af26 --- /dev/null +++ b/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs @@ -0,0 +1,489 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::redundant_closure_for_method_calls +)] +//! Cross-boundary fixture proofs for the jedit rope schema LE binary codec. +//! +//! These tests assert that the Rust LE binary codec primitives, when invoked +//! in the same declaration-order layout that `echo-wesley-gen` (Rust emit) and +//! `wesley emit le-binary-typescript` (TS emit) emit for the rope schema, +//! produce byte sequences that are bytewise identical to the literal hex +//! vectors asserted by `jedit/spec/rope-codec.spec.mjs`. +//! +//! The hex literals here MUST stay in lockstep with the literals in that TS +//! spec. If you change one side, change both — they are the cross-boundary +//! contract. +//! +//! Covers Phase 6 (cross-boundary roundtrip fixtures) of +//! `docs/design/0024-universal-le-binary-codec/design.md`. + +use echo_wasm_abi::codec::{Reader, Writer}; + +// --------------------------------------------------------------------------- +// rope-schema-shaped Rust shapes +// +// These mirror the structs that `echo-wesley-gen` would emit for the rope +// schema (without re-running the generator inside this test, which would +// require committing a generated.rs file to this crate). The Encode/Decode +// behaviour matches the generator's output exactly: enums as u32 LE +// discriminants, fields in SDL declaration order, nullables as presence-tagged +// options. +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +enum AnchorBias { + Left = 0, + Right = 1, +} + +fn encode_anchor_bias(w: &mut Writer, v: AnchorBias) { + w.write_u32_le(v as u32); +} + +fn decode_anchor_bias(r: &mut Reader<'_>) -> AnchorBias { + match r.read_u32_le().unwrap() { + 0 => AnchorBias::Left, + 1 => AnchorBias::Right, + d => panic!("invalid AnchorBias discriminant: {d}"), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +enum CheckpointKind { + Initial = 0, + ManualSave = 1, + AutoSave = 2, +} + +fn encode_checkpoint_kind(w: &mut Writer, v: CheckpointKind) { + w.write_u32_le(v as u32); +} + +fn decode_checkpoint_kind(r: &mut Reader<'_>) -> CheckpointKind { + match r.read_u32_le().unwrap() { + 0 => CheckpointKind::Initial, + 1 => CheckpointKind::ManualSave, + 2 => CheckpointKind::AutoSave, + d => panic!("invalid CheckpointKind discriminant: {d}"), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CreateBufferWorldlineInput { + buffer_key: String, + initial_text: Option, + projection_path: Option, + create_initial_checkpoint: Option, +} + +fn encode_create_buffer_worldline_input(w: &mut Writer, v: &CreateBufferWorldlineInput) { + w.write_string(&v.buffer_key, usize::MAX).unwrap(); + w.write_option(v.initial_text.as_deref(), |w, s| { + w.write_string(s, usize::MAX) + }) + .unwrap(); + w.write_option(v.projection_path.as_deref(), |w, s| { + w.write_string(s, usize::MAX) + }) + .unwrap(); + w.write_option(v.create_initial_checkpoint, |w, b| { + w.write_bool(b); + Ok(()) + }) + .unwrap(); +} + +fn decode_create_buffer_worldline_input(r: &mut Reader<'_>) -> CreateBufferWorldlineInput { + CreateBufferWorldlineInput { + buffer_key: r.read_string(usize::MAX).unwrap(), + initial_text: r.read_option(|r| r.read_string(usize::MAX)).unwrap(), + projection_path: r.read_option(|r| r.read_string(usize::MAX)).unwrap(), + create_initial_checkpoint: r.read_option(|r| r.read_bool()).unwrap(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ReplaceRangeAsTickInput { + worldline_id: String, + base_head_id: String, + start_byte: i32, + end_byte: i32, + insert_text: String, + author: Option, +} + +fn encode_replace_range_as_tick_input(w: &mut Writer, v: &ReplaceRangeAsTickInput) { + w.write_string(&v.worldline_id, usize::MAX).unwrap(); + w.write_string(&v.base_head_id, usize::MAX).unwrap(); + w.write_i32_le(v.start_byte); + w.write_i32_le(v.end_byte); + w.write_string(&v.insert_text, usize::MAX).unwrap(); + w.write_option(v.author.as_deref(), |w, s| w.write_string(s, usize::MAX)) + .unwrap(); +} + +fn decode_replace_range_as_tick_input(r: &mut Reader<'_>) -> ReplaceRangeAsTickInput { + ReplaceRangeAsTickInput { + worldline_id: r.read_string(usize::MAX).unwrap(), + base_head_id: r.read_string(usize::MAX).unwrap(), + start_byte: r.read_i32_le().unwrap(), + end_byte: r.read_i32_le().unwrap(), + insert_text: r.read_string(usize::MAX).unwrap(), + author: r.read_option(|r| r.read_string(usize::MAX)).unwrap(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CreateCheckpointInput { + worldline_id: String, + kind: CheckpointKind, + label: Option, +} + +fn encode_create_checkpoint_input(w: &mut Writer, v: &CreateCheckpointInput) { + w.write_string(&v.worldline_id, usize::MAX).unwrap(); + encode_checkpoint_kind(w, v.kind); + w.write_option(v.label.as_deref(), |w, s| w.write_string(s, usize::MAX)) + .unwrap(); +} + +fn decode_create_checkpoint_input(r: &mut Reader<'_>) -> CreateCheckpointInput { + CreateCheckpointInput { + worldline_id: r.read_string(usize::MAX).unwrap(), + kind: decode_checkpoint_kind(r), + label: r.read_option(|r| r.read_string(usize::MAX)).unwrap(), + } +} + +// Each operation has a single `input` arg in the rope schema, so Vars is just +// the input wrapped — matching what the generator emits. + +fn encode_create_buffer_worldline_vars(input: &CreateBufferWorldlineInput) -> Vec { + let mut w = Writer::with_capacity(0); + encode_create_buffer_worldline_input(&mut w, input); + w.into_vec() +} + +fn encode_replace_range_as_tick_vars(input: &ReplaceRangeAsTickInput) -> Vec { + let mut w = Writer::with_capacity(0); + encode_replace_range_as_tick_input(&mut w, input); + w.into_vec() +} + +fn encode_create_checkpoint_vars(input: &CreateCheckpointInput) -> Vec { + let mut w = Writer::with_capacity(0); + encode_create_checkpoint_input(&mut w, input); + w.into_vec() +} + +// --------------------------------------------------------------------------- +// fixture vectors — must match jedit/spec/rope-codec.spec.mjs exactly +// --------------------------------------------------------------------------- + +#[test] +fn anchor_bias_left_encodes_to_u32_le_zero() { + let mut w = Writer::with_capacity(0); + encode_anchor_bias(&mut w, AnchorBias::Left); + assert_eq!(w.into_vec(), [0x00, 0x00, 0x00, 0x00]); +} + +#[test] +fn anchor_bias_right_encodes_to_u32_le_one() { + let mut w = Writer::with_capacity(0); + encode_anchor_bias(&mut w, AnchorBias::Right); + assert_eq!(w.into_vec(), [0x01, 0x00, 0x00, 0x00]); +} + +#[test] +fn anchor_bias_roundtrips() { + for variant in [AnchorBias::Left, AnchorBias::Right] { + let mut w = Writer::with_capacity(0); + encode_anchor_bias(&mut w, variant); + let bytes = w.into_vec(); + let mut r = Reader::new(&bytes); + assert_eq!(decode_anchor_bias(&mut r), variant); + } +} + +#[test] +fn checkpoint_kind_encodes_in_sdl_declaration_order() { + let cases = [ + (CheckpointKind::Initial, [0x00, 0x00, 0x00, 0x00]), + (CheckpointKind::ManualSave, [0x01, 0x00, 0x00, 0x00]), + (CheckpointKind::AutoSave, [0x02, 0x00, 0x00, 0x00]), + ]; + for (variant, expected) in cases { + let mut w = Writer::with_capacity(0); + encode_checkpoint_kind(&mut w, variant); + assert_eq!(w.into_vec(), expected, "variant {variant:?}"); + } +} + +#[test] +fn checkpoint_kind_roundtrips_all_variants() { + for variant in [ + CheckpointKind::Initial, + CheckpointKind::ManualSave, + CheckpointKind::AutoSave, + ] { + let mut w = Writer::with_capacity(0); + encode_checkpoint_kind(&mut w, variant); + let bytes = w.into_vec(); + let mut r = Reader::new(&bytes); + assert_eq!(decode_checkpoint_kind(&mut r), variant); + } +} + +#[test] +fn create_buffer_worldline_vars_minimal_matches_ts_spec_bytes() { + // jedit/spec/rope-codec.spec.mjs: 'minimal: bufferKey only, all optionals null' + let bytes = encode_create_buffer_worldline_vars(&CreateBufferWorldlineInput { + buffer_key: "demo.txt".to_string(), + initial_text: None, + projection_path: None, + create_initial_checkpoint: None, + }); + + let expected: Vec = vec![ + // u32 LE length = 8 + 0x08, 0x00, 0x00, 0x00, // "demo.txt" + 0x64, 0x65, 0x6d, 0x6f, 0x2e, 0x74, 0x78, 0x74, // initialText: null + 0x00, // projectionPath: null + 0x00, // createInitialCheckpoint: null + 0x00, + ]; + assert_eq!(bytes, expected); +} + +#[test] +fn create_buffer_worldline_vars_with_present_fields_matches_ts_spec_bytes() { + // jedit/spec/rope-codec.spec.mjs: 'with initialText and createInitialCheckpoint=true' + let bytes = encode_create_buffer_worldline_vars(&CreateBufferWorldlineInput { + buffer_key: "a".to_string(), + initial_text: Some("hello".to_string()), + projection_path: None, + create_initial_checkpoint: Some(true), + }); + + let expected: Vec = vec![ + // bufferKey: "a" + 0x01, 0x00, 0x00, 0x00, b'a', // initialText present + 0x01, // "hello" + 0x05, 0x00, 0x00, 0x00, b'h', b'e', b'l', b'l', b'o', // projectionPath: null + 0x00, // createInitialCheckpoint present + true + 0x01, 0x01, + ]; + assert_eq!(bytes, expected); +} + +#[test] +fn create_buffer_worldline_vars_roundtrips() { + let input = CreateBufferWorldlineInput { + buffer_key: "full.ts".to_string(), + initial_text: Some("export const x = 1;".to_string()), + projection_path: Some("/src".to_string()), + create_initial_checkpoint: Some(false), + }; + let bytes = encode_create_buffer_worldline_vars(&input); + let mut r = Reader::new(&bytes); + let decoded = decode_create_buffer_worldline_input(&mut r); + assert_eq!(decoded, input); +} + +#[test] +fn replace_range_as_tick_vars_start_byte_lands_after_two_string_prefixes() { + // jedit/spec/rope-codec.spec.mjs: 'startByte and endByte are i32 LE (check wire layout for byte 0)' + let bytes = encode_replace_range_as_tick_vars(&ReplaceRangeAsTickInput { + worldline_id: "w".to_string(), + base_head_id: "b".to_string(), + start_byte: 0, + end_byte: 1, + insert_text: String::new(), + author: None, + }); + + // worldlineId: u32 LE (1) + "w" => 5 bytes + // baseHeadId: u32 LE (1) + "b" => 5 bytes + // start_byte starts at offset 10. + let start_byte_offset = (4 + 1) + (4 + 1); + let read_i32 = |off: usize| -> i32 { + let mut bytes_at = [0u8; 4]; + bytes_at.copy_from_slice(&bytes[off..off + 4]); + i32::from_le_bytes(bytes_at) + }; + assert_eq!( + read_i32(start_byte_offset), + 0, + "startByte should encode as 0" + ); + assert_eq!( + read_i32(start_byte_offset + 4), + 1, + "endByte should encode as 1" + ); +} + +#[test] +fn replace_range_as_tick_vars_roundtrips_with_optional_author() { + let input = ReplaceRangeAsTickInput { + worldline_id: "wl-002".to_string(), + base_head_id: "hd-002".to_string(), + start_byte: 10, + end_byte: 20, + insert_text: "replacement".to_string(), + author: Some("james".to_string()), + }; + let bytes = encode_replace_range_as_tick_vars(&input); + let mut r = Reader::new(&bytes); + let decoded = decode_replace_range_as_tick_input(&mut r); + assert_eq!(decoded, input); +} + +#[test] +fn replace_range_as_tick_vars_minimal_matches_pinned_bytes() { + // Local-roundtrip alone won't catch encoder/decoder drift — pin literal + // wire bytes the same way create_buffer_worldline does. This vector is + // also the wire image that any TS-side rope-codec spec must produce for + // the same input; keep both sides in lockstep. + let bytes = encode_replace_range_as_tick_vars(&ReplaceRangeAsTickInput { + worldline_id: "w".to_string(), + base_head_id: "b".to_string(), + start_byte: 0, + end_byte: 1, + insert_text: String::new(), + author: None, + }); + let expected: Vec = vec![ + // worldlineId: u32 LE length = 1, "w" + 0x01, 0x00, 0x00, 0x00, b'w', // baseHeadId: u32 LE length = 1, "b" + 0x01, 0x00, 0x00, 0x00, b'b', // startByte: i32 LE = 0 + 0x00, 0x00, 0x00, 0x00, // endByte: i32 LE = 1 + 0x01, 0x00, 0x00, 0x00, // insertText: u32 LE length = 0 + 0x00, 0x00, 0x00, 0x00, // author: null + 0x00, + ]; + assert_eq!(bytes, expected); + // Decoder must accept the literal vector and produce the original input. + let mut r = Reader::new(&expected); + let decoded = decode_replace_range_as_tick_input(&mut r); + assert_eq!( + decoded, + ReplaceRangeAsTickInput { + worldline_id: "w".to_string(), + base_head_id: "b".to_string(), + start_byte: 0, + end_byte: 1, + insert_text: String::new(), + author: None, + } + ); +} + +#[test] +fn replace_range_as_tick_vars_with_author_matches_pinned_bytes() { + let bytes = encode_replace_range_as_tick_vars(&ReplaceRangeAsTickInput { + worldline_id: "wl-002".to_string(), + base_head_id: "hd-002".to_string(), + start_byte: 10, + end_byte: 20, + insert_text: "replacement".to_string(), + author: Some("james".to_string()), + }); + let expected: Vec = vec![ + // worldlineId: len=6, "wl-002" + 0x06, 0x00, 0x00, 0x00, b'w', b'l', b'-', b'0', b'0', b'2', + // baseHeadId: len=6, "hd-002" + 0x06, 0x00, 0x00, 0x00, b'h', b'd', b'-', b'0', b'0', b'2', // startByte: 10 + 0x0a, 0x00, 0x00, 0x00, // endByte: 20 + 0x14, 0x00, 0x00, 0x00, // insertText: len=11, "replacement" + 0x0b, 0x00, 0x00, 0x00, b'r', b'e', b'p', b'l', b'a', b'c', b'e', b'm', b'e', b'n', b't', + // author: present + len=5 + "james" + 0x01, 0x05, 0x00, 0x00, 0x00, b'j', b'a', b'm', b'e', b's', + ]; + assert_eq!(bytes, expected); +} + +#[test] +fn create_checkpoint_vars_roundtrips_with_manual_save_and_label() { + let input = CreateCheckpointInput { + worldline_id: "wl-001".to_string(), + kind: CheckpointKind::ManualSave, + label: Some("before refactor".to_string()), + }; + let bytes = encode_create_checkpoint_vars(&input); + let mut r = Reader::new(&bytes); + let decoded = decode_create_checkpoint_input(&mut r); + assert_eq!(decoded, input); +} + +#[test] +fn create_checkpoint_vars_roundtrips_with_auto_save_and_no_label() { + let input = CreateCheckpointInput { + worldline_id: "wl-001".to_string(), + kind: CheckpointKind::AutoSave, + label: None, + }; + let bytes = encode_create_checkpoint_vars(&input); + let mut r = Reader::new(&bytes); + let decoded = decode_create_checkpoint_input(&mut r); + assert_eq!(decoded, input); +} + +#[test] +fn create_checkpoint_vars_manual_save_with_label_matches_pinned_bytes() { + let bytes = encode_create_checkpoint_vars(&CreateCheckpointInput { + worldline_id: "wl-001".to_string(), + kind: CheckpointKind::ManualSave, + label: Some("before refactor".to_string()), + }); + let expected: Vec = vec![ + // worldlineId: len=6, "wl-001" + 0x06, 0x00, 0x00, 0x00, b'w', b'l', b'-', b'0', b'0', b'1', + // kind: MANUAL_SAVE = u32 LE 1 + 0x01, 0x00, 0x00, 0x00, // label: present + len=15 + "before refactor" + 0x01, 0x0f, 0x00, 0x00, 0x00, b'b', b'e', b'f', b'o', b'r', b'e', b' ', b'r', b'e', b'f', + b'a', b'c', b't', b'o', b'r', + ]; + assert_eq!(bytes, expected); + let mut r = Reader::new(&expected); + assert_eq!( + decode_create_checkpoint_input(&mut r), + CreateCheckpointInput { + worldline_id: "wl-001".to_string(), + kind: CheckpointKind::ManualSave, + label: Some("before refactor".to_string()), + } + ); +} + +#[test] +fn create_checkpoint_vars_auto_save_no_label_matches_pinned_bytes() { + let bytes = encode_create_checkpoint_vars(&CreateCheckpointInput { + worldline_id: "wl-001".to_string(), + kind: CheckpointKind::AutoSave, + label: None, + }); + let expected: Vec = vec![ + // worldlineId: len=6, "wl-001" + 0x06, 0x00, 0x00, 0x00, b'w', b'l', b'-', b'0', b'0', b'1', + // kind: AUTO_SAVE = u32 LE 2 + 0x02, 0x00, 0x00, 0x00, // label: null + 0x00, + ]; + assert_eq!(bytes, expected); + let mut r = Reader::new(&expected); + assert_eq!( + decode_create_checkpoint_input(&mut r), + CreateCheckpointInput { + worldline_id: "wl-001".to_string(), + kind: CheckpointKind::AutoSave, + label: None, + } + ); +} diff --git a/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary_eint.rs b/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary_eint.rs new file mode 100644 index 00000000..b9ddccc2 --- /dev/null +++ b/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary_eint.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +#![allow(clippy::unwrap_used, clippy::expect_used)] +//! Cross-boundary fixture proofs for the EINT envelope. +//! +//! These tests assert that `echo_wasm_abi::pack_intent_v1` produces byte +//! sequences that are bytewise identical to the literal hex vectors asserted +//! by `jedit/spec/eint.spec.mjs`. The hex literals here MUST stay in lockstep +//! with that TS spec — they are the cross-boundary contract for the EINT +//! envelope itself (the wrapper that carries LE-binary vars across the WASM +//! boundary). + +use echo_wasm_abi::pack_intent_v1; + +#[test] +fn pack_intent_v1_op1_three_byte_vars_matches_ts_spec() { + let bytes = pack_intent_v1(1, &[0x01, 0x02, 0x03]).unwrap(); + let expected: Vec = vec![ + 0x45, 0x49, 0x4e, 0x54, // "EINT" + 0x01, 0x00, 0x00, 0x00, // op_id = 1, u32 LE + 0x03, 0x00, 0x00, 0x00, // vars_len = 3 + 0x01, 0x02, 0x03, // vars + ]; + assert_eq!(bytes, expected); +} + +#[test] +fn pack_intent_v1_deadbeef_empty_vars_matches_ts_spec() { + let bytes = pack_intent_v1(0xdead_beef, &[]).unwrap(); + let expected: Vec = vec![ + 0x45, 0x49, 0x4e, 0x54, // "EINT" + 0xef, 0xbe, 0xad, 0xde, // op_id = 0xdeadbeef LE + 0x00, 0x00, 0x00, 0x00, // vars_len = 0 + ]; + assert_eq!(bytes, expected); +} + +#[test] +fn pack_intent_v1_op_one_empty_vars_matches_ts_spec() { + let bytes = pack_intent_v1(1, &[]).unwrap(); + let expected: Vec = vec![ + 0x45, 0x49, 0x4e, 0x54, // "EINT" + 0x01, 0x00, 0x00, 0x00, // op_id = 1 + 0x00, 0x00, 0x00, 0x00, // vars_len = 0 + ]; + assert_eq!(bytes, expected); +} diff --git a/crates/echo-wesley-gen/src/main.rs b/crates/echo-wesley-gen/src/main.rs index 845c0319..a91db2c2 100644 --- a/crates/echo-wesley-gen/src/main.rs +++ b/crates/echo-wesley-gen/src/main.rs @@ -22,7 +22,7 @@ mod ir; use ir::{OpKind, TypeKind, WesleyIR}; const ECHO_IR_VERSION: &str = "echo-ir/v1"; -const DEFAULT_CODEC_ID: &str = "cbor-canon-v1"; +const DEFAULT_CODEC_ID: &str = "le-binary-v1"; const DEFAULT_REGISTRY_VERSION: u32 = 1; const RESERVED_CONTROL_OP_ID: u32 = u32::MAX; const WESLEY_CORE_VERSION: &str = "0.0.4"; @@ -61,7 +61,7 @@ fn main() -> Result<()> { bail!("--contract-host requires std and cannot be combined with --no-std"); } - let ir = if let Some(schema_path) = &args.schema { + let mut ir = if let Some(schema_path) = &args.schema { let schema_sdl = std::fs::read_to_string(schema_path)?; echo_ir_from_schema_sdl(&schema_sdl)? } else { @@ -72,6 +72,13 @@ fn main() -> Result<()> { validate_version(&ir)?; ir }; + // Normalize codec_id to the canonical value BEFORE any artifact hash, + // observer identity, or footprint certificate preimage is computed. + // Otherwise an IR that declares e.g. "cbor-canon-v1" would carry that + // value into the preimage while the emitted CODEC_ID const advertises + // "le-binary-v1", so the artifact would claim the new wire contract + // under a hash derived from the old one. + ir.codec_id = Some(DEFAULT_CODEC_ID.to_string()); let code = generate_rust(&ir, &args)?; @@ -176,6 +183,28 @@ fn operation_type_rank(operation_type: wesley_core::OperationType) -> u8 { } } +/// FNV-1 32-bit op id derivation. Despite an earlier "1a" misnomer in this +/// file, the actual byte step multiplies first and then xors (`hash * +/// prime ^ byte`), which is FNV-1, not FNV-1a (`(hash ^ byte) * prime`). +/// The cross-language op-id contract — including the pinned vectors in +/// `stable_op_id_pinned` — is locked to this FNV-1 ordering. Must stay +/// bytewise identical to `wesley_core::stable_op_id` (added in wesley-core +/// ≥0.0.5). The duplication will collapse when echo bumps its wesley-core +/// dependency to 0.0.5+; until then both copies are pinned to the same +/// outputs in unit tests. +fn stable_op_id(operation_type: &wesley_core::OperationType, field_name: &str) -> u32 { + let mut hash = 2_166_136_261_u32; + hash = fnv1_step(hash, operation_type_rank(*operation_type)); + for byte in field_name.as_bytes() { + hash = fnv1_step(hash, *byte); + } + hash +} + +fn fnv1_step(hash: u32, byte: u8) -> u32 { + hash.wrapping_mul(16_777_619) ^ u32::from(byte) +} + fn op_kind_from_wesley(operation_type: wesley_core::OperationType) -> OpKind { match operation_type { wesley_core::OperationType::Query | wesley_core::OperationType::Subscription => { @@ -214,19 +243,6 @@ fn type_kind_from_wesley(type_kind: wesley_core::TypeKind) -> TypeKind { } } -fn stable_op_id(operation_type: &wesley_core::OperationType, field_name: &str) -> u32 { - let mut hash = 2_166_136_261_u32; - hash = fnv1a_step(hash, operation_type_rank(*operation_type)); - for byte in field_name.as_bytes() { - hash = fnv1a_step(hash, *byte); - } - hash -} - -fn fnv1a_step(hash: u32, byte: u8) -> u32 { - hash.wrapping_mul(16_777_619) ^ u32::from(byte) -} - fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { validate_operation_ids(ir)?; validate_generated_item_names(ir)?; @@ -243,8 +259,13 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { }); } + // Bring the codec traits into scope at the top of the generated module + // so Encode/Decode impl bodies can call .encode(w) / Type::decode(r) on + // nested user types via method/associated-fn syntax without needing a + // fully-qualified path at every call site. tokens.extend(quote! { use serde::{Serialize, Deserialize}; + use echo_wasm_abi::codec::{Decode as _, Encode as _}; }); if args.minicbor { @@ -255,7 +276,7 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { // Metadata constants let schema_sha = ir.schema_sha256.as_deref().unwrap_or(""); - let codec_id = ir.codec_id.as_deref().unwrap_or("cbor-canon-v1"); + let codec_id = DEFAULT_CODEC_ID; let registry_version = ir.registry_version.unwrap_or(1); let generated_rust_artifact_hash = generated_rust_artifact_hash(ir, args)?; let wesley_generator_version = format!("echo-wesley-gen/{}", env!("CARGO_PKG_VERSION")); @@ -280,7 +301,7 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { match type_def.kind { TypeKind::Enum => { - let variants = type_def.values.iter().map(|v| safe_ident(v)); + let variants: Vec<_> = type_def.values.iter().map(|v| safe_ident(v)).collect(); tokens.extend(quote! { #[derive(#derives, Copy, Eq)] #[cbor(index_only)] @@ -288,8 +309,99 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { #(#variants),* } }); + // Emit LE binary Encode/Decode for Enum types. + let variant_arms = type_def.values.iter().enumerate().map(|(i, _v)| { + let variant = safe_ident(&type_def.values[i]); + // Safety: realistic enums have far fewer than u32::MAX variants. + #[allow(clippy::cast_possible_truncation)] + let discriminant = i as u32; + quote! { #discriminant => Ok(Self::#variant) } + }); + // Safety: realistic enums have far fewer than u32::MAX variants. + #[allow(clippy::cast_possible_truncation)] + let num_variants = type_def.values.len() as u32; + tokens.extend(quote! { + impl echo_wasm_abi::codec::Encode for #name { + fn encode(&self, w: &mut echo_wasm_abi::codec::Writer) -> Result<(), echo_wasm_abi::codec::CodecError> { + w.write_u32_le(*self as u32); + Ok(()) + } + } + impl echo_wasm_abi::codec::Decode for #name { + fn decode(r: &mut echo_wasm_abi::codec::Reader<'_>) -> Result { + let discriminant = r.read_u32_le()?; + match discriminant { + #(#variant_arms,)* + _ => Err(echo_wasm_abi::codec::CodecError::InvalidEnum), + } + } + } + }); + // Reject discriminant values >= num_variants to ensure exhaustive match. + let _ = num_variants; } - TypeKind::Object | TypeKind::InputObject => { + TypeKind::InputObject => { + let fields = type_def.fields.iter().enumerate().map(|(i, f)| { + let field_name = safe_ident(&f.name); + let base_ty = map_type(&f.type_name, args); + let list_ty: TokenStream = if f.list { + quote! { Vec<#base_ty> } + } else { + quote! { #base_ty } + }; + + let field_tokens = if f.required { + quote! { pub #field_name: #list_ty } + } else { + quote! { pub #field_name: Option<#list_ty> } + }; + + if args.minicbor { + let idx = i as u64; + quote! { + #[n(#idx)] + #field_tokens + } + } else { + field_tokens + } + }); + + tokens.extend(quote! { + #[derive(#derives)] + pub struct #name { + #(#fields),* + } + }); + + // Emit LE binary Encode/Decode for InputObject types. + let encode_stmts: Vec = type_def + .fields + .iter() + .map(|f| encode_field_stmt(f, args)) + .collect(); + let decode_fields: Vec = type_def + .fields + .iter() + .map(|f| decode_field_expr(f, args)) + .collect(); + tokens.extend(quote! { + impl echo_wasm_abi::codec::Encode for #name { + fn encode(&self, w: &mut echo_wasm_abi::codec::Writer) -> Result<(), echo_wasm_abi::codec::CodecError> { + #(#encode_stmts)* + Ok(()) + } + } + impl echo_wasm_abi::codec::Decode for #name { + fn decode(r: &mut echo_wasm_abi::codec::Reader<'_>) -> Result { + Ok(Self { + #(#decode_fields),* + }) + } + } + }); + } + TypeKind::Object => { let fields = type_def.fields.iter().enumerate().map(|(i, f)| { let field_name = safe_ident(&f.name); let base_ty = map_type(&f.type_name, args); @@ -483,6 +595,14 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { }); } + // Vars Encode/Decode impls inside __echo_wesley_generated call + // .encode(w) / Type::decode(r) on user-defined types that live in + // the parent module; without these imports the trait methods are + // unresolvable from inside the private module. + helper_prelude.extend(quote! { + use echo_wasm_abi::codec::{Decode as _, Encode as _}; + }); + let has_query_ops = ir.ops.iter().any(|op| op.kind == OpKind::Query); let has_mutation_ops = ir.ops.iter().any(|op| op.kind == OpKind::Mutation); @@ -520,8 +640,8 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { /// Error produced while building a generated EINT intent. #[derive(Debug)] pub enum GeneratedIntentError { - /// Operation vars could not be encoded canonically. - EncodeVars(echo_wasm_abi::CanonError), + /// Operation vars could not be encoded. + EncodeVars(echo_wasm_abi::codec::CodecError), /// Encoded vars could not be packed into an EINT envelope. PackEnvelope(echo_wasm_abi::EnvelopeError), } @@ -550,33 +670,59 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { let helper_name_string = to_snake_case(&op.name); let helper_name = format_ident!("{}", helper_name_string); let vars_name = format_ident!("{}Vars", to_pascal_case(&op.name)); - let vars_fields = op.args.iter().map(|a| { - let field_name = safe_ident(&a.name); - let base_ty = map_helper_type(&a.type_name, args); - let list_ty: TokenStream = if a.list { - quote! { Vec<#base_ty> } - } else { - quote! { #base_ty } - }; + let vars_fields: Vec = op + .args + .iter() + .map(|a| { + let field_name = safe_ident(&a.name); + let base_ty = map_helper_type(&a.type_name, args); + let list_ty: TokenStream = if a.list { + quote! { Vec<#base_ty> } + } else { + quote! { #base_ty } + }; - if a.required { - quote! { pub #field_name: #list_ty } - } else { - quote! { pub #field_name: Option<#list_ty> } - } - }); + if a.required { + quote! { pub #field_name: #list_ty } + } else { + quote! { pub #field_name: Option<#list_ty> } + } + }) + .collect(); let encode_fn_name = format_ident!("encode_{}_vars", helper_name); helper_exports.push(encode_fn_name.clone()); + + // Encode/Decode impls for the Vars struct (use super:: for user-defined types). + let vars_encode_stmts: Vec = + op.args.iter().map(|a| encode_arg_stmt(a, args)).collect(); + let vars_decode_fields: Vec = + op.args.iter().map(|a| decode_arg_expr(a, args)).collect(); + helper_tokens.extend(quote! { - /// Canonical vars payload for this generated operation. + /// LE binary vars payload for this generated operation. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct #vars_name { #(#vars_fields),* } - /// Encode this operation's vars using Echo canonical CBOR. - pub fn #encode_fn_name(vars: &#vars_name) -> Result, echo_wasm_abi::CanonError> { - echo_wasm_abi::encode_cbor(vars) + impl echo_wasm_abi::codec::Encode for #vars_name { + fn encode(&self, w: &mut echo_wasm_abi::codec::Writer) -> Result<(), echo_wasm_abi::codec::CodecError> { + #(#vars_encode_stmts)* + Ok(()) + } + } + + impl echo_wasm_abi::codec::Decode for #vars_name { + fn decode(r: &mut echo_wasm_abi::codec::Reader<'_>) -> Result { + Ok(Self { + #(#vars_decode_fields),* + }) + } + } + + /// Encode this operation's vars using the LE binary codec. + pub fn #encode_fn_name(vars: &#vars_name) -> Result, echo_wasm_abi::codec::CodecError> { + echo_wasm_abi::codec::encode_to_vec(vars) } }); match op.kind { @@ -694,7 +840,7 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { /// EINT runtime ingress event. pub fn #contract_vars_fn_name(view: GraphView<'_>, scope: &NodeId) -> Option<#vars_name> { let vars = warp_core::eint_vars_for_op(view, scope, super::#const_name)?; - echo_wasm_abi::decode_cbor(vars).ok() + echo_wasm_abi::codec::decode_from_bytes(vars).ok() } /// Base footprint for reading this mutation's runtime ingress event. @@ -745,7 +891,7 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { helper_exports.push(optic_raw_fn_name.clone()); helper_tokens.extend(quote! { /// Encode this query's vars and build a frontier query-view observation request. - pub fn #fn_name(worldline_id: WorldlineId, vars: &#vars_name) -> Result { + pub fn #fn_name(worldline_id: WorldlineId, vars: &#vars_name) -> Result { let vars_bytes = #encode_fn_name(vars)?; Ok(#raw_fn_name(worldline_id, &vars_bytes)) } @@ -777,7 +923,7 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { reducer_version: Option, budget: OpticReadBudget, vars: &#vars_name, - ) -> Result { + ) -> Result { let vars_bytes = #encode_fn_name(vars)?; Ok(#optic_raw_fn_name( optic_id, @@ -866,8 +1012,8 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { /// Decode this query's generated vars from read-only observer context. pub fn #observer_vars_fn_name( context: &warp_core::ContractQueryObserverContext<'_>, - ) -> Result<#vars_name, echo_wasm_abi::CanonError> { - echo_wasm_abi::decode_cbor(context.vars_bytes) + ) -> Result<#vars_name, echo_wasm_abi::codec::CodecError> { + echo_wasm_abi::codec::decode_from_bytes(context.vars_bytes) } /// Build a read-only `warp-core` query observer for this generated query. @@ -1671,6 +1817,267 @@ fn op_kind_name(kind: &OpKind) -> &'static str { } } +/// Generate a single encode statement for an ArgDefinition inside a Vars struct (`self.field`). +/// +/// User-defined types use `super::TypeName` path convention. +fn encode_arg_stmt(arg: &ir::ArgDefinition, args: &Args) -> TokenStream { + let field_name = safe_ident(&arg.name); + if arg.list { + let inner_encode = scalar_list_element_encoder(&arg.type_name, args); + if arg.required { + return quote! { + w.write_list(&self.#field_name, #inner_encode)?; + }; + } + // Nullable list: Vars field is Option>; must wrap with write_option. + return quote! { + w.write_option(self.#field_name.as_ref(), |w, v| { + w.write_list(v, #inner_encode) + })?; + }; + } + // no_std maps the GraphQL ID scalar to [u8; 32]; encode the raw bytes + // instead of treating it like String. + let id_is_bytes = args.no_std && arg.type_name == "ID"; + if arg.required { + match arg.type_name.as_str() { + "Boolean" => quote! { w.write_bool(self.#field_name); }, + "Int" => quote! { w.write_i32_le(self.#field_name); }, + "Float" => quote! { w.write_f32_le(self.#field_name); }, + "ID" if id_is_bytes => quote! { w.write_bytes(&self.#field_name); }, + "String" | "ID" => quote! { w.write_string(&self.#field_name, usize::MAX)?; }, + _ => quote! { self.#field_name.encode(w)?; }, + } + } else { + match arg.type_name.as_str() { + "Boolean" => quote! { + w.write_option(self.#field_name, |w, v| { w.write_bool(v); Ok(()) })?; + }, + "Int" => quote! { + w.write_option(self.#field_name, |w, v| { w.write_i32_le(v); Ok(()) })?; + }, + "Float" => quote! { + w.write_option(self.#field_name, |w, v| { w.write_f32_le(v); Ok(()) })?; + }, + "ID" if id_is_bytes => quote! { + w.write_option(self.#field_name.as_ref(), |w, v| { w.write_bytes(v); Ok(()) })?; + }, + "String" | "ID" => quote! { + w.write_option(self.#field_name.as_deref(), |w, v| w.write_string(v, usize::MAX))?; + }, + _ => quote! { + w.write_option(self.#field_name.as_ref(), |w, v| v.encode(w))?; + }, + } + } +} + +/// Generate the field initializer for an ArgDefinition inside a Vars Decode impl. +/// +/// User-defined types use `super::TypeName::decode(r)?` path convention. +fn decode_arg_expr(arg: &ir::ArgDefinition, args: &Args) -> TokenStream { + let field_name = safe_ident(&arg.name); + if arg.list { + // Vars Decode impl sits inside the private __echo_wesley_generated + // module; user-defined element types live in the parent module and + // need `super::` qualification. + let inner_decode = + scalar_list_element_decoder(&arg.type_name, args, /* super_qualified */ true); + if arg.required { + return quote! { + #field_name: r.read_list(#inner_decode)? + }; + } + // Nullable list: field is Option>; wrap with read_option. + return quote! { + #field_name: r.read_option(|r| r.read_list(#inner_decode))? + }; + } + let id_is_bytes = args.no_std && arg.type_name == "ID"; + if arg.required { + let expr = match arg.type_name.as_str() { + "Boolean" => quote! { r.read_bool()? }, + "Int" => quote! { r.read_i32_le()? }, + "Float" => quote! { r.read_f32_le()? }, + "ID" if id_is_bytes => quote! { r.read_byte_array::<32>()? }, + "String" | "ID" => quote! { r.read_string(usize::MAX)? }, + _ => { + let ty = safe_ident(&arg.type_name); + quote! { super::#ty::decode(r)? } + } + }; + quote! { #field_name: #expr } + } else { + let expr = match arg.type_name.as_str() { + "Boolean" => quote! { r.read_option(|r| r.read_bool())? }, + "Int" => quote! { r.read_option(|r| r.read_i32_le())? }, + "Float" => quote! { r.read_option(|r| r.read_f32_le())? }, + "ID" if id_is_bytes => quote! { r.read_option(|r| r.read_byte_array::<32>())? }, + "String" | "ID" => quote! { r.read_option(|r| r.read_string(usize::MAX))? }, + _ => { + let ty = safe_ident(&arg.type_name); + quote! { r.read_option(|r| super::#ty::decode(r))? } + } + }; + quote! { #field_name: #expr } + } +} + +/// Generate a single encode statement for a FieldDefinition on a named struct (`self.field`). +/// +/// Uses the LE binary codec primitives from `echo_wasm_abi::codec`. +fn encode_field_stmt(field: &ir::FieldDefinition, args: &Args) -> TokenStream { + let field_name = safe_ident(&field.name); + if field.list { + let inner_encode = scalar_list_element_encoder(&field.type_name, args); + if field.required { + // [T!]! required list + return quote! { + w.write_list(&self.#field_name, #inner_encode)?; + }; + } + // [T!] nullable list: field is Option>; wrap with write_option. + return quote! { + w.write_option(self.#field_name.as_ref(), |w, v| { + w.write_list(v, #inner_encode) + })?; + }; + } + let id_is_bytes = args.no_std && field.type_name == "ID"; + if field.required { + // Required (non-nullable) scalar or user type + match field.type_name.as_str() { + "Boolean" => quote! { w.write_bool(self.#field_name); }, + "Int" => quote! { w.write_i32_le(self.#field_name); }, + "Float" => quote! { w.write_f32_le(self.#field_name); }, + "ID" if id_is_bytes => quote! { w.write_bytes(&self.#field_name); }, + "String" | "ID" => quote! { w.write_string(&self.#field_name, usize::MAX)?; }, + _ => { + // User-defined type — delegate to its Encode impl + quote! { self.#field_name.encode(w)?; } + } + } + } else { + // Nullable (Option) + match field.type_name.as_str() { + "Boolean" => quote! { + w.write_option(self.#field_name, |w, v| { w.write_bool(v); Ok(()) })?; + }, + "Int" => quote! { + w.write_option(self.#field_name, |w, v| { w.write_i32_le(v); Ok(()) })?; + }, + "Float" => quote! { + w.write_option(self.#field_name, |w, v| { w.write_f32_le(v); Ok(()) })?; + }, + "ID" if id_is_bytes => quote! { + w.write_option(self.#field_name.as_ref(), |w, v| { w.write_bytes(v); Ok(()) })?; + }, + "String" | "ID" => quote! { + w.write_option(self.#field_name.as_deref(), |w, v| w.write_string(v, usize::MAX))?; + }, + _ => { + // User-defined type — delegate to its Encode impl + quote! { + w.write_option(self.#field_name.as_ref(), |w, v| v.encode(w))?; + } + } + } + } +} + +/// Generate the field initializer for a FieldDefinition inside a Decode impl (`field_name: ...`). +fn decode_field_expr(field: &ir::FieldDefinition, args: &Args) -> TokenStream { + let field_name = safe_ident(&field.name); + if field.list { + // FieldDefinition Decode impls live alongside the user types + // themselves; no `super::` qualification needed. + let inner_decode = + scalar_list_element_decoder(&field.type_name, args, /* super_qualified */ false); + if field.required { + return quote! { + #field_name: r.read_list(#inner_decode)? + }; + } + return quote! { + #field_name: r.read_option(|r| r.read_list(#inner_decode))? + }; + } + let id_is_bytes = args.no_std && field.type_name == "ID"; + if field.required { + let expr = match field.type_name.as_str() { + "Boolean" => quote! { r.read_bool()? }, + "Int" => quote! { r.read_i32_le()? }, + "Float" => quote! { r.read_f32_le()? }, + "ID" if id_is_bytes => quote! { r.read_byte_array::<32>()? }, + "String" | "ID" => quote! { r.read_string(usize::MAX)? }, + _ => { + let ty = safe_ident(&field.type_name); + quote! { #ty::decode(r)? } + } + }; + quote! { #field_name: #expr } + } else { + let expr = match field.type_name.as_str() { + "Boolean" => quote! { r.read_option(|r| r.read_bool())? }, + "Int" => quote! { r.read_option(|r| r.read_i32_le())? }, + "Float" => quote! { r.read_option(|r| r.read_f32_le())? }, + "ID" if id_is_bytes => quote! { r.read_option(|r| r.read_byte_array::<32>())? }, + "String" | "ID" => quote! { r.read_option(|r| r.read_string(usize::MAX))? }, + _ => { + let ty = safe_ident(&field.type_name); + quote! { r.read_option(|r| #ty::decode(r))? } + } + }; + quote! { #field_name: #expr } + } +} + +/// Generate a list element encoder closure for `write_list`. +fn scalar_list_element_encoder(type_name: &str, args: &Args) -> TokenStream { + // no_std maps GraphQL ID to [u8; 32]; encoder must emit raw bytes, not + // treat the element as &String. + let id_is_bytes = args.no_std && type_name == "ID"; + match type_name { + "Boolean" => quote! { |w, v| { w.write_bool(*v); Ok(()) } }, + "Int" => quote! { |w, v| { w.write_i32_le(*v); Ok(()) } }, + "Float" => quote! { |w, v| { w.write_f32_le(*v); Ok(()) } }, + "ID" if id_is_bytes => quote! { |w, v| { w.write_bytes(v); Ok(()) } }, + "String" | "ID" => quote! { |w, v| w.write_string(v.as_str(), usize::MAX) }, + _ => quote! { |w, v| v.encode(w) }, + } +} + +/// Generate a list element decoder closure for `read_list`. +/// +/// `super_qualified` matters for user-defined element types: Vars Decode +/// impls are emitted inside the `__echo_wesley_generated` private module +/// (so user types live in the parent and need `super::`), but +/// FieldDefinition Decode impls are emitted alongside the user types +/// themselves (so `super::` would over-qualify). The non-list scalar +/// decoders pick the right form already; this helper had been emitting a +/// bare `#ty::decode(r)` regardless, so list-of-user-types under Vars +/// failed to compile (`tags: [Tag!]!` → `Tag::decode` unresolved). +fn scalar_list_element_decoder(type_name: &str, args: &Args, super_qualified: bool) -> TokenStream { + // no_std maps GraphQL ID to [u8; 32]; decoder must read a fixed-size + // byte array, not a length-prefixed String. + let id_is_bytes = args.no_std && type_name == "ID"; + match type_name { + "Boolean" => quote! { |r| r.read_bool() }, + "Int" => quote! { |r| r.read_i32_le() }, + "Float" => quote! { |r| r.read_f32_le() }, + "ID" if id_is_bytes => quote! { |r| r.read_byte_array::<32>() }, + "String" | "ID" => quote! { |r| r.read_string(usize::MAX) }, + _ => { + let ty = safe_ident(type_name); + if super_qualified { + quote! { |r| super::#ty::decode(r) } + } else { + quote! { |r| #ty::decode(r) } + } + } + } +} + /// Map a GraphQL base type name to a Rust type used in generated DTOs. /// /// GraphQL `Float` intentionally maps to `f32` (not `f64`) so generated types @@ -1715,3 +2122,37 @@ fn map_helper_type(gql_type: &str, args: &Args) -> TokenStream { } } } + +#[cfg(test)] +mod stable_op_id_pinned { + use super::stable_op_id; + use wesley_core::OperationType; + + /// These u32 outputs are the cross-language contract surface. They MUST + /// stay bytewise identical to `wesley_core::stable_op_id` and to every + /// TypeScript / WASM consumer that routes EINT envelopes by op id. If a + /// value changes here, every contract that uses that op id breaks. + #[test] + fn rope_operation_op_ids_are_pinned() { + assert_eq!( + stable_op_id(&OperationType::Mutation, "createBufferWorldline"), + 2_519_122_874 + ); + assert_eq!( + stable_op_id(&OperationType::Mutation, "replaceRangeAsTick"), + 3_329_158_538 + ); + assert_eq!( + stable_op_id(&OperationType::Mutation, "createCheckpoint"), + 3_744_251_216 + ); + assert_eq!( + stable_op_id(&OperationType::Query, "worldlineSnapshot"), + 3_219_688_859 + ); + assert_eq!( + stable_op_id(&OperationType::Query, "textWindow"), + 2_414_231_278 + ); + } +} diff --git a/crates/echo-wesley-gen/tests/fixtures/toy-counter/echo-ir-v1.json b/crates/echo-wesley-gen/tests/fixtures/toy-counter/echo-ir-v1.json index c6690708..37fc248e 100644 --- a/crates/echo-wesley-gen/tests/fixtures/toy-counter/echo-ir-v1.json +++ b/crates/echo-wesley-gen/tests/fixtures/toy-counter/echo-ir-v1.json @@ -1,7 +1,7 @@ { "ir_version": "echo-ir/v1", "schema_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "codec_id": "cbor-canon-v1", + "codec_id": "le-binary-v1", "registry_version": 1, "types": [ { diff --git a/crates/echo-wesley-gen/tests/generation.rs b/crates/echo-wesley-gen/tests/generation.rs index 8dd4aa63..764cd9ca 100644 --- a/crates/echo-wesley-gen/tests/generation.rs +++ b/crates/echo-wesley-gen/tests/generation.rs @@ -109,7 +109,7 @@ type Mutation { assert!(stdout.contains("pub struct CounterValue")); assert!(stdout.contains("pub struct IncrementInput")); assert!(stdout.contains("pub const ECHO_CONTRACT_ABI_VERSION: u32 = 1")); - assert!(stdout.contains("pub const CODEC_ID: &str = \"cbor-canon-v1\"")); + assert!(stdout.contains("pub const CODEC_ID: &str = \"le-binary-v1\"")); assert!(stdout.contains("pub const REGISTRY_VERSION: u32 = 1")); assert!(stdout.contains("pub const WESLEY_GENERATOR_VERSION: &str = \"echo-wesley-gen/0.1.0\"")); assert!(stdout.contains("pub const CONTRACT_HOST_HELPER_API_VERSION: u32 = 1")); @@ -237,6 +237,7 @@ mod tests { SchedulerState, SchedulerStatus, WorkState, WorldlineId, WorldlineTick, ABI_VERSION, }; use echo_wasm_abi::{decode_cbor, encode_cbor, unpack_intent_v1}; + use echo_wasm_abi::codec::decode_from_bytes; #[derive(Default)] struct ToyKernel { @@ -266,7 +267,7 @@ mod tests { message: error.to_string(), })?; assert_eq!(op_id, OP_INCREMENT); - let decoded: IncrementVars = decode_cbor(vars).map_err(|error| AbiError { + let decoded: IncrementVars = decode_from_bytes(vars).map_err(|error| AbiError { code: 2, message: error.to_string(), })?; @@ -295,7 +296,7 @@ mod tests { }; assert_eq!(*query_id, OP_COUNTER_VALUE); let _decoded: CounterValueVars = - decode_cbor(vars_bytes).map_err(|error| AbiError { + decode_from_bytes(vars_bytes).map_err(|error| AbiError { code: 3, message: error.to_string(), })?; @@ -428,7 +429,7 @@ mod tests { .unwrap(); let (op_id, vars) = unpack_intent_v1(&intent).unwrap(); assert_eq!(op_id, OP_INCREMENT); - let decoded: IncrementVars = decode_cbor(vars).unwrap(); + let decoded: IncrementVars = decode_from_bytes(vars).unwrap(); assert_eq!(decoded.input.amount, 42); let mut kernel = ToyKernel::default(); @@ -547,7 +548,7 @@ mod tests { let OpticIntentPayload::EintV1 { bytes } = &dispatch.payload; let (op_id, vars_bytes) = unpack_intent_v1(bytes).unwrap(); assert_eq!(op_id, OP_INCREMENT); - let decoded: IncrementVars = decode_cbor(vars_bytes).unwrap(); + let decoded: IncrementVars = decode_from_bytes(vars_bytes).unwrap(); assert_eq!(decoded.input.amount, 42); } @@ -1301,7 +1302,7 @@ fn test_reserved_control_op_id_fails_closed() { let ir = r#"{ "ir_version": "echo-ir/v1", "schema_sha256": "abc123", - "codec_id": "cbor-canon-v1", + "codec_id": "le-binary-v1", "registry_version": 1, "types": [ { "name": "RunStatus", "kind": "OBJECT", "fields": [ @@ -1354,7 +1355,7 @@ fn test_generate_from_json() { let ir = r#"{ "ir_version": "echo-ir/v1", "schema_sha256": "abc123", - "codec_id": "cbor-canon-v1", + "codec_id": "le-binary-v1", "registry_version": 7, "types": [ { @@ -1404,7 +1405,7 @@ fn test_generate_from_json() { assert!(stdout.contains("pub theme: Theme")); assert!(stdout.contains("pub tags: Option>")); assert!(stdout.contains("pub const SCHEMA_SHA256: &str = \"abc123\"")); - assert!(stdout.contains("pub const CODEC_ID: &str = \"cbor-canon-v1\"")); + assert!(stdout.contains("pub const CODEC_ID: &str = \"le-binary-v1\"")); assert!(stdout.contains("pub const REGISTRY_VERSION: u32 = 7")); assert!(stdout.contains("pub const OP_SET_THEME: u32 = 111")); assert!(stdout.contains("pub const OP_APP_STATE: u32 = 222")); @@ -1531,7 +1532,7 @@ fn test_footprint_artifact_hash_changes_when_generated_args_change() { let without_arg = r#"{ "ir_version": "echo-ir/v1", "schema_sha256": "abc123", - "codec_id": "cbor-canon-v1", + "codec_id": "le-binary-v1", "registry_version": 1, "types": [ { "name": "CounterValue", "kind": "OBJECT", "fields": [ @@ -1557,7 +1558,7 @@ fn test_footprint_artifact_hash_changes_when_generated_args_change() { let with_arg = r#"{ "ir_version": "echo-ir/v1", "schema_sha256": "abc123", - "codec_id": "cbor-canon-v1", + "codec_id": "le-binary-v1", "registry_version": 1, "types": [ { "name": "CounterValue", "kind": "OBJECT", "fields": [ @@ -1613,7 +1614,7 @@ fn test_query_only_contract_does_not_import_intent_packer() { let ir = r#"{ "ir_version": "echo-ir/v1", "schema_sha256": "abc123", - "codec_id": "cbor-canon-v1", + "codec_id": "le-binary-v1", "registry_version": 1, "types": [], "ops": [ @@ -1642,7 +1643,7 @@ fn test_operation_vars_type_collision_uses_helper_namespace() { let ir = r#"{ "ir_version": "echo-ir/v1", "schema_sha256": "abc123", - "codec_id": "cbor-canon-v1", + "codec_id": "le-binary-v1", "registry_version": 1, "types": [ { @@ -1680,7 +1681,7 @@ fn test_generated_intent_error_user_type_does_not_collide_with_helper_error() { let ir = r#"{ "ir_version": "echo-ir/v1", "schema_sha256": "abc123", - "codec_id": "cbor-canon-v1", + "codec_id": "le-binary-v1", "registry_version": 1, "types": [ { @@ -1718,7 +1719,7 @@ fn test_query_mutation_operation_name_collision_fails_with_clear_diagnostic() { let ir = r#"{ "ir_version": "echo-ir/v1", "schema_sha256": "abc123", - "codec_id": "cbor-canon-v1", + "codec_id": "le-binary-v1", "registry_version": 1, "types": [], "ops": [ @@ -1811,6 +1812,50 @@ fn test_toy_contract_no_std_generated_output_checks_in_consumer_crate() { )); } +#[test] +fn test_no_std_id_list_field_compiles_in_consumer_crate() { + // Regression: scalar_list_element_{encoder,decoder} used to treat ID as + // String unconditionally, so under --no-std a [ID] field generated + // v.as_str() on [u8; 32] (encode) and read_string into Vec<[u8; 32]> + // (decode), neither of which compile. The list element helpers must + // honor the same no_std ID -> [u8; 32] mapping as the scalar helpers. + let ir = r#"{ + "ir_version": "echo-ir/v1", + "types": [ + { + "name": "TagBag", + "kind": "INPUT_OBJECT", + "fields": [ + { "name": "tags", "type": "ID", "required": true, "list": true }, + { "name": "optional_tags", "type": "ID", "required": false, "list": true } + ] + } + ], + "ops": [] + }"#; + let output = run_wesley_gen_with_args(ir, &["--no-std"]); + assert!( + output.status.success(), + "CLI failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let generated = String::from_utf8_lossy(&output.stdout); + // The generated struct must declare list elements as [u8; 32], not String. + assert!( + generated.contains("pub tags: Vec<[u8; 32]>"), + "expected Vec<[u8; 32]> for required [ID] under no_std, got:\n{generated}" + ); + assert!( + generated.contains("pub optional_tags: Option>"), + "expected Option> for nullable [ID] under no_std, got:\n{generated}" + ); + assert_generated_crate_checks(&write_basic_generated_crate( + generated.as_ref(), + "no-std-id-list", + true, + )); +} + #[test] fn test_contract_host_generation_rejects_no_std() { let output = run_wesley_gen_with_args(TOY_COUNTER_IR, &["--no-std", "--contract-host"]); diff --git a/docs/design/0024-universal-le-binary-codec/design.md b/docs/design/0024-universal-le-binary-codec/design.md new file mode 100644 index 00000000..5c73897a --- /dev/null +++ b/docs/design/0024-universal-le-binary-codec/design.md @@ -0,0 +1,332 @@ + + + +# 0024 — Universal LE Binary Codec + +_Replace per-boundary format diversity with a single deterministic little-endian +binary codec generated from Wesley IR across all serialization boundaries._ + +Legend: `PLATFORM` + +Depends on: + +- `0016 — Wesley-to-Echo toy contract proof` (established echo-wesley-gen pipeline) + +--- + +## Why this cycle exists + +Echo currently uses at least three serialization formats depending on context: + +- **Canonical CBOR** (`cbor-canon-v1`) — generated vars payloads in + `echo-wesley-gen`, encoded via `echo_wasm_abi::encode_cbor`. +- **Custom LE binary** (`echo-wasm-abi::codec`) — lower-level wire framing + (EINT envelopes, TTD protocol). Already has `Writer`/`Reader` with LE + primitives and length-prefixed strings. +- **JSON** — Wesley IR exchange, config, and diagnostics. + +This is inconsistent. An app developer touching the WASM boundary, the WSC +format, and the network layer must understand three different formats and trust +that three separate implementations stay in sync. + +The fix: use the existing `echo-wasm-abi::codec` LE binary format everywhere, +and have **Wesley generate all codec implementations simultaneously from the +same IR**. Because Wesley is the source of truth, drift between Rust, TypeScript, +WSC, and network representations is structurally impossible — not just unlikely. + +--- + +## The core insight + +The standard objection to raw binary formats is that they are not +self-describing: a version mismatch causes silent corruption instead of a +graceful skip. This objection assumes independent implementations that can +drift. Wesley eliminates that assumption. + +```text +hot-text-runtime.graphql + → echo-wesley-gen --rust → Encode/Decode impls (codec.rs primitives) + → echo-wesley-gen --typescript → encode*/decode* functions (matching layout) + → echo-wesley-gen --wsc → WSC codec (future) + → echo-wesley-gen --net → network codec (future) +``` + +One schema compile. All representations emit atomically. Version gating is +handled by including the Wesley `SCHEMA_SHA256` as a prefix on every framed +message — version mismatch is a hard rejection, not silent corruption. + +--- + +## Encoding table (ratified) + +These mappings are canonical. Changing a mapping is a breaking change requiring +a new codec version. + +| GraphQL type | Rust type | Wire encoding | +| ----------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Int` | `i32` | 4 bytes, little-endian signed | +| `Float` | `F32Scalar` | Canonicalize via `F32Scalar::new()` first (flushes subnormals to `+0.0`, canonicalizes NaN to `0x7fc00000`, maps `-0.0` to `+0.0`), then 4 bytes LE bit pattern. TypeScript encoder must apply identical canonicalization before writing. | +| `Boolean` | `bool` | 1 byte: `0x00` = false, `0x01` = true | +| _(fixed-point alt)_ | `DFix64` | Q32.32 representation: `i64 LE` raw value (`real = raw / 2^32`). Used when a schema field is backed by `DFix64` instead of `Float`. Not a GraphQL scalar — a codec-level type. | +| `String`, `ID` | `String` | `u32 LE` byte-length, then UTF-8 bytes (no null term) | +| `T!` (non-null scalar) | `T` | value inline, no wrapper | +| `T` (nullable) | `Option` | `u8` presence tag (`0x00` = null, `0x01` = present), then value if present | +| `[T!]!` (non-null list) | `Vec` | `u32 LE` element count, then elements inline | +| `[T]` (nullable list) | `Option>>` | presence tag, then count, then elements with per-element presence tags | +| `enum E` | `enum E` | `u32 LE` discriminant (variant index in declaration order) | +| `type T` (object/input) | `struct T` | fields encoded in declaration order, no separators | + +**Alignment**: none. Fields are packed with no padding. + +**String max bound**: enforced by the generator based on schema constraints +(default `usize::MAX` / `u32::MAX` bytes for unconstrained fields). + +**Enum discriminant**: zero-based, ordered by SDL declaration order. Every +schema edit — append, insert, reorder, rename — is a breaking change against +peers running a different schema version, because the framing in Lines 206–214 +hard-gates every payload by `SCHEMA_SHA256` and any of those edits perturbs +that hash. The declaration-order rule is therefore a determinism guarantee for +peers on the _same_ schema version, not a compatibility promise across them. + +--- + +## What needs to be added to `codec.rs` + +Current `codec.rs` has: `u8`, `u16`, `u32`, `i64`, length-prefixed bytes, +length-prefixed strings. + +Missing primitives needed for full GraphQL type coverage: + +- `write_i32_le` / `read_i32_le` — for `Int` +- `write_f32_le` / `read_f32_le` — for `Float` +- `write_bool` / `read_bool` — for `Boolean` (u8 0/1) +- `write_option` / `read_option` — presence tag + conditional payload +- `write_list` / `read_list` — u32 LE count + elements + +These are all expressible with existing primitives today, but named helpers +make generated code readable and keep the TS mirror exact. + +--- + +## `echo-wesley-gen` changes + +### Existing (to be replaced) + +```rust +pub fn encode_create_buffer_worldline_vars( + vars: &CreateBufferWorldlineVars, +) -> Result, echo_wasm_abi::CanonError> { + echo_wasm_abi::encode_cbor(vars) // ← CBOR, going away +} +``` + +### Target (Rust emit) + +```rust +impl echo_wasm_abi::codec::Encode for CreateBufferWorldlineVars { + fn encode(&self, w: &mut echo_wasm_abi::codec::Writer) -> Result<(), echo_wasm_abi::codec::CodecError> { + w.write_string(&self.buffer_key, usize::MAX)?; + w.write_option(self.initial_text.as_deref(), |w, v| w.write_string(v, usize::MAX))?; + w.write_option(self.projection_path.as_deref(), |w, v| w.write_string(v, usize::MAX))?; + w.write_bool(self.create_initial_checkpoint.unwrap_or(false)); + Ok(()) + } +} + +pub fn encode_create_buffer_worldline_vars( + vars: &CreateBufferWorldlineVars, +) -> Result, echo_wasm_abi::codec::CodecError> { + echo_wasm_abi::codec::encode_to_vec(vars) +} +``` + +### Target (TypeScript emit) + +```typescript +// Generated by echo-wesley-gen. Do not edit. + +export function encodeCreateBufferWorldlineVars( + vars: CreateBufferWorldlineVars, +): Uint8Array { + const w = new Writer(); + w.writeString(vars.bufferKey); + w.writeOption(vars.initialText ?? null, (w, v) => w.writeString(v)); + w.writeOption(vars.projectionPath ?? null, (w, v) => w.writeString(v)); + w.writeBool(vars.createInitialCheckpoint ?? false); + return w.finish(); +} + +export function decodeCreateBufferWorldlineResult( + bytes: Uint8Array, +): CreateBufferWorldlineResult { + const r = new Reader(bytes); + return { + worldline: decodeBufferWorldline(r), + head: decodeRopeHead(r), + checkpoint: r.readOption(() => decodeCheckpoint(r)), + }; +} +``` + +The TypeScript `Writer`/`Reader` is a ~100-line counterpart to `codec.rs`. + +--- + +## TypeScript `codec.ts` primitive spec + +The TypeScript reader/writer must mirror `codec.rs` exactly. + +```typescript +class Writer { + writeU8(v: number): void; // 1 byte + writeU16Le(v: number): void; // 2 bytes LE + writeU32Le(v: number): void; // 4 bytes LE + writeI32Le(v: number): void; // 4 bytes LE signed + writeF32Le(v: number): void; // canonicalize then 4 bytes LE: NaN→0x7fc00000, subnormal→0, -0→+0 + writeBool(v: boolean): void; // 1 byte (0x00 / 0x01) + writeString(v: string): void; // u32 LE length + UTF-8 + writeOption(v: T | null, fn: (w: Writer, v: T) => void): void; + writeList(vs: T[], fn: (w: Writer, v: T) => void): void; + finish(): Uint8Array; +} + +class Reader { + readU8(): number; + readU16Le(): number; + readU32Le(): number; + readI32Le(): number; + readF32Le(): number; + readBool(): boolean; + readString(): string; + readOption(fn: (r: Reader) => T): T | null; + readList(fn: (r: Reader) => T): T[]; +} +``` + +--- + +## Version / schema gating + +Every framed message (WASM boundary, WSC, network) is prefixed with the 32-byte +`SCHEMA_SHA256` computed by Wesley at compile time. Decoders verify the hash +before reading the payload. Mismatch → hard rejection with an explicit error. + +This is not per-field versioning. It is a hard schema version gate. +Upgrading schema requires regenerating both sides and redeploying together. +For WASM boundary and WSC, that is always already true. For network, it means +clients and servers must be at the same schema version, which is acceptable for +this system. + +--- + +## Human users / jobs / hills + +### Primary human users + +- App developers wiring jedit to Echo. +- Platform engineers maintaining `echo-wesley-gen`. + +### Human jobs + +1. Run `echo-wesley-gen --schema hot-text-runtime.graphql --rust --out src/generated.rs` +2. Run `echo-wesley-gen --schema hot-text-runtime.graphql --typescript --out src/generated.ts` +3. Use the generated encode/decode functions in jedit and in Echo contract handlers. + +### Human hill + +A developer can serialize a jedit operation input in TypeScript and deserialize +it in Rust (or vice versa) without writing any codec code by hand. + +--- + +## Implementation outline + +1. **Extend `codec.rs`** — add `write_i32_le`, `write_f32_le`, `write_bool`, + `write_option`, `write_list` and their `read_*` counterparts. Add tests for + each primitive in both directions. + +2. **Write `codec.ts`** — TypeScript mirror of `codec.rs`. Must be standalone + (no runtime dependencies). Add roundtrip tests for each primitive. + +3. **Add `--typescript` flag to `echo-wesley-gen`** — walking the same IR nodes + as the Rust path, emit TypeScript encode/decode functions using `codec.ts` + primitives. Field order must exactly match the Rust emit. + +4. **Replace CBOR vars encoding** — update the Rust emit path in + `echo-wesley-gen` to generate `Encode`/`Decode` impls instead of + `encode_cbor` calls. Update generated code for existing contracts. + +5. **Add roundtrip fixture tests** — for every operation in + `hot-text-runtime.graphql`, encode a vars struct in Rust, decode it in + TypeScript, encode it back in TypeScript, decode it in Rust. Assert identity. + +--- + +## Tests to write first + +- `codec.rs`: roundtrip for each new primitive (i32, f32, bool, option, list) +- `codec.ts`: roundtrip for each TypeScript primitive +- Cross-boundary: Rust-encoded bytes decode correctly in TypeScript (fixture vectors) +- Cross-boundary: TypeScript-encoded bytes decode correctly in Rust (fixture vectors) +- Generator: `echo-wesley-gen --typescript` emits functions that compile and + roundtrip for the jedit hot-text fixture +- Schema hash gate: mismatched schema hash returns a hard error, not corruption + +--- + +## Risks / unknowns + +- **`f32` canonicalization**: The TypeScript `writeF32Le` must replicate `F32Scalar::new()` + exactly: NaN → `0x7fc00000`, subnormal → `0`, `-0.0` → `+0.0`. + JavaScript's `DataView.setFloat32` writes raw IEEE 754 bits without + canonicalization. The pass must happen before the write. + Roundtrip fixture tests must include NaN, subnormal, `-0.0`, and `+Infinity` + as inputs to verify the TypeScript and Rust paths produce identical bytes. + +- **`f64 → f32` narrowing in TypeScript**: JavaScript has no `f32` type. + All numbers are `f64`. `DataView.setFloat32` silently narrows to the nearest + representable `f32` per IEEE 754, which is deterministic, but any value that + was computed in `f64` arithmetic may narrow differently than the same value + computed in `f32`. Contract: **values passed to `writeF32Le` must already be + exact `f32`-representable values**. The generator must emit this as a caller + contract. Do not feed `f64` intermediate results into `writeF32Le`. + +- **String Unicode normalization**: `TextEncoder` converts JavaScript's internal + UTF-16 to UTF-8 without normalizing. The same logical string (e.g. `"café"`) + has two valid UTF-8 byte sequences depending on NFC vs. NFD normalization, + producing different digests. The codec encodes raw UTF-8 bytes as given — it + does not normalize. Contract: **all strings must be NFC-normalized before + encoding**. The application layer (not the codec) enforces this. This is + especially critical for `insertText` in rewrite payloads where the content + hash must be stable across platforms. + +- **Field encoding order**: The TypeScript encoder emits fields in hardcoded + generator-declared order, not by iterating JavaScript object keys. The + generator must use Wesley IR declaration order for both Rust and TypeScript + emit. If Wesley internally sorts fields for hashing purposes, that sorted + order must never leak into the codec field order. +- **Large string / list bounds**: unconstrained fields default to `u32::MAX`. + A malicious or buggy payload could allocate 4 GiB. The decoder should enforce + a configurable max per decode context before reading. +- **CBOR migration**: existing generated code uses `encode_cbor`. jedit is the + first product; there is no stored data in the old format. Hard cutover with no + migration path needed. + +--- + +## Postures + +- **Accessibility:** Not applicable — this is a wire protocol. +- **Localization:** Not applicable. +- **Agent inspectability:** Generated code must be readable and have doc + comments explaining the field order. An agent inspecting generated code + must be able to reconstruct the wire layout from the source. + +--- + +## Non-goals + +- Replace EINT envelope framing (that already uses LE binary correctly). +- Design a self-describing format (Wesley is the description). +- Support schema evolution / field-skipping (hard version gate is sufficient). +- Generate WSC or network codec in this cycle (Rust + TypeScript first). diff --git a/docs/design/0025-sessions-as-causal-contexts/design.md b/docs/design/0025-sessions-as-causal-contexts/design.md new file mode 100644 index 00000000..0c1ca985 --- /dev/null +++ b/docs/design/0025-sessions-as-causal-contexts/design.md @@ -0,0 +1,901 @@ + + + +# 0025 — Sessions as Causal Contexts + +_Promote Session to a first-class durable causal-context node in Echo — +principal-bound, lifecycle-gated, with a queryable work projection — so +coherent streams of work have a real home in the graph instead of being +mirrored in each client._ + +Legend: `PLATFORM` + +Depends on: + +- `0024 — Universal LE Binary Codec` — the EINT envelope is the wire surface + where session addressing attaches. This cycle branches from + `stack/echo-le-binary-codec` rather than `main`. + +Integrates with (does not replace): + +- `warp-core::playback::SessionId` / `ViewSession` — read/playback-side + session concept; unified under this cycle's `SessionId`. +- `warp-core::head_inbox` (`IngressTarget`, `InboxAddress`, `HeadInbox`) — + worldline-scoped ingress and admission. Session attributes work flowing + through this; it does not introduce a second ingress queue. +- `warp-core::optic_artifact::PrincipalRef` — existing principal identity + surface, used directly as `Session.principal_ref`. +- `Worldline`, `head`, `tick`, `strand`, `braid` — mutation ordering / + state-lineage primitives. Session attributes, it does not reorder. + +--- + +## Why this cycle exists + +The jedit Slice B cutover (commit `26a8f43` on `stack/jedit-rope-rename`) +moved jedit's mutation wire from JSON-with-session to EINT envelopes +carrying only `(op_id, vars)`. To survive that, jedit had to introduce a +client-side `JeditWorldlineSessionPort` — an in-process map of +`worldlineId → session` — because Echo had no name for the thing that +session represented. The in-process transport reads from the same port the +optic client writes to; they cooperate by sharing memory. + +That works because both live in the same process. It does not work across +a real WASM transport, and it forces every client to invent the same +abstraction. The smell is not jedit's; the smell is that Echo lacks a +durable name for the **coherent stream of causal work originating from +some principal**. + +The fix is to make Session a first-class concept in Echo: a durable +causal-context node with a principal reference, a lifecycle state, an +admission gate over submissions made under that session, an attribution +surface on every intent / effect / receipt, and a queryable projection of +the lifecycle events generated by work attributed to it. The session-port +becomes a temporary compatibility bridge with a defined deletion criterion +(see `jedit/docs/method/backlog/asap/sessions-migration.md`). + +### What this cycle does _not_ do + +Echo already has a deterministic ingress and admission system +(`head_inbox.rs` — per its module comment, ADR-0008 Phase 3). Worldlines have inboxes; routing +addresses worldlines via `IngressTarget::DefaultWriter { worldline_id }` or +`IngressTarget::InboxAddress { worldline_id, inbox }`. Mutation ordering is +owned by worldline / head / tick / strand / braid machinery. + +Session does **not** introduce a second ingress queue. Session does **not** +introduce a second mutation-ordering lane. Adding either would create a +duplicate admission system, two backpressure stories, and a future merge +problem. + +What Session does add is **a durable, principal-bound context that gates +admission, attributes work, holds lifecycle, and exposes a queryable +projection of its own event history**. + +--- + +## Concept separation (sharpened) + +| Concept | Role | Source | +| ----------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------- | +| Writer / Agent | who is acting | external | +| `PrincipalRef` | identity binding for an actor | existing `warp-core::optic_artifact` | +| **Session** | principal-bound work context; lifecycle; attribution; admission gate | new (this cycle) | +| `ViewSession` | read/playback-side view facet attached to a Session | existing `warp-core::playback`, unified under Session | +| Worldline / strand / braid | state lineage being edited / observed | existing `warp-core` | +| `HeadInbox` / `IngressTarget` | worldline-scoped ingress, routing, admission | existing `warp-core::head_inbox` | +| `Worldline head` / `tick` | mutation ordering authority | existing `warp-core` | +| Connection | ephemeral transport (no causal weight) | existing transport layer | +| Intent | requested causal work | existing | +| Effect / Receipt | result of causal work | existing | + +Session is the **principal-bound work context** that binds the rest during +a stretch of work. It is neither an ingress queue nor a mutation-ordering +lane. + +### Unification with `playback::SessionId` + +`warp-core::playback` today exports `SessionId(pub Hash)` documented as +"client's authenticated context and subscription set," with `ViewSession` +carrying an active cursor. The collision is structural, not just nominal: +both are "a durable, principal-bound context that aggregates work." + +**0025 unifies them.** One `SessionId` type; the playback's `ViewSession` +becomes a read-side facet attached to a Session, holding cursors and +subscriptions. Two live `SessionId` concepts in warp-core are forbidden. + +```text +Session (this cycle) + the durable causal-context node +ViewSession (existing, narrowed) + read/playback view state attached to a SessionId + holds CursorId, CursorRole, subscription set, etc. +``` + +The existing `playback::SessionId` shape (`SessionId(pub Hash)`) is +preserved; only its semantic surface grows. + +### Naming reclaim of "Session" + +`echo-session-proto` was a networked inspection prototype using "Session" +in the connection / WebSocket sense. **Retired.** Per coordination with +the user, the entire `PLATFORM_echo-session-proto-split.md` backlog card +is moved to graveyard as out of scope; the unqualified name "Session" is +reclaimed for the causal-context sense defined here. Transport-layer +concepts should use `Connection` / `TransportConnection` / similar. + +--- + +## Session node shape (v1) + +```text +Session + id : SessionId // unified with existing playback::SessionId + principal_ref : PrincipalRef // existing warp-core::optic_artifact type + principal_label? : String // human-readable, optional + status : Open | Closing | Closed + created_at : LogicalTime + closed_at? : LogicalTime + primary_worldline? : WorldlineId // convenience hint only, not authoritative +``` + +Notes: + +- **No `inbox` field.** Submissions route through existing `HeadInbox` / + `IngressTarget`. Session is consulted at the admission gate to validate + status and capability, then steps aside. +- **No `lane_head` / "lane" field.** A session's "lane" is a _projection_ + over admitted intents that named this `SessionId` in their attribution, + not an owned ordering primitive. Worldline head / tick / strand / braid + remain the mutation-ordering authority. +- **`primary_worldline` is convenience metadata, not authoritative.** + Sessions and Worldlines are orthogonal: a session may touch many + worldlines, a worldline may be touched by many sessions. Every intent + names its target worldline(s) explicitly via the existing + `IngressTarget`; the session's `primary_worldline` is a hint clients may + use for default routing. + +--- + +## Submission model — Session over existing ingress + +```text +submit(session_id, intent_id, target: IngressTarget, payload) + → validate session exists and is in Open status (Session gate) + → validate PrincipalRef capability if applicable (Session gate) + → emit IntentSubmitted { session_id, intent_id, target } + → route through existing HeadInbox / IngressTarget (no new queue) + → on admission emit IntentAccepted { session_id, intent_id, target, head/tick info } +``` + +Session is the **context and gate**. `HeadInbox` is the ingress queue. +Worldline head / tick machinery is the mutation-ordering authority. Three +roles, three layers, no duplication. + +### Session event projection (not a "lane") + +For each `SessionId`, the engine exposes a queryable projection of +lifecycle events attributed to it. Working name: **`SessionEventLog`** +(open to revision; explicitly **not** `SessionLane` — that word implies a +competing causal-ordering primitive). The projection is read-only, +derived, and does not own ordering or admission. + +`SessionEventLog(session_id)` answers: + +- which intents were submitted under this session +- which were accepted, which were rejected +- which have produced receipts +- which have reached effect quiescence +- when the session opened, closed, and the close mode + +It is a queryable view over the existing event stream filtered by +`session_id`, not a new event-producing structure. + +### Ordering + +`SessionEventLog(session_id)` is ordered by the engine event log's +deterministic event order: `LogicalTime` / event sequence, with a stable +content-address tie-breaker where the engine already uses one. + +**`SessionEventLog` order is not mutation order.** Worldline head / tick +order remains mutation order. The projection's order reflects when events +were observed by the engine event log, not when their underlying causal +effects took place on any particular worldline. A reader that needs +worldline-level causal order must query worldline state directly, not +infer it from the session projection. + +--- + +## Lifecycle events + +```text +SessionOpened +IntentSubmitted // session-gated; entered HeadInbox +IntentAccepted // worldline-admitted; named in this session +IntentRejected // refused at session gate, head_inbox admission, or execution; stage is recorded +IntentStarted // execution began +EffectEmitted // a downstream effect was produced +IntentReceiptIssued // primary receipt for this intent is in +IntentEffectsQuiesced // all bounded child work from this intent done +SessionCloseRequested // close initiated +SessionClosing // admission gate refusing new submissions +SessionClosed // no accepted in-flight bounded work remains +``` + +### Rejection stages + +`IntentRejected` carries a stage and a reason so callers and audits know +where in the pipeline the refusal happened: + +```text +IntentRejected { + session_id?: SessionId // absent only when no session_id was in the envelope at all + intent_id? : IntentId // absent only when decode failed before intent_id could be read + stage : Decode | SessionGate | HeadInboxAdmission | Execution + reason : MissingSession | UnknownSession | ClosedSession | CapabilityDenied + | BaseHeadMismatch | Cancelled | +} +``` + +Four cases that look similar but record differently: + +- **`MissingSession`** — envelope reached the decode boundary but + carried no `session_id` for a session-aware operation. Stage = + `Decode`. Has no `session_id` to attribute to. Recorded on the engine + event log for diagnostics; no `SessionEventLog` projection to land + in. Distinct from `UnknownSession`: missing means "header absent"; + unknown means "header present but does not resolve." +- **`UnknownSession`** — envelope carried a `session_id` but no session + with that id exists. Stage = `SessionGate`. Cannot appear in + `SessionEventLog(session_id)`, because the named session does not + exist. Observable on the engine event log. +- **`ClosedSession`** — envelope carried a `session_id` resolving to a + Closed session. Stage = `SessionGate`. Appears in + `SessionEventLog(session_id)` for the named closed session. Closed + sessions remain queryable as provenance; "submission attempted after + close" is part of their record. +- **`BaseHeadMismatch`** — envelope passed the session gate, was + admitted to `HeadInbox`, but rejected by the existing worldline/head + admission. Stage = `HeadInboxAdmission`. Attributed to the valid + session that made the submission. Appears in that session's + projection. + +The invariant: only sessions that exist can have a `SessionEventLog`. +`MissingSession` and `UnknownSession` rejections are engine-level, not +session-level. The two are not silently folded together — they are +different bugs. + +### Why two settlement events instead of one + +`IntentSettled` is rejected as a name because it conflates two distinct +meanings: + +- **`IntentReceiptIssued`** — the primary command has produced its direct + receipt. Example: `replaceRangeAsTick` emitted the tick receipt. +- **`IntentEffectsQuiesced`** — all bounded downstream work causally rooted + in that intent is complete. Example: render / re-index / checkpoint + rebuild work spawned from the intent has finished. + +A single `IntentSettled` event would force callers to either lie (declare +settled when the receipt is in even though effects are still rippling) or +overshoot (wait for all effects even when the caller only needed a +receipt). Splitting makes the choice explicit at the call site. + +### Bounded vs unbounded causal participation + +Only **bounded** causal work participates in `EffectsQuiesced`. Long-lived +subscriptions, watchers, telemetry sinks, background daemons, and +open-ended observers do not keep an intent forever unquiesced. Participants +wanting to register bounded child work do so explicitly via the +intent → effect attribution surface; otherwise they stay out of the +quiescence calculation. + +### Quiescence registration rule + +To prevent quiescence from becoming a race: + +- **Bounded child work must be registered before it can keep an intent + non-quiescent.** Registration goes through the intent → effect + attribution surface and happens before the registering participant + could observe `IntentEffectsQuiesced` for the parent intent. +- **After `IntentEffectsQuiesced` is emitted for an intent, no new + bounded child work may be registered for that intent.** Late + registrations are rejected. (Unbounded participants — watchers, + subscriptions — are unaffected; they were never in the quiescence + calculation.) + +Without this rule, a participant could register a bounded child after +the engine has already observed all known bounded work as quiesced, +flipping the intent back to non-quiescent and making "quiesced" a +moving target. The rule turns `IntentEffectsQuiesced` into a one-way +gate per intent. + +--- + +## `runUntilIdle` semantics — over attributed work + +```text +runUntilIdle(session_id, until: receipt | quiescent) +``` + +Defined over **attributed work**, not an owned queue: + +- `runUntilIdle(session_id, receipt)` — returns when every in-flight + accepted intent attributed to `session_id` has emitted + `IntentReceiptIssued`. Fast. For interactive UI affordances and + responsiveness-sensitive callers. +- `runUntilIdle(session_id, quiescent)` — returns when every in-flight + accepted intent attributed to `session_id` has additionally reached + `IntentEffectsQuiesced`. Slower. For tests, deterministic engine calls, + and sync workflows that need actual quiet. + +Default modes: + +- Tests, deterministic engine calls, sync workflows: `quiescent` +- Interactive UI affordances: `receipt`, unless caller asks deeper + +**No fake idle.** Returning "idle" when work attributed to the session is +still rippling is worse than not having the primitive at all. + +--- + +## Close semantics — session admission gate + +Close is two-stage. The intermediate state is observable. + +```text +SessionCloseRequested // close has been asked for +SessionClosing // admission gate refuses new submissions; in-flight work draining or being aborted +SessionClosed // no accepted in-flight bounded work remains +``` + +Phrased as a **session admission gate**, not as draining an owned queue: + +- **Reject new submissions** under this `session_id` at the session gate, + before they ever enter `HeadInbox`. +- **Pending-ingress cancellation is deferred** — envelopes already in + `HeadInbox` may still admit. See the next subsection for the rationale + and the follow-up cycle that adds this capability. +- **Accepted work remains attached** to the session for attribution; its + causal record persists. +- **Graceful close** drains accepted bounded work to `EffectsQuiesced`. +- **Abortive close** cancels interruptible accepted work and waits for + cancellation quiescence. + +### Abortive close v1 scope: pending-ingress cancellation deferred + +`IngressEnvelope` is BLAKE3 content-addressed and `HeadInbox` keys its +pending map by `ingress_id`. Adding `session_id` either changes the +content-address input (determinism-critical, with replay implications) or +requires a secondary index. Either path is moderate surgery on a +determinism-load-bearing component, in the same cycle as the introduction +of Session itself. + +**V1 takes the weaker variant deliberately, not by accident:** + +- Abortive close v1 blocks new submissions at the session gate and + cancels interruptible **accepted** work attributed to the session + (cancellation receipts emitted; cancellation quiesced before + `SessionClosed`). +- Pending-ingress cancellation — rejecting envelopes already in + `HeadInbox` but not yet admitted — is **out of v1**. +- Practical consequence: a pending envelope under an abortively-closing + session may still be admitted by the existing ingress path; once + admitted, the in-flight cancellation logic handles it. + +The follow-up cycle that adds pending-ingress cancellation will: + +- Extend `IngressEnvelope` (in `warp-core::head_inbox`) to carry + `session_id` attribution, deciding explicitly whether the field is + part of the content address (replay-determinism implications) or a + separate attribution column. +- Add a `HeadInbox` API surface for rejecting/cancelling pending + envelopes filtered by `session_id`. `HeadInbox` remains the queue + owner; the session admission gate requests cancellation, the inbox + honors it per its own determinism / ordering rules. + +This deferral is named so RED tests do not test pending-ingress +cancellation in v1, and so the abortive-close invariant is honest: v1 +abortive close means "no new submissions, no in-flight accepted work," +not "no envelope addressed to this session anywhere in the engine." + +### Invariant + +`SessionClosed` means the session no longer has accepted in-flight bounded +work. If that is not true, the name is lying. + +### Detached post-close sub-lanes + +Out of v1. Letting effects from a closed session continue to land on a +sentinel "post-close" sub-lane is clever and tempting, but it creates a +forensic side-channel that future code will accidentally depend on. Defer. + +--- + +## Cross-session concurrency on a shared worldline (v1) + +Multiple sessions may target the same worldline. v1 uses the **obstruction +model** — no automatic merge, no true concurrent worldline lanes, no +multi-writer conflict resolution. + +```text +Session A edits worldline X at head H1 → produces H2 +Session B tries edit against H1 → baseHeadMismatch obstruction +``` + +Mechanics — all existing: + +- A worldline has a current head (`warp-core` already provides this). +- An intent that targets a worldline names the base head it expects. +- If the base head does not match, the intent obstructs via the existing + admission machinery. The session is told via `IntentRejected`. + +This v1 is correct because it preserves the path to real causal +concurrency without pretending we already built Google Docs in a trench +coat. Multi-writer concurrent merge semantics are intentionally deferred. + +--- + +## Single-target routing in v1; atomic multi-target deferred + +V1 session-aware submission carries exactly one routing target, using the +existing warp-core address type: + +```text +Submission { + session_id : SessionId + intent_id : IntentId + target : IngressTarget // existing warp-core::head_inbox type + payload : +} +``` + +`IngressTarget` already supports `DefaultWriter { worldline_id }`, +`InboxAddress { worldline_id, inbox }`, and (for control/debug) +`ExactHead { key }`. V1 reuses it unchanged. + +Atomic multi-target submissions are **deferred**. Future shapes might be: + +```text +target : NonEmptyList +// or +target : AtomicIngressBatch +``` + +The deferral is not just typing. Atomic multi-target work requires +multiple base heads, all-or-nothing commit, cross-target obstruction, +rollback / compensation, lock ordering to avoid deadlock, and +cross-target receipt semantics. Real machinery, not just plural. Out of +v1; a future cycle picks up the shape and the semantics together. + +--- + +## System sessions: primordial `system/genesis` + +There is **no null / default session**. Every intent has a session, even +system-emitted intents. Especially system-emitted intents. + +The bootstrap recursion ("the first session needs a session to open from") +is resolved with the hybrid approach: + +- **`system/genesis`** is a primordial Session, created at genesis time as + part of engine bootstrap. It does **not** arrive via an intent. It is + the one bottom turtle. +- **Every other system session** (`system/bootstrap`, `system/indexer`, + `system/test-runner`, `agent//batch/`, etc.) is opened through + normal lifecycle events rooted in `system/genesis`. They arrive via an + Open intent submitted under `system/genesis`. + +This keeps "session-opening is observable" true for everything after boot +without requiring genesis itself to be observable as an intent. There has +to be one bottom turtle; name it and move on. + +### Genesis principal identity + +`system/genesis` is **not anonymous**. It carries a real `PrincipalRef`, +not a null or empty identity. Proposed shape, subject to Echo's existing +`PrincipalRef` conventions: + +```text +system/genesis.principal_ref = PrincipalRef::system("genesis") +``` + +(Or whatever constructor / domain convention `warp-core` already uses for +system principals — implementation phase confirms the exact form.) + +Invariants: + +- No null session anywhere. +- No null principal anywhere — not even on `system/genesis`. +- Other system sessions (`system/bootstrap`, `system/indexer`, etc.) + similarly carry concrete `PrincipalRef::system(...)`-shaped identities. + +One bottom turtle, with a name tag. + +--- + +## Durable vs ephemeral state + +| Durable | Ephemeral / compactable | +| ---------------------------------- | ----------------------------- | +| Session node | Operational queue bookkeeping | +| Accepted intents (attribution) | Transport connection state | +| Effects | Transient dispatch timers | +| Receipts | Retry bookkeeping | +| Lifecycle events (open/close, etc) | | + +Closed sessions remain queryable as provenance. Operational tail may be +compacted or archived; the causal skeleton stays. + +--- + +## Wire surface + +The EINT envelope gains session addressing in header metadata: + +```text +Envelope { + session_id : SessionId // required for session-aware ops + intent_id : IntentId + target : IngressAddress // protocol-shaped routing value + correlation_id?: CorrelationId // caller-supplied opt-in + payload : +} +``` + +The `target` field is an **`IngressAddress`-shaped protocol value** — +discriminated by the same variant taxonomy as `warp-core::head_inbox:: +IngressTarget` (`DefaultWriter { worldline_id }`, +`InboxAddress { worldline_id, inbox }`, `ExactHead { key }`), but +serialized through the 0024 universal LE binary codec rather than smuggled +as a raw Rust enum. The engine maps `IngressAddress` → `IngressTarget` +internally at decode time. Codecs become graveyards when they casually +import runtime types; this seam keeps the wire shape and the runtime +type free to evolve independently. + +**Naming discipline (binding on RED tests):** + +| Layer | Type name | Where it lives | +| ------------------- | ----------------------------------------------- | ----------------------------------------------- | +| Wire / EINT | `IngressAddress` | protocol-shaped LE binary codec value | +| Decode boundary | `IngressAddress` → `IngressTarget` map function | this cycle's decode layer | +| Runtime / admission | `IngressTarget` | existing `warp-core::head_inbox::IngressTarget` | + +RED tests for the wire surface assert on `IngressAddress` serialization; +RED tests for the admission gate assert on `IngressTarget` values. +Neither test should assert that the wire directly serializes +`IngressTarget`; the decode boundary exists precisely so the runtime type +can change without breaking the wire and vice versa. + +The engine decides: is this session open, is this principal authorized to +submit under it, what worldline does `target` reach, what head / tick does +admission produce. + +The codec serializes facts and addresses. It does not smuggle session +behavior. + +### EINT change classification + +Treat the addition as **additive to the semantic envelope model, +potentially breaking to strict encoders/decoders.** Coordinate with 0024. + +- **Additive** in the sense that envelopes carrying `session_id` are a + semantically richer superset of v1 envelopes. +- **Breaking** in the sense that strict v1 decoders may reject unknown + headers, and session-aware operations require `session_id` (legacy v1 + paths cannot satisfy session-aware ops by omitting the header). + +Concretely: + +- **EINT v1**: `op_id + vars` +- **EINT session-aware revision**: `op_id + vars + session_id + intent_id` headers +- **Legacy paths may omit `session_id` only during the migration window.** + Production session-aware intent submission requires `session_id`. + +### Correlation + +The session attribution gives correlation a durable causal home, but +individual intent / effect correlation still needs explicit IDs. A session +can have many simultaneous or queued things happening; the session +provides scope, not object identity. + +- `session_id` = scope and gate +- `intent_id` = unit of requested work +- `effect_id` / `receipt_id` = result identity +- worldline head / tick = mutation order (existing primitive) +- session position in `SessionEventLog` = engine event log deterministic + order (derived projection; see §Ordering above — not mutation order + and not raw submission order) + +--- + +## Core invariants + +1. Every accepted intent names exactly one `SessionId` for attribution. +2. Every effect / receipt references the accepted intent (and thereby + transitively the session) that caused it. +3. `Worldline`/`head`/`tick` machinery remains the sole mutation-ordering + authority. Session does not reorder admitted work. +4. `HeadInbox`/`IngressTarget` remains the sole ingress queue. Session + does not introduce a second queue. +5. A closed session cannot accept new submissions (gate enforced before + `HeadInbox`). +6. `runUntilIdle(session, quiescent)` returns true iff no in-flight + accepted intent attributed to that session remains short of + `EffectsQuiesced`. +7. `SessionClosed` implies invariant 6 for the closed session. +8. No null / default session anywhere in the system. No null principal + anywhere — even `system/genesis` carries a concrete + `PrincipalRef::system("genesis")`-shaped identity. +9. Exactly one `SessionId` concept exists in `warp-core`. The + `playback::SessionId` is unified with this cycle's `SessionId`. +10. `IntentEffectsQuiesced` is a one-way gate per intent: no bounded child + work may be registered for an intent after that intent has been + observed quiesced. + +--- + +## Human users / jobs / hills + +### Primary human users + +- **Engine maintainers** building on Echo's causal graph. +- **Application authors** wiring clients that submit intents (jedit being + the proof case; future agent / IDE / browser hosts being the + generalization). +- **Operators / auditors** inspecting what was done, by whom, in what + order. + +### Human jobs + +1. Attribute an intent to a coherent stream of work without inventing a + client-side session abstraction. +2. Wait on the right notion of "done" (receipt vs full quiescence) for + the call site without lying. +3. Inspect a closed session's event projection after the fact for + provenance. + +### Human hill + +A human can model multi-writer, multi-buffer, agent-driven work as +distinct sessions in Echo without each application reinventing a +session-port, and can query "what did this actor do?" as a normal graph +read. + +--- + +## Agent users / jobs / hills + +### Primary agent users + +- Agents submitting intents on behalf of a human or themselves. +- Test harnesses driving deterministic engine cycles. +- The jedit optic client (immediate consumer). + +### Agent jobs + +1. Open a session for a bounded piece of work, submit intents attributed + to it, close it cleanly. +2. Programmatically determine when a session is idle, with explicit + knowledge of which idle kind was asked for. +3. Address intents to worldlines through the existing `IngressTarget` + while attributing them to a session. + +### Agent hill + +An agent can submit, observe, and conclude a coherent stream of work +attributed to a single `SessionId` and programmatically determine when +that stream is fully settled, by querying the engine's existing graph +surface filtered to that session. + +--- + +## Human playback + +1. The human runs the cycle's introductory walkthrough (script TBD in + implementation phase). +2. The output shows: `system/genesis` already present; a session opened + under it; three intents submitted, all accepted, receipts issued, + effects quiesced; session closed. +3. The human can query the closed session's `SessionEventLog` and see + the full submission/admission/effect/receipt history without opening + any source file. + +## Agent playback + +1. The agent runs `runUntilIdle(session_id, until: quiescent)` after + submitting a batch. +2. The output contains the session's `SessionClosed` event (or, in the + open-session case, confirmation that no in-flight attributed work + remains). +3. The agent determines that no further effects rooted in intents + attributed to that session will be emitted. + +--- + +## Implementation outline + +Proposed. No code in Phase 1. + +1. Generalize `warp-core::playback::SessionId`: keep the shape, expand + the documented semantic to "durable principal-bound causal context." + Narrow `ViewSession` to "read/playback view facet attached to a + `SessionId`." +2. Add the `Session` node type to the causal graph schema, carrying + `principal_ref: PrincipalRef`, status, lifecycle timestamps, + `primary_worldline?`. No inbox field, no lane field. +3. Add the lifecycle event set as first-class events with stable IDs. +4. Add the session admission gate before `HeadInbox`: a submission with + `session_id` that does not name an Open session is rejected; one whose + principal lacks capability (when capability is checked) is rejected; + otherwise it proceeds to existing ingress unchanged. +5. Extend the EINT envelope shape (coordinated with 0024) to carry + `session_id` + `intent_id` headers and an `IngressAddress`-shaped + protocol target value that maps to `warp-core::head_inbox::IngressTarget` + at decode. +6. **Deferred to follow-up cycle.** `IngressEnvelope` is not extended + with `session_id` attribution in v1; pending-ingress cancellation is + not added in v1. Abortive close v1 blocks new submissions and + cancels interruptible accepted work only. +7. Implement `SessionEventLog(session_id)` as a read-only projection over + the existing event stream filtered by attribution, ordered by the + engine event log's deterministic event order (`LogicalTime` / event + sequence with stable content-address tie-breaker). +8. Implement two-stage close with graceful and abortive modes, expressed + as session admission-gate state transitions. +9. Implement `runUntilIdle(session, until)` against attributed work, with + the one-way quiescence gate enforced on bounded-child registration. +10. Establish `system/genesis` as a primordial Session created by genesis, + carrying a concrete `PrincipalRef::system("genesis")`-shaped identity; + open `system/bootstrap` etc. through normal Open intents. +11. Coordinate the jedit migration via + `jedit/docs/method/backlog/asap/sessions-migration.md`. Deprecate + `JeditWorldlineSessionPort` once the engine surface ships; + delete it once jedit threads `SessionId` through its optic-client + surface and the bridge is no longer referenced. + +--- + +## Tests to write first + +To be expanded in Phase 2. Minimum coverage required: + +- A submission naming an unknown `SessionId` is rejected at the session + gate, never reaches `HeadInbox`. +- A submission naming a Closed session is rejected at the session gate. +- An accepted intent appears in `SessionEventLog(session_id)` with both + `IntentSubmitted` and `IntentAccepted` events in the engine event log's + deterministic order (`IntentSubmitted` precedes `IntentAccepted` for + the same intent_id; not "submission order" in any caller-clock sense). +- A rejected intent appears in `SessionEventLog` with `IntentRejected` + and never reaches `IntentAccepted`. +- `runUntilIdle(session, receipt)` returns when all in-flight attributed + intents have `IntentReceiptIssued`, even if effects are still rippling. +- `runUntilIdle(session, quiescent)` returns only when all in-flight + attributed intents have `IntentEffectsQuiesced`. +- Graceful close drains; abortive close cancels and quiesces cancellation + receipts; both end at `SessionClosed` with no in-flight attributed + bounded work. +- Two sessions targeting the same worldline: second writer obstructs via + existing `baseHeadMismatch`; the rejection is attributed to the + second session. +- An envelope missing `session_id` for a session-aware operation is + rejected at stage `Decode` with reason `MissingSession`; no + `SessionEventLog` entry is produced (no session to attribute to). +- An envelope carrying an unknown `SessionId` is rejected at stage + `SessionGate` with reason `UnknownSession`; the two cases + (`MissingSession` vs `UnknownSession`) must not silently collapse. +- A multi-target / atomic-batch submission is not accepted in v1 + (covers the deferred multi-target invariant). Existing `IngressTarget` + variants are the only accepted shapes. +- `system/genesis` exists at engine boot without an originating intent. +- `system/bootstrap` is opened by an Open intent submitted under + `system/genesis` and its `SessionOpened` event is attributed to + `system/genesis`. +- `playback::ViewSession` continues to function attached to the unified + `SessionId` (no regression on the existing read-side concept). +- Attempting to register a bounded child work item for an intent that has + already emitted `IntentEffectsQuiesced` is rejected (covers the + one-way-gate invariant). +- An unknown-session rejection appears on the engine event log but does + not produce a `SessionEventLog` entry (covers rejection-stage attribution). +- A closed-session rejection appears in that session's `SessionEventLog` + (covers closed-session provenance). +- An abortive close on a session blocks new submissions at the session + gate and cancels interruptible accepted work (cancellation receipts + emitted, cancellation quiesced before `SessionClosed`). Pending + ingress envelopes already in `HeadInbox` may still admit — this is + the v1 deferral, explicitly tested as such, not silently observed. + +--- + +## Risks / unknowns + +- **EINT wire change coordination with 0024.** Addition is semantically + additive but potentially breaking for strict decoders. Must land + alongside or within 0024's envelope work, not as a hot patch after. +- **`playback::SessionId` unification.** Generalizing the existing type + has callers across `warp-core`. The shape stays (`SessionId(pub Hash)`), + but the docstring and surrounding semantics change. Search-and-update + is mechanical; the design risk is missing a place where `SessionId` + was assumed to mean "view-only" and now grows write-attribution + responsibility. +- **`PrincipalRef` capability semantics today.** `PrincipalRef` exists + (`warp-core::optic_artifact`) and is used by `CapabilityGrantIntent` / + `ObstructionReceipt`. The session admission gate's "validate + capability" step needs to compose with whatever exists; v1 may carry + only identity binding without capability enforcement, but the design + must not preclude it. +- **Wesley schema dependency.** Session as a node type means it appears + in Wesley-generated contracts. echo currently pins + `wesley-core = 0.0.4` from crates.io; wesley trunk has moved. This + must be reconciled before implementation, otherwise generated Session + types diverge between echo and consumers (jedit) that pull from + wesley directly. +- **`SessionEventLog` is a derived projection.** Implementation needs to + decide whether to materialize it or compute it on read. For v1, on-read + is fine if event-stream filtering is cheap; materialization is an + optimization, not a correctness concern. +- **`SessionEventLog` naming.** The working name is provisional. Other + candidates considered: `SessionWorkLog`, `SessionIntentProjection`, + `SessionTimeline`. Avoid `SessionLane` — implies a competing + causal-ordering primitive. + +--- + +## Postures + +- **Accessibility:** Not applicable. Internal architecture cycle; no + user-facing surface. +- **Localization:** Not applicable. Same reason. +- **Agent inspectability:** Strong. The whole point of Session as a + causal-context node is that agents can query session state, attributed + work, and quiescence via the standard graph surface and the + `SessionEventLog` projection. No special inspection API required. + +--- + +## Non-goals + +- A second ingress queue (Session does not own one; `HeadInbox` does). +- A second mutation-ordering lane (worldline head / tick / strand / braid + remain authoritative). +- A second `SessionId` concept (unification with `playback::SessionId` + is required, not optional). +- Multi-writer concurrent merge on a single worldline (deferred; v1 is + obstruction-only). +- Atomic multi-target submissions (v1 routing is single-target via + existing `IngressTarget`; a future cycle picks both the shape — e.g. + `NonEmptyList` or `AtomicIngressBatch` — and the + semantics, including multiple base heads, all-or-nothing commit, + cross-target obstruction, rollback, and lock ordering). +- Backpressure, fairness, priority policies on ingress (the existing + `HeadInbox` already handles ingress; session-level policy is future + work that builds on top). +- Capability enforcement teeth on `PrincipalRef` (the seam is preserved; + v1 may carry identity binding only). +- Replacing or renaming `echo-session-proto` (retired entirely; see + Naming reclaim). +- Sub-sessions, session forking, session merging (potentially + interesting, explicitly out of v1). +- Detached post-close sub-lanes for effects landing after `SessionClosed` + (forensic side-channel risk; out). +- Garbage-collecting closed sessions (closed sessions are durable + provenance; only operational tail is compactable). + +--- + +## Source + +This design was extracted from a cross-repo conversation rooted in the +jedit Slice B EINT cutover (jedit commit `26a8f43`, +`stack/jedit-rope-rename`). The request stub is in `request.md`. The +jedit-side migration consequences are documented in +`jedit/docs/method/backlog/asap/sessions-migration.md`. + +The original v1 of this design proposed Session-owned inbox and lane +machinery. That was corrected after reading `warp-core::head_inbox` and +`warp-core::playback::SessionId`: Echo already has worldline-scoped +ingress and a read-side session concept, so Session is positioned as a +principal-bound work context that gates and attributes, not a parallel +admission system. The corrected design integrates with existing primitives +instead of competing with them. diff --git a/docs/design/0025-sessions-as-causal-contexts/phase-2-handoff.md b/docs/design/0025-sessions-as-causal-contexts/phase-2-handoff.md new file mode 100644 index 00000000..c8662982 --- /dev/null +++ b/docs/design/0025-sessions-as-causal-contexts/phase-2-handoff.md @@ -0,0 +1,294 @@ + + + +# 0025 — Phase 2 Handoff + +**Phase 1 complete. Phase 2 (RED + GREEN) not started.** No Rust tests, +stubs, modules, or API surface have been added in this cycle. Phase 2 is +substantial engine work and starts only with explicit implementation +greenlight. + +--- + +## Branch and history + +Branch: `cycle/0025-sessions-as-causal-contexts`, forked from +`stack/echo-le-binary-codec` (per the 0024 coordination bend; see +`design.md`). + +Phase 1 commits, in order: + +| SHA | Subject | +| ---------- | ------------------------------------------------------------------------- | +| `5a73a3f` | docs(design): 0025 sessions as causal contexts (Phase 1 design) | +| `8a21f045` | docs(design): revise 0025 to integrate with existing warp-core primitives | +| `a53cac95` | docs(design): 0025 pre-RED cleanup patch | +| `34e4984b` | docs(design): 0025 pre-RED clarifications | + +The full design is in `design.md`. The seed backlog stub is in +`request.md`. The retired `echo-session-proto-split` card lives at +`docs/method/graveyard/PLATFORM_echo-session-proto-split.md` with a +tombstone note. + +The companion jedit migration plan is at +`jedit/docs/method/backlog/asap/sessions-migration.md` on the jedit +branch `stack/jedit-rope-rename` (jedit commit `e6abe45`). + +--- + +## What is locked + +The design has stabilized through three rounds of human review. The +following decisions are binding on Phase 2 and should not be relitigated +without explicit reopening: + +- **Session is a principal-bound causal-context node** — first-class, + durable, queryable, lifecycle-gated. +- **Session does not own an ingress queue.** `HeadInbox` / + `IngressTarget` remain the sole ingress and routing surface. +- **Session does not own a mutation-ordering lane.** Worldline / head / + tick / strand / braid remain the mutation-ordering authority. +- **`SessionId` is unified across read and write.** The existing + `warp-core::playback::SessionId` shape stays; its semantics widen. + Two live `SessionId` concepts in warp-core are forbidden. +- **`ViewSession` is a read/playback facet** attached to the unified + `SessionId`, holding cursors and subscriptions. +- **`SessionEventLog` is a derived projection,** not a lane. Ordered by + the engine event log's deterministic event order (`LogicalTime` / + event sequence with stable content-address tie-breaker). Explicitly + not mutation order. +- **`system/genesis` is primordial.** Created at genesis time, not by + an intent. Carries a concrete `PrincipalRef::system("genesis")`-shaped + identity. All other system sessions open through it via normal Open + intents. +- **No null session anywhere.** No null principal anywhere — including + `system/genesis`. +- **Two settlement events, not one.** `IntentReceiptIssued` and + `IntentEffectsQuiesced` are distinct events. `IntentSettled` as a + name is rejected. +- **`IntentEffectsQuiesced` is a one-way gate per intent.** After it + fires, no new bounded child work may be registered for that intent. +- **`IntentRejected` records stage and reason.** Stages: `Decode`, + `SessionGate`, `HeadInboxAdmission`, `Execution`. `MissingSession` + (header absent at decode) and `UnknownSession` (header present, does + not resolve at gate) are distinct reasons that must not be silently + folded together. +- **EINT wire target is `IngressAddress`-shaped.** The wire serializes + an `IngressAddress` protocol value through the 0024 universal LE + binary codec; the decode boundary maps it to + `warp-core::head_inbox::IngressTarget`. The wire does not serialize + the Rust enum directly. +- **V1 routing is single-target.** `target: IngressTarget` in v1. + Atomic multi-target submissions are deferred to a follow-up cycle + with a future shape (`NonEmptyList` or + `AtomicIngressBatch`). +- **V1 cross-session concurrency is obstruction-only.** Multiple + sessions may target the same worldline; conflicts surface via the + existing `baseHeadMismatch` admission machinery. True concurrent + multi-writer lanes are deferred. +- **V1 abortive close is the weaker variant (B).** Blocks new + submissions, cancels interruptible accepted work. Pending-ingress + cancellation is deferred to a follow-up cycle. `IngressEnvelope` is + not extended with `session_id` attribution in v1. `HeadInbox` does + not gain a cancel-by-`session_id` API in v1. +- **`PrincipalRef` is the existing type** at + `warp-core::optic_artifact::PrincipalRef`. Reused, not redefined. +- **`echo-session-proto` is retired.** The unqualified name "Session" + is reclaimed for the causal-context sense. Transport-layer concepts + should use `Connection` / `TransportConnection` / similar. + +--- + +## Pre-RED decisions still required + +These are the concrete decisions the Phase 2 agent must lock in before +writing RED tests. The design names the question; the answer is +implementation-level and was deliberately not forced in Phase 1. + +1. **Abortive close v1 scope — confirm B in code.** The design commits + to the weaker variant. Phase 2 RED tests should explicitly assert + that pending-ingress cancellation is _not_ available in v1 (so the + deferral is honest). If the Phase 2 agent re-evaluates and wants to + try A (full `HeadInbox` cancel-by-`session_id`), that is a design + reopening, not an implementation choice. + +2. **`MissingSession` representation.** The design specifies a `Decode` + stage and a `MissingSession` reason in `IntentRejected`. The Phase 2 + agent must decide whether the decode layer raises this as an + `IntentRejected` event or as a separate hard-reject type at the + decode boundary. Both are acceptable; "silently fold into + `UnknownSession`" is not. + +3. **`IngressAddress` type home.** Where does the protocol-shaped + `IngressAddress` type live? Options: a new module in + `echo-wasm-abi`, in `warp-core` alongside `IngressTarget`, or in a + new codec module. How is it generated / encoded under 0024 — by + hand, or via the Wesley emit path? Phase 2 picks the home before + writing the decode boundary tests. + +4. **Session node storage.** Which graph / schema layer owns the + durable `Session` node and its lifecycle facts? The design says + "the causal graph schema" but does not pin the concrete crate / + module. Candidates include `warp-core::graph`, a new + `warp-core::session` module, or attachment to existing causal-WAL + machinery. Phase 2 chooses and documents. + +5. **`SessionEventLog` strategy.** Compute on read or materialize? + Both are correctness-compatible with the design's "derived + projection" framing. V1 default recommendation: compute on read + unless benchmark data forces materialization. Phase 2 confirms. + +--- + +## Phase 2 RED matrix + +The design's hard invariants, expressed as RED test targets. **This is a +matrix, not test code.** Phase 2 RED expands each row into actual Rust +test cases (or shell assertions where appropriate per METHOD). + +### Session admission gate + +- Unknown `SessionId` rejects at stage `SessionGate` with reason + `UnknownSession`; the rejection is observable on the engine event log + but produces no `SessionEventLog` entry. +- Missing `session_id` (header absent for a session-aware operation) + rejects at stage `Decode` with reason `MissingSession`; no + `SessionEventLog` entry. `MissingSession` and `UnknownSession` do not + collapse. +- Closed `SessionId` rejects at stage `SessionGate` with reason + `ClosedSession`; the rejection appears in that closed session's + `SessionEventLog` (closed sessions remain queryable as provenance). +- Valid open `SessionId` passes the session gate and reaches + `HeadInbox` for normal admission. + +### Attribution and admission + +- An accepted intent is attributed to exactly one `SessionId`. +- An accepted intent's `IntentAccepted` event appears in that session's + `SessionEventLog`. +- `IntentRejected` records `stage` and `reason` for every refusal. +- `BaseHeadMismatch` remains the existing `HeadInbox` / worldline + admission behavior; the rejection is attributed to the valid + submitting session and appears in that session's `SessionEventLog` + with stage `HeadInboxAdmission`. + +### SessionEventLog + +- `SessionEventLog(session_id)` orders entries by the engine event + log's deterministic event order (`LogicalTime` / event sequence with + stable content-address tie-breaker), not by mutation order. +- Querying `SessionEventLog` for an unknown `SessionId` does not + return a partial result derived from `MissingSession` / + `UnknownSession` rejections (those rejections are engine-level, not + session-level). + +### runUntilIdle + +- `runUntilIdle(session_id, until: receipt)` returns when every + in-flight accepted intent attributed to that session has emitted + `IntentReceiptIssued`, regardless of effect rippling. +- `runUntilIdle(session_id, until: quiescent)` returns only when every + in-flight accepted intent has additionally reached + `IntentEffectsQuiesced`. +- Late bounded-child registration — attempting to register bounded + child work for an intent that has already emitted + `IntentEffectsQuiesced` — is rejected. (The one-way quiescence gate.) + +### Lifecycle and close + +- `SessionOpened`, `SessionCloseRequested`, `SessionClosing`, + `SessionClosed` events appear in expected order. +- Graceful close drains accepted bounded work to quiescence before + `SessionClosed` fires. +- Abortive close cancels interruptible accepted work, quiesces + cancellation receipts, and ends at `SessionClosed`. +- Abortive close v1 does _not_ cancel pending `HeadInbox` envelopes + (the deferral is asserted explicitly, not silently observed). +- `SessionClosed` implies no accepted in-flight bounded work remains + for that session. + +### System sessions + +- `system/genesis` exists at engine boot without an originating + intent and carries a concrete `PrincipalRef::system("genesis")`-shaped + identity (no null principal). +- `system/bootstrap` is opened by an Open intent submitted under + `system/genesis`; its `SessionOpened` event is attributed to + `system/genesis`. +- Other system sessions follow the same pattern. There is no null + / default session anywhere in the system. + +### Wire and decode boundary + +- An `IngressAddress` wire value round-trips through the 0024 LE binary + codec without loss for every variant + (`DefaultWriter { worldline_id }`, `InboxAddress { worldline_id, inbox }`, + `ExactHead { key }`). +- The decode boundary maps `IngressAddress` to runtime + `warp-core::head_inbox::IngressTarget`. The wire does not serialize + `IngressTarget` directly. Tests for the wire surface assert on + `IngressAddress`; tests for admission assert on `IngressTarget`. +- A multi-target / atomic-batch submission is rejected in v1 (covers + the deferred multi-target invariant). + +### Unification with `playback::SessionId` + +- `playback::ViewSession` continues to function attached to the + unified `SessionId` (no regression on the existing read-side + concept). +- No second `SessionId` concept exists in `warp-core` after this + cycle's implementation. Grep / static check that catches this is + acceptable. + +--- + +## What this handoff is not + +- Not a code change. No Rust, no Wesley IR edits, no test files. +- Not an API surface commitment. Type signatures named in this doc + (`Session::new`, `IntentRejected::stage`, etc.) are illustrative; + Phase 2 picks the exact shape. +- Not authorization to implement. Phase 2 RED begins only with + explicit implementation greenlight; the design conversation that + produced this cycle was scoped to design only. + +--- + +## How Phase 2 should start + +1. Read `design.md` end-to-end. Confirm the locked decisions above + match the doc; if any drift is found, the discrepancy is a design + bug to be fixed before Phase 2 begins. +2. Resolve the five pre-RED decisions above with explicit answers + captured in `phase-2-notes.md` (or by amending `design.md` if the + resolution rises to the design level). +3. Reconcile the `wesley-core` dependency pinning. Echo currently + pins `wesley-core = 0.0.4` from crates.io; wesley trunk has + moved. Session as a node type means Wesley emits matter; the + pinning must be sorted before the schema lands or generated + types will diverge between echo and consumers (jedit). +4. Write RED Rust tests in `crates/warp-core/tests/` (or wherever + convention places them) for each row in the RED matrix above. + Confirm they fail (compile failure on missing types is the + literal RED state). +5. Proceed to Phase 2 GREEN: implement Session, the admission gate, + `SessionEventLog`, `runUntilIdle`, lifecycle events, + `IngressAddress` codec, and the `playback::SessionId` + unification, until RED tests pass. +6. Phase 3 (playback) and Phase 4 (close) follow METHOD. + +The jedit migration card +(`jedit/docs/method/backlog/asap/sessions-migration.md`) consumes +this cycle's output and tracks the client-side migration off +`JeditWorldlineSessionPort`. It is downstream of Phase 2 GREEN. + +--- + +## Provenance + +This handoff was produced in the same conversation as the Phase 1 +design, immediately after Phase 1 was approved. Path 1 (stop after +Phase 1, hand off Phase 2 cleanly) was chosen explicitly to keep API +surface introduction out of a design conversation. Phase 2 RED stubs +in any form — even `todo!()` placeholders — would have crossed that +line. diff --git a/docs/design/0025-sessions-as-causal-contexts/request.md b/docs/design/0025-sessions-as-causal-contexts/request.md new file mode 100644 index 00000000..86a534bf --- /dev/null +++ b/docs/design/0025-sessions-as-causal-contexts/request.md @@ -0,0 +1,131 @@ + + + +# Sessions as Causal Contexts + +Status: active sequencing card. Extension of the 0024 LE binary cutover. + +## Why now + +The jedit Slice B EINT cutover (jedit commit `26a8f43`, echo branch +`stack/echo-le-binary-codec`) surfaced a structural smell: the wire was +carrying jedit-internal session state because Echo had no name for the thing +the session represented — a coherent causal stream of work originating from +some writer. The fix in Slice B was a client-side `JeditWorldlineSessionPort` +(in-process map of `worldlineId → session`). That is a scaffold, not an +abstraction. It works because the optic client and the in-process transport +share the same memory; it cannot ship across a real WASM transport, and it +duplicates engine state on the client. + +The honest fix is to make **Session** a first-class concept in Echo: a durable +causal-context node with a principal reference, a lifecycle gate, an +attribution surface on every intent / effect / receipt, and a queryable +event projection. Session does **not** own an ingress queue (the existing +`HeadInbox` does) and does **not** own a causal-ordering lane (the existing +worldline head / tick / strand / braid machinery does). Jedit then holds +only a `SessionId` and threads it through intent submission. The +session-port becomes a temporary compatibility bridge slated for deletion. + +## What Session is + +| Concept | Role | +| ---------------- | ------------------------------------- | +| Writer / Agent | who is acting | +| **Session** | coherent stream of interaction | +| Worldline | state lineage being edited / observed | +| Connection | ephemeral transport | +| Intent | requested causal work | +| Effect / Receipt | result of causal work | + +Those layers stay separate. Mixing them is the bug class this cycle eliminates. + +## Why this rides 0024 rather than waiting + +The 0024 universal LE binary codec is mid-flight (`stack/echo-le-binary-codec`, ++7 ahead of `origin/main`). The wire shape under discussion in 0024 — EINT +envelopes carrying `(op_id, vars)` — is exactly the surface where session +addressing would be added. Deferring Session to a post-0024 cycle would mean +either rebuilding the EINT envelope shape twice or accumulating debt that +specifically blocks the jedit observe-path cutover. + +This cycle therefore opens on a branch derived from `stack/echo-le-binary-codec` +rather than `main`. The 0024 stack lands first or in parallel; this cycle +extends it. + +## Acceptance criteria + +- Design doc 0025 exists at `docs/design/0025-sessions-as-causal-contexts/` + and defines Session as a first-class causal-context node distinct from + Writer/Principal, Worldline, Connection, and Optic. +- Session integrates with existing warp-core primitives rather than + introducing parallels: ingress remains owned by `head_inbox.rs` + (`HeadInbox` / `IngressTarget`); mutation ordering remains owned by + worldline head / tick / strand / braid; `SessionId` is unified with + the existing `playback::SessionId`. +- Lifecycle event set is named and bounded: `SessionOpened`, + `IntentSubmitted`, `IntentAccepted`, `IntentRejected`, `IntentStarted`, + `EffectEmitted`, `IntentReceiptIssued`, `IntentEffectsQuiesced`, + `SessionCloseRequested`, `SessionClosing`, `SessionClosed`. +- `IntentSettled` is explicitly rejected as ambiguous in favor of the + `ReceiptIssued` / `EffectsQuiesced` split. +- `IntentRejected` records `stage` (`Decode` / `SessionGate` / + `HeadInboxAdmission` / `Execution`) and `reason` (`MissingSession` / + `UnknownSession` / `ClosedSession` / `CapabilityDenied` / + `BaseHeadMismatch` / `Cancelled` / …). `MissingSession` and + `UnknownSession` do not silently collapse. +- `runUntilIdle(session_id, until: receipt | quiescent)` semantics are + defined with explicit caller modes — no fake idle. +- `IntentEffectsQuiesced` is a one-way gate per intent; late + bounded-child registration is rejected. +- Two-stage close (`graceful` vs `abortive`) is specified; detached + post-close sub-lanes are explicitly out of v1; abortive close v1 does + NOT cancel pending `HeadInbox` envelopes (the deferral is honest). +- Cross-session concurrency on a shared worldline is explicitly v1 = + obstruction-only (base-head mismatch). True concurrent worldline lanes + are deferred and named as deferred. +- V1 routing is single-target: submissions carry one `IngressTarget` from + existing warp-core. Atomic multi-target submissions are deferred to a + future cycle along with their shape and semantics. +- Principal identity uses the existing `PrincipalRef` from + `warp-core::optic_artifact`; v1 may carry identity binding only without + capability-enforcement teeth. +- `system/genesis` is primordial (created at genesis, not by an intent) + and carries a concrete `PrincipalRef::system("genesis")`-shaped + identity. No null session and no null principal anywhere. +- The EINT envelope `target` field is an `IngressAddress`-shaped protocol + value (serialized through 0024) that the decode boundary maps to + runtime `IngressTarget`. The wire does not serialize the Rust enum + directly. +- Jedit migration consequences are documented in + `jedit/docs/method/backlog/asap/sessions-migration.md` and link back to + this packet. + +## Non-goals (v1) + +- A second ingress queue (`HeadInbox` remains the sole one). +- A second mutation-ordering lane (worldline head / tick / strand / braid + remain authoritative). +- A second `SessionId` concept (`playback::SessionId` is unified, not + duplicated). +- Multi-writer concurrent merge on a single worldline. +- Atomic multi-target submissions (v1 type is singular `IngressTarget`; + future cycle picks both shape and semantics together). +- Backpressure, fairness, or priority policies on ingress. +- Capability-enforcement teeth on `PrincipalRef` (the seam is preserved; + v1 may carry identity binding only). +- Replacing or renaming `echo-session-proto` — retired entirely (moved to + graveyard); the unqualified name "Session" is reclaimed for the + causal-context sense defined here. +- Detached post-close sub-lanes; sub-sessions / forking / merging; + garbage-collecting closed sessions (all explicitly out of v1). + +## Dependencies + +- `0024 — Universal LE Binary Codec` — the EINT envelope shape this cycle + extends. Cycle branches from `stack/echo-le-binary-codec`. + +## Source + +This card was created from a cross-repo design conversation rooted in the +jedit Slice B cutover. The full prose record of the design decisions is +captured directly in the design packet that follows the pull. diff --git a/docs/method/backlog/bad-code/DOCS_no-published-umbrella-for-warp-optics.md b/docs/method/backlog/bad-code/DOCS_no-published-umbrella-for-warp-optics.md new file mode 100644 index 00000000..df086e93 --- /dev/null +++ b/docs/method/backlog/bad-code/DOCS_no-published-umbrella-for-warp-optics.md @@ -0,0 +1,119 @@ + + + +# No published umbrella names the WARP optics ecosystem + +Status: bad code (well, bad documentation, which is bad code in +disguise). + +## Where + +- `github.com/flyingrobots` profile (no umbrella README; just a list of + repositories) +- `docs/architecture/there-is-no-graph.md` (contains the ecosystem + framing — buried) +- Each repository's own README (introduces itself, doesn't introduce + its neighbors) + +## Smell + +The frame that connects Echo, Wesley, jedit, git-warp, Graft, WARPDrive, +warp-ttd, Bijou, Alfred, ninetails, xyph, and Think exists. It lives in +the table in `there-is-no-graph.md` under "Runtimes As Optics," about +120 lines deep into an architecture note inside one of the projects it +catalogs: + +```text +| Runtime or tool | WARP optic role | +| --------------- | -------------------------------------------------------- | +| Echo | live execution and deterministic admission optic | +| git-warp | Git-backed causal persistence optic | +| Wesley | semantic/compiler optic over authored contract history | +| warp-ttd | historical inspection and causal forensics optic | +| Graft | governed aperture and support-obligation optic | +| WARPDrive | POSIX/FUSE materialization and write-back optic | +| jedit | human-facing console that hosts readings, lanes, and ... | +``` + +A first-time visitor to `github.com/flyingrobots` cannot see this table. +They see ~20 repository cards, each a sibling, each appearing to be of +equal status. They have to do detective work to find out that: + +- Echo is the foundational runtime +- Wesley is the meta-compiler everything depends on for codec parity +- jedit is the reference consumer +- WARPDrive is the planned compatibility layer +- The other names are siblings, experiments, or building blocks + +## Why it matters + +The work is good. The story that explains why it's good is invisible to +exactly the audience most likely to evaluate it: an outsider arriving +through GitHub's profile page. + +Concretely: + +1. **Under-adoption.** Each repo's README has to re-explain the + ecosystem context to a reader who lands cold. Most don't, because + it would balloon every README. The result is that each repo looks + smaller and more isolated than it is. +2. **Recruiter friction.** Someone evaluating the portfolio sees a + list, not a system. Twelve OSS projects look like dabbling; one + ecosystem of twelve interrelated optics looks like vision. +3. **Contributor friction.** A potential contributor doesn't know + where to start, what's load-bearing, what's a sketch, or what + depends on what. +4. **Decision drift.** Without a published frame, design decisions in + one repo can pull against the ecosystem's stated philosophy + without anyone noticing. The umbrella story is governance, not + just marketing. + +## Suggested fix + +This is a 200-word problem with two viable shapes. + +### Shape A — `flyingrobots/.github` profile README + +GitHub renders `/.github/profile/README.md` (or the user-level +equivalent) as the front page of the profile. Drop in: + +- One paragraph: "I build WARP-shaped systems. Here's what that means." +- The optics table from `there-is-no-graph.md`, with each row linked + to its repo +- Three "what to look at first" repos for different audiences + (recruiters, contributors, the curious) +- A status legend (foundational / consumer / experimental / archived) + next to each project + +Estimated: 200-300 words. Half a day. Largest blocker is deciding the +status labels honestly. + +### Shape B — `flyingrobots/platform` umbrella repo + +A dedicated repo whose README is the ecosystem story, with a +dependency diagram (Mermaid), per-project status, the WARP philosophy +in 500 words, and links into each project's deeper docs. Optionally +hosts a static site (VitePress?) that aggregates docs from each repo. + +Estimated: a week to scaffold and write. Better for long-term but +heavier upfront. + +**Recommendation: ship Shape A first.** It costs a half-day and solves +80% of the problem. Shape B emerges naturally if Shape A starts +attracting traffic. + +## Risk: don't over-engineer this + +The temptation will be to design a beautiful documentation system. The +correct first move is **a profile README with a table and status +labels**. Ship that. See if anyone notices. Iterate from feedback, not +from imagined needs. + +## Surface when + +- A new repo is added to the portfolio (it's the moment "wait, what + context do I drop this in?" becomes loudest) +- Anyone asks "where should I start looking at your work?" +- Before any external talk / publication mentioning multiple projects +- Before WARPDrive goes from vapor to v0.0.1 — the umbrella story is + what justifies WARPDrive existing at all diff --git a/docs/method/backlog/bad-code/PLATFORM_echo-wesley-gen-monolith.md b/docs/method/backlog/bad-code/PLATFORM_echo-wesley-gen-monolith.md new file mode 100644 index 00000000..f2dcbe18 --- /dev/null +++ b/docs/method/backlog/bad-code/PLATFORM_echo-wesley-gen-monolith.md @@ -0,0 +1,53 @@ + + + +# echo-wesley-gen is a 2000-line monolith with mixed concerns + +Status: bad code. + +## Where + +`crates/echo-wesley-gen/src/main.rs` — single 2000+ line file. + +## Smell + +One file emits: + +- GraphQL → Rust type definitions (struct/enum) +- LE binary `Encode`/`Decode` impls +- Op id constants + `OP_*_ARGS` tables +- Footprint certificates + observer plan identities +- `RegistryProvider` implementation + static `REGISTRY` +- `__echo_wesley_generated` helper module with `Vars` structs +- EINT intent packers (`pack_*_intent`, `pack_*_intent_raw_vars`) +- Optic dispatch/observe request builders +- Contract-host helpers (`*_contract_rule`, `*_contract_vars`, + `*_query_observer`) gated by `--contract-host` + +Every consumer pays the cost of every concern. Tests are slow (full +cargo run + smoke crate compile per case). Adding a new emit feature +means threading a new flag through eight code paths. + +## Why it matters + +This file is also where the cross-language `stable_op_id` algorithm +lived until 2026-05-28. The fact that algorithm-as-source-of-truth +got buried in a code-emitter monolith is precisely the kind of smell +that delayed Wesley adoption. + +## Suggested split + +- `echo-wesley-gen-types` — struct/enum + Encode/Decode emit +- `echo-wesley-gen-ops` — op_id table + arg descriptors + RegistryProvider +- `echo-wesley-gen-intent` — EINT packers + optic helpers +- `echo-wesley-gen-contract-host` — gated host-side helpers +- A thin top-level binary that composes them + +Each compose-able piece can be tested in isolation. Algorithms shared +across targets (e.g., `stable_op_id`) migrate to `wesley-core` rather +than a vendored copy. + +## Surface when + +Touching anything in this file. The next agent who has to add an emit +target will feel the pain; that's a good moment to extract. diff --git a/docs/method/backlog/bad-code/PLATFORM_warp-wasm-cbor-debt.md b/docs/method/backlog/bad-code/PLATFORM_warp-wasm-cbor-debt.md new file mode 100644 index 00000000..b654ebf8 --- /dev/null +++ b/docs/method/backlog/bad-code/PLATFORM_warp-wasm-cbor-debt.md @@ -0,0 +1,55 @@ + + + +# warp-wasm still speaks CBOR everywhere except the EINT path + +Status: bad code. + +## Where + +`crates/warp-wasm/src/lib.rs` and `crates/warp-wasm/src/warp_kernel.rs`. + +## Smell + +Per the 2026-05-28 audit (during 0024 universal LE binary codec +Phase 3): + +- Public ABI exports named `*_cbor`: `dispatch_intent_cbor`, + `observe_cbor`, `get_registry_info_cbor`, + `dispatch_control_intent_trusted_cbor`, plus optic/settlement variants. +- `observe_cbor` decodes `ObservationRequest` as CBOR +- `dispatch_optic_intent` decodes `DispatchOpticIntentRequest` as CBOR +- `observe_settlement*` / `read_settlement*` decode requests as CBOR +- ALL response envelopes (`OkEnvelope`, `ErrEnvelope`) are + CBOR-encoded +- `warp_kernel.rs` reports `codec_id = "cbor-canonical-v1"` while the + EINT payload path is fully LE binary — inconsistent self-description + +## Why it matters + +The mutation path goes EINT-in / CBOR-out today. That works because +jedit can CBOR-decode the response envelope, but it means: + +- The "engine native protocol" claim is half-true; queries still need + CBOR-encoded ObservationRequest from jedit +- Function names mislead consumers about the actual wire format +- `codec_id` advertises the wrong protocol + +## Suggested cycle + +1. Add `Encode`/`Decode` impls for `ObservationRequest`, + `DispatchOpticIntentRequest`, `SettlementRequest`, `OkEnvelope`, + `ErrEnvelope` +2. Rename `*_cbor` exports to `*_wire` (or similar). Hold the rename + under a feature flag so jedit can adopt the new names in lockstep. +3. Update `codec_id` to `le-binary-v1` +4. Update jedit `echo-wasm-kernel.ts` consumer to call the new names + +This is multi-turn. Don't attempt until the mutation cutover +(jedit/spec/rope-codec.spec.mjs, jedit/spec/eint.spec.mjs) has been +proven end-to-end. + +## Surface when + +After the jedit optic-client EINT cutover lands and the user wants to +move queries onto LE binary. diff --git a/docs/method/backlog/cool-ideas/PLATFORM_schema-version-fingerprint-prefix.md b/docs/method/backlog/cool-ideas/PLATFORM_schema-version-fingerprint-prefix.md new file mode 100644 index 00000000..d319d77a --- /dev/null +++ b/docs/method/backlog/cool-ideas/PLATFORM_schema-version-fingerprint-prefix.md @@ -0,0 +1,62 @@ + + + +# Schema-version fingerprint prefix on every encoded payload + +Status: cool idea. + +Depends on: + +- [[0024 — Universal LE Binary Codec]](../../../design/0024-universal-le-binary-codec/design.md) + +## The idea + +Every framed message that crosses an Echo boundary (WASM call, WSC +record, network frame) carries a 32-byte prefix: the `SCHEMA_SHA256` +that Wesley computed at compile time for the schema the sender was +generated from. + +```text +[32-byte SCHEMA_SHA256] [op_id u32 LE] [vars_len u32 LE] [vars...] +``` + +Receivers verify the prefix before reading the payload. Mismatch → +hard rejection with an explicit "schema version mismatch" error code. +Not silent corruption. + +## Why it matters + +The standard objection to binary formats is that they're not +self-describing — version mismatch causes silent data corruption. +0024's answer is "Wesley regenerates both sides, so they can't drift." +That's true for code that ships together; it breaks for: + +- WSC records read by a newer codec than wrote them +- Network protocol where client and server are deployed + asynchronously +- Stored intents replayed after a contract upgrade + +A 32-byte prefix is a cheap, universal version gate. It promotes +silent corruption to a typed runtime error. + +## What it replaces + +Today `warp-wasm` reports `codec_id = "cbor-canonical-v1"` (and would +report `"le-binary-v1"` once Phase 3b cleanup lands). That captures +the FORMAT version but not the SCHEMA version. A protocol whose +`codec_id` matches but whose schema fields shifted will still +deserialize to silent garbage. + +## Implementation cost + +- 32 extra bytes per message — non-trivial for high-frequency RPC, + trivial for intent envelopes +- One additional Wesley const emit (already produced — `SCHEMA_SHA256`) +- One additional verify step at every boundary + +## Open question + +Does the prefix go INSIDE the EINT envelope (between magic and op_id) +or OUTSIDE? Outside makes routing trivial; inside makes it harder to +forge a forward-compatible envelope. Probably outside, with EINT magic +right after the schema-hash prefix. diff --git a/docs/method/backlog/cool-ideas/PLATFORM_wesley-emitted-fixture-vectors.md b/docs/method/backlog/cool-ideas/PLATFORM_wesley-emitted-fixture-vectors.md new file mode 100644 index 00000000..136cf646 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PLATFORM_wesley-emitted-fixture-vectors.md @@ -0,0 +1,69 @@ + + + +# Wesley emits cross-language fixture vectors as JSON + +Status: cool idea. + +Depends on: + +- [[0024 — Universal LE Binary Codec]](../../../design/0024-universal-le-binary-codec/design.md) + +## The idea + +Add a Wesley emit target — call it `emit fixture-vectors-json` — that, +given a schema and a small set of canonical input instances, produces +a JSON file like: + +```json +{ + "createBufferWorldlineVars": { + "minimal": { + "value": { "input": { "bufferKey": "demo.txt", ... } }, + "hex": "08000000 64 65 6d 6f 2e 74 78 74 00 00 00" + }, + "with-all-fields": { "value": {...}, "hex": "..." } + } +} +``` + +Both Rust and TypeScript spec suites read this JSON file. Each side +encodes `value` using its own generated codec and asserts the bytes +equal `hex`. The fixture file is the cross-boundary contract. + +## What it replaces + +Today (post-0024) cross-language byte parity is asserted by +hand-duplicated hex literals in two places: + +- `crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs` (this repo) +- `jedit/spec/rope-codec.spec.mjs` (jedit repo) + +(And similarly for EINT: `jedit_rope_cross_boundary_eint.rs` plus +`spec/eint.spec.mjs`.) + +The literals are the protocol's source of truth, which means changing +a wire layout requires editing both files in lockstep. A code-review +slip can let them drift. + +## Why it matters + +Wesley is the source of truth for the schema. It should also be the +source of truth for the canonical wire encoding of any specific +input. Then "cross-boundary parity" becomes "both sides read the +same file." + +## Open questions + +- Where does the file live? Wesley's tree (committed alongside the + schema fixture)? Echo's tree? A shared fixture-data repo? +- Where do the canonical input instances come from? Hand-authored + YAML next to the schema? Generated by a fuzzer? +- How does the file get regenerated when the schema changes? + Wesley CLI `emit fixture-vectors-json --schema X.graphql --inputs +X.inputs.yaml --out X.vectors.json`? + +## Surface when + +Next time someone proposes adding a new operation to the rope schema +and has to hand-write hex on both sides. diff --git a/docs/method/backlog/cool-ideas/PLATFORM_wesley-gen-test-loop-speedup.md b/docs/method/backlog/cool-ideas/PLATFORM_wesley-gen-test-loop-speedup.md new file mode 100644 index 00000000..dcbc8a11 --- /dev/null +++ b/docs/method/backlog/cool-ideas/PLATFORM_wesley-gen-test-loop-speedup.md @@ -0,0 +1,151 @@ + + + +# `wesley-gen` test loop speedup + +Legend: `PLATFORM` + +## What hurts + +Round-trip on a single small `echo-wesley-gen` integration change today +looks like this: + +- A single integration test (`tests/generation.rs::test_*`) routinely + takes **60–120s** before it even reports pass/fail. +- A workspace `cargo build` from cold ran **7m41s** in the most recent + `code-lawyer` review pass. +- A single `git commit` ran the pre-commit hook for **3m23s** before + prettier triggered an abort-and-restage cycle, doubling the wait. + +The compound effect: small mechanical PRs (one-line code review fixes) +take many minutes between intent and confirmation. The verification +loop is the bottleneck, not the code. + +## Where the time goes + +Profile of a single `tests/generation.rs::test_no_std_id_list_field_…` +run: + +1. The test calls `Command::new("cargo").args(["run", "-p", +"echo-wesley-gen", …])` once per case. `cargo run` re-checks + freshness across the whole dependency graph on every invocation, + even when the binary is current. +2. `write_basic_generated_crate` writes the generated module to + `target/echo-wesley-gen-basic-smoke//