From 77bcae64d0a57abb40186f3119d5b539f473d77d Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 27 May 2026 16:44:05 -0700 Subject: [PATCH 01/17] docs: add 0024 universal LE binary codec design doc --- .../0024-universal-le-binary-codec/design.md | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 docs/design/0024-universal-le-binary-codec/design.md 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..e2d2765c --- /dev/null +++ b/docs/design/0024-universal-le-binary-codec/design.md @@ -0,0 +1,328 @@ + + + +# 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. Adding a +new variant at the end is safe. Reordering or inserting is a breaking change. + +--- + +## 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). From 8471e5a90ca10415048e14110598e74e67a5f43b Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 27 May 2026 18:11:07 -0700 Subject: [PATCH 02/17] feat(codec): add i32, f32, bool, option, list primitives (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends echo-wasm-abi::codec with the missing GraphQL scalar coverage required by the universal LE binary codec: write_i32_le / read_i32_le — for GraphQL Int write_f32_le / read_f32_le — for GraphQL Float (with inline canonicalization) write_bool / read_bool — for GraphQL Boolean (u8 0x00/0x01) write_option / read_option — presence tag (u8) + conditional payload write_list / read_list — u32 LE count + elements Also adds `canonicalize_f32()` as a public helper that replicates the F32Scalar::new() invariant without depending on warp-math: NaN (any) → 0x7fc00000, subnormal → +0.0, -0.0 → +0.0. Adds CodecError::InvalidBoolTag for malformed bool/option presence bytes. 28 new unit tests cover roundtrips, wire byte layout, and f32 edge cases (NaN variants, subnormals, -0.0, +Infinity). Part of 0024-universal-le-binary-codec Phase 2. --- crates/echo-wasm-abi/src/codec.rs | 399 ++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) diff --git a/crates/echo-wasm-abi/src/codec.rs b/crates/echo-wasm-abi/src/codec.rs index 7f6a7c2e..0d99ab45 100644 --- a/crates/echo-wasm-abi/src/codec.rs +++ b/crates/echo-wasm-abi/src/codec.rs @@ -27,6 +27,9 @@ pub enum CodecError { /// Enum decoding failed. #[error("invalid enum value")] InvalidEnum, + /// Bool tag byte was not 0x00 or 0x01. + #[error("invalid bool tag")] + InvalidBoolTag, } /// Trait for deterministic encoding to bytes. @@ -114,6 +117,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 { @@ -191,6 +247,74 @@ 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 (stored as canonicalized bits). + 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(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. + 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 mut out = Vec::with_capacity(count); + 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 +365,278 @@ 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"]); + } + + // ── 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}" + ); + } + } +} From a1251a763db620fca0633a699b0606c9d13d1f20 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 27 May 2026 19:25:53 -0700 Subject: [PATCH 03/17] feat(echo-wesley-gen): emit LE binary Encode/Decode impls (Phase 4) Replace CBOR vars encoding with echo_wasm_abi::codec LE binary. Generate impl Encode + Decode for all InputObject and Enum types. Generate impl Encode + Decode for all Vars structs. Always force CODEC_ID = "le-binary-v1" (hard cutover, no migration). Update encode/decode helpers and error types to use codec::CodecError. Update generation tests and smoke crate to match new behavior. Also adds CodecError::Trailing and Reader::remaining() to codec.rs so decode_from_bytes rejects trailing bytes after a complete decode. Part of 0024-universal-le-binary-codec Phase 4. --- crates/echo-wasm-abi/src/codec.rs | 17 +- crates/echo-wesley-gen/src/main.rs | 365 ++++++++++++++++-- .../fixtures/toy-counter/echo-ir-v1.json | 2 +- crates/echo-wesley-gen/tests/generation.rs | 13 +- 4 files changed, 358 insertions(+), 39 deletions(-) diff --git a/crates/echo-wasm-abi/src/codec.rs b/crates/echo-wasm-abi/src/codec.rs index 0d99ab45..7bbf189d 100644 --- a/crates/echo-wasm-abi/src/codec.rs +++ b/crates/echo-wasm-abi/src/codec.rs @@ -30,6 +30,9 @@ pub enum CodecError { /// 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. @@ -51,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. @@ -191,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 diff --git a/crates/echo-wesley-gen/src/main.rs b/crates/echo-wesley-gen/src/main.rs index 845c0319..2476a4d4 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"; @@ -156,7 +156,7 @@ fn echo_ir_from_schema_sdl(schema_sdl: &str) -> Result { .map(type_definition_from_wesley) .collect(), ops, - codec_id: Some(DEFAULT_CODEC_ID.to_string()), + codec_id: Some("le-binary-v1".to_string()), registry_version: Some(DEFAULT_REGISTRY_VERSION), }) } @@ -255,7 +255,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 = "le-binary-v1"; 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 +280,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 +288,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); @@ -520,8 +611,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 +641,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 +811,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 +862,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 +894,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 +983,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 +1788,194 @@ 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); + return quote! { + w.write_list(&self.#field_name, #inner_encode)?; + }; + } + 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); }, + "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(()) })?; + }, + "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 { + let inner_decode = scalar_list_element_decoder(&arg.type_name, args); + return quote! { + #field_name: r.read_list(#inner_decode)? + }; + } + 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()? }, + "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())? }, + "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 { + // [T!]! required list + let inner_encode = scalar_list_element_encoder(&field.type_name, args); + return quote! { + w.write_list(&self.#field_name, #inner_encode)?; + }; + } + 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); }, + "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(()) })?; + }, + "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 { + let inner_decode = scalar_list_element_decoder(&field.type_name, args); + return quote! { + #field_name: r.read_list(#inner_decode)? + }; + } + 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()? }, + "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())? }, + "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 { + 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(()) } }, + "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`. +fn scalar_list_element_decoder(type_name: &str, _args: &Args) -> TokenStream { + match type_name { + "Boolean" => quote! { |r| r.read_bool() }, + "Int" => quote! { |r| r.read_i32_le() }, + "Float" => quote! { |r| r.read_f32_le() }, + "String" | "ID" => quote! { |r| r.read_string(usize::MAX) }, + _ => { + let ty = safe_ident(type_name); + 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 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..d1ee3def 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); } @@ -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")); From 09f3e2cd11918f98d44a26e9766114916271926e Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 28 May 2026 04:34:39 -0700 Subject: [PATCH 04/17] test(codec): rope cross-boundary LE binary fixture proofs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests/jedit_rope_cross_boundary.rs in echo-wasm-abi that uses the LE binary codec primitives to encode canonical rope-schema Vars values and asserts the produced byte sequences are bytewise identical to the literal hex vectors asserted by jedit/spec/rope-codec.spec.mjs. This locks the Rust↔TS contract for the rope codec at the primitive level: AnchorBias / CheckpointKind enum discriminants, the CreateBufferWorldlineInput layout (with required + nullable fields mixed), the ReplaceRangeAsTickInput i32 layout, and the CreateCheckpointInput enum-in-the-middle layout. 12 tests, all green. If a future change breaks parity, both ends fail simultaneously: the TS spec on one side and these Rust assertions on the other. Covers Phase 6 (cross-boundary roundtrip fixtures) of docs/design/0024-universal-le-binary-codec/design.md. --- .../tests/jedit_rope_cross_boundary.rs | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs 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..276a4685 --- /dev/null +++ b/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs @@ -0,0 +1,372 @@ +// 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 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); +} From f24b11051e8bbd477ac3aca2eaaaf7c95afd238f Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 28 May 2026 04:56:29 -0700 Subject: [PATCH 05/17] test(envelope): EINT cross-boundary fixture proof for jedit Adds tests/jedit_rope_cross_boundary_eint.rs covering pack_intent_v1 against the same hex literals asserted by jedit/spec/eint.spec.mjs. Pairs the EINT envelope contract on the Rust side with the TS packIntentV1 added in jedit's stack/jedit-rope-rename. This locks the wrapper layer (envelope) so the next phase (swapping jedit's optic-client JSON wire for EINT-wrapped LE binary vars) can proceed without worrying about envelope drift. --- .../tests/jedit_rope_cross_boundary_eint.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 crates/echo-wasm-abi/tests/jedit_rope_cross_boundary_eint.rs 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); +} From dded4e025ae9da09d26f692557b6f0c0b93f7f99 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 28 May 2026 12:58:16 -0700 Subject: [PATCH 06/17] test(emit): pin rope op id outputs in stable_op_id_pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `stable_op_id_pinned` test module to echo-wesley-gen that asserts the FNV-1a outputs for the five rope-schema operations match the same u32 values pinned by `wesley_core::stable_op_id` (added in wesley-core ≥0.0.5). These tests catch drift between echo-wesley-gen's local stable_op_id copy and wesley-core's canonical version, which will collapse to a single source of truth when echo bumps wesley-core to 0.0.5+. Until then, this pin is the guarantee that the local copy stays bytewise identical to Wesley. Also adds a docstring on the local `stable_op_id` flagging the transitional duplication. --- crates/echo-wesley-gen/src/main.rs | 64 ++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/crates/echo-wesley-gen/src/main.rs b/crates/echo-wesley-gen/src/main.rs index 2476a4d4..6a5b273b 100644 --- a/crates/echo-wesley-gen/src/main.rs +++ b/crates/echo-wesley-gen/src/main.rs @@ -176,6 +176,23 @@ fn operation_type_rank(operation_type: wesley_core::OperationType) -> u8 { } } +/// FNV-1a 32-bit op id derivation. 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 = 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 op_kind_from_wesley(operation_type: wesley_core::OperationType) -> OpKind { match operation_type { wesley_core::OperationType::Query | wesley_core::OperationType::Subscription => { @@ -214,19 +231,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)?; @@ -2020,3 +2024,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 + ); + } +} From cf2b4cd59acd63803f92aa4228da3bc711608f77 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 28 May 2026 14:18:22 -0700 Subject: [PATCH 07/17] docs(backlog): capture PLATFORM bad-code and cool-ideas from 0024 work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bad-code/PLATFORM_echo-wesley-gen-monolith: 2000+ line file mixes type/codec/op-id/footprint/registry/intent/optic/contract-host emit. Suggested split into composable sub-emitters. - bad-code/PLATFORM_warp-wasm-cbor-debt: outer request types and all response envelopes are still CBOR; `_cbor` function names; codec_id reports cbor-canonical-v1 while EINT path is LE binary. Audited 2026-05-28; full removal is a future cycle. - cool-ideas/PLATFORM_schema-version-fingerprint-prefix: 32-byte SCHEMA_SHA256 prefix on every framed message gives hard rejection instead of silent corruption — codec_id captures format version but not schema version. - cool-ideas/PLATFORM_wesley-emitted-fixture-vectors: wire-parity hex literals are duplicated in Rust and TS specs today; Wesley should emit fixture-vectors.json and both sides read the same file. --- .../PLATFORM_echo-wesley-gen-monolith.md | 53 ++++++++++++++ .../bad-code/PLATFORM_warp-wasm-cbor-debt.md | 55 +++++++++++++++ ...TFORM_schema-version-fingerprint-prefix.md | 62 +++++++++++++++++ ...PLATFORM_wesley-emitted-fixture-vectors.md | 69 +++++++++++++++++++ 4 files changed, 239 insertions(+) create mode 100644 docs/method/backlog/bad-code/PLATFORM_echo-wesley-gen-monolith.md create mode 100644 docs/method/backlog/bad-code/PLATFORM_warp-wasm-cbor-debt.md create mode 100644 docs/method/backlog/cool-ideas/PLATFORM_schema-version-fingerprint-prefix.md create mode 100644 docs/method/backlog/cool-ideas/PLATFORM_wesley-emitted-fixture-vectors.md 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..7f7cf63c --- /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: + +- `echo/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs` +- `jedit/spec/rope-codec.spec.mjs` + +(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. From bb257ddcbe2223e7dd0a7ad1352d99de54aa5363 Mon Sep 17 00:00:00 2001 From: James Ross Date: Thu, 28 May 2026 18:21:37 -0700 Subject: [PATCH 08/17] docs(backlog): no published umbrella for the WARP optics ecosystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame that connects Echo, Wesley, jedit, git-warp, Graft, WARPDrive, warp-ttd, and the rest exists — it's the table in docs/architecture/there-is-no-graph.md under 'Runtimes As Optics'. A first-time visitor to github.com/flyingrobots cannot see it; they see ~20 sibling repos with no ordering, status, or dependency hints. Card proposes two shapes (profile README is the lightweight win; an umbrella repo with diagram + status is the heavier path) and recommends shipping the lightweight one first. Estimated half a day for the profile README; the biggest blocker is honestly labeling which projects are foundational vs experimental vs archived. Filed in echo's backlog because echo's docs already host the WARP optics framing language. The card prompts lifting that frame from buried architecture notes to a profile-level front door. --- ...S_no-published-umbrella-for-warp-optics.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/method/backlog/bad-code/DOCS_no-published-umbrella-for-warp-optics.md 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..e2c85cdf --- /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 WARP DRIVE goes from vapor to v0.0.1 — the umbrella story is + what justifies WARP DRIVE existing at all From 5a73a3f6fc7a5b4088b31b3c7cf5ea8ee278b4a5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 29 May 2026 07:24:26 -0700 Subject: [PATCH 09/17] docs(design): 0025 sessions as causal contexts (Phase 1 design) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull and design phase for cycle 0025, extending the 0024 LE binary cutover with a first-class Session concept in Echo. Branched from stack/echo-le-binary-codec rather than main because the EINT envelope under discussion in 0024 is exactly the surface where session addressing attaches; deferring would force a wire-shape rebuild or block jedit's observe-path cutover. Promotes Session from jedit's client-side scaffold (the JeditWorldlineSession Port introduced in jedit commit 26a8f43, Slice B of 0024) to a first-class addressable causal-context node, with the following design commitments: - Session is a new primitive that composes Writer/Worldline/Connection/ Optic, not a generalization of any of them. - Two-event settlement (IntentReceiptIssued + IntentEffectsQuiesced) replaces the ambiguous IntentSettled. - runUntilIdle takes an explicit until: receipt | quiescent mode — no fake idle. - Two-stage close (graceful vs abortive); detached post-close sub-lanes explicitly out of v1. - Cross-session worldline concurrency: v1 obstruction-only; true concurrent lanes deferred. - Intent.target_worldlines typed plural, v1 enforces singleton. - PrincipalRef-shaped writer identity from day one. - No null/default session; system sessions are explicit and named. - Naming collision with echo-session-proto acknowledged; coordination plan stated. Phase 1 STOP per METHOD: presenting the design for human review before proceeding to RED/GREEN. --- .../design.md | 567 ++++++++++++++++++ .../request.md | 99 +++ 2 files changed, 666 insertions(+) create mode 100644 docs/design/0025-sessions-as-causal-contexts/design.md create mode 100644 docs/design/0025-sessions-as-causal-contexts/request.md 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..00ae21b3 --- /dev/null +++ b/docs/design/0025-sessions-as-causal-contexts/design.md @@ -0,0 +1,567 @@ + + + +# 0025 — Sessions as Causal Contexts + +_Promote Session from a client-side scaffold to a first-class addressable +causal-context node in Echo, so coherent streams of work have a durable home +in the graph instead of being mirrored in each client._ + +Legend: `PLATFORM` + +Depends on: + +- `0024 — Universal LE Binary Codec` — EINT envelope is the wire surface + where session addressing attaches. This cycle branches from + `stack/echo-le-binary-codec` rather than `main`. + +--- + +## 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 name +for the **coherent stream of causal work originating from some writer**. + +The fix is to make Session a first-class concept in Echo: a durable causal +context with a writer reference, an ingress mailbox, an accepted causal +lane, and an explicit lifecycle. Intents address sessions; effects and +receipts attribute back to them; lifecycle becomes a queryable property of +the graph instead of a property of jedit's heap. + +The session-port becomes a temporary compatibility bridge with a defined +deletion criterion (see `jedit/docs/method/backlog/asap/sessions-migration.md`). + +--- + +## What Session is, and what it is not + +Session is a **new primitive** that composes existing primitives rather than +replacing them. The clean separation: + +| Concept | Role | +| ---------------- | ----------------------------------------------------- | +| Writer / Agent | who is acting | +| **Session** | coherent stream of interaction (this cycle's subject) | +| Worldline | state lineage being edited / observed | +| Connection | ephemeral transport | +| Intent | requested causal work | +| Effect / Receipt | result of causal work | + +Session is **not** an Optic (a typed surface over data), **not** a Worldline +(a state lineage), **not** an Agent (writer identity), **not** a Connection +(transport identity). It is the missing causal context that binds those +things during a stretch of work. + +### Naming collision + +`echo-session-proto` currently uses "Session" in the transport-protocol +sense (WebSocket-style connection session). That crate is being split under +`PLATFORM_echo-session-proto-split` (rename of legacy transport types, +preserve the EINT/TTD framing). This cycle reclaims the unqualified +"Session" for the causal-context sense. Coordination plan: the proto-split +cycle renames its types to the connection-sense (`ConnectionSession`, +`TransportSession`, or whatever it lands on) before this cycle's Session +node ships. The two cycles must agree on the rename order; this cycle does +not block on completion of proto-split, only on its rename direction being +chosen. + +--- + +## Session node shape (v1) + +```text +Session + id : SessionId + writer_ref : PrincipalRef + writer_label? : String // human-readable, optional + status : open | closing | closed + created_at : LogicalTime + closed_at? : LogicalTime + primary_worldline? : WorldlineId // convenience, not authoritative + lane_head? : LaneCursor // last accepted intent on this session's lane +``` + +**Writer identity** is `PrincipalRef`-shaped even in v1. The v1 +implementation may carry only a label, but the type is the seam where +identity-with-teeth (signatures, capabilities, audit) eventually attaches. +A bare `String` here would be a one-way street. + +**`primary_worldline`** is convenience metadata, not authoritative. Sessions +and Worldlines are orthogonal axes: a session may touch many worldlines, a +worldline may be touched by many sessions. Each intent names its target +worldline(s) explicitly; the session's `primary_worldline` is just a hint +for clients that want a default. + +--- + +## Inbox vs lane + +These are not the same thing. Conflating them is how queueing concerns get +mistaken for causal concerns. + +- **Inbox** is ingress. Things submitted to the session. May be rejected, + delayed, prioritized, or cancelled. Not part of causal history. +- **Lane** is accepted causal order. Things Echo has admitted into the + session's causal history. Has a total order. Immutable. + +The submit-then-accept seam lets us add backpressure, priority, fairness, +and cancellation later without lying about causality. v1 ships with the +seam but no scheduling sophistication on top of it. + +--- + +## Lifecycle events + +```text +SessionOpened +IntentSubmitted // entered the inbox +IntentAccepted // admitted onto the lane (causal commitment) +IntentRejected // never made it onto the lane +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 // no new intents accepted; drain or abort +SessionClosed // no accepted in-flight bounded work remains +``` + +### Why two settlement events instead of one + +`IntentSettled` is rejected as a name because it carries two distinct +meanings that callers need to choose between: + +- **`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 the event makes the choice explicit at the call site +instead of baked into the name. + +### 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. If such a +participant wants to register bounded child work, it does so explicitly via +the intent → effect attribution surface; otherwise it stays out of the +quiescence calculation. + +--- + +## `runUntilIdle` semantics + +```text +runUntilIdle(session_id, until: receipt | quiescent) +``` + +The mode is explicit and required: + +- `until: receipt` — return when every in-flight intent on the session has + produced its receipt. Fast. Suitable for interactive UI affordances and + responsiveness-sensitive callers. +- `until: quiescent` — return when every in-flight intent has additionally + reached `EffectsQuiesced`. Slower. Suitable 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 is still rippling is worse +than not having an idle primitive at all, because it gives bugs a hat and +a clipboard. + +--- + +## Close semantics + +Close is two-stage. The intermediate state is observable. + +```text +SessionCloseRequested // close has been asked for +SessionClosing // no new intents accepted; in-flight work draining or being aborted +SessionClosed // no accepted in-flight bounded work remains +``` + +During `SessionClosing`: + +- No new intents are accepted. +- Already accepted intents remain causally attached to the session. +- Unaccepted inbox entries are rejected or cancelled. +- Accepted in-flight work either drains or is explicitly aborted, per + close mode. + +### Close modes (v1) + +- **Graceful close.** Stop accepting new intents; drain accepted work to + quiescence; then emit `SessionClosed`. +- **Abortive close.** Stop accepting new intents; cancel unstarted / + interruptible work; emit obstruction/cancellation receipts; wait for + cancellation quiescence; then emit `SessionClosed`. + +**Detached post-close sub-lanes are out of v1.** Letting effects from a +closed session continue to land on a sentinel "post-close" sub-lane is +clever and tempting. It creates a forensic side-channel that future code +will accidentally start depending on. Out for now; revisit if a real use +case forces it. + +### Invariant + +`SessionClosed` means the session no longer has accepted in-flight bounded +work. If that is not true, the name is lying. + +--- + +## 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: + +- A worldline still has a current head. +- An intent that targets a worldline must name the base head it expects. +- If the base head does not match, the intent obstructs. + +This is the right v1 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. + +--- + +## Multi-worldline intents + +The intent type admits plural targets: + +```text +Intent { + target_worldlines : NonEmptyList + ... +} +``` + +The v1 protocol enforces `target_worldlines.length == 1`. Atomic +multi-worldline transactions are deferred. + +The plural type is intentional: it leaves room for future cross-buffer +atomic operations (project-wide rename, refactor-across-files) without +needing a wire-shape break. Implementing those atomically — multiple base +heads, all-or-nothing commit, cross-worldline obstruction, rollback / +compensation, lock ordering to avoid deadlock, cross-worldline receipt +semantics — is real machinery, not "just plural." Out of v1. + +--- + +## System and headless intents + +There is **no null / default session**. Every intent has a session, even +system-emitted intents. Especially system-emitted intents. + +System sessions are explicit and named, e.g.: + +```text +session: system/bootstrap +session: system/indexer +session: system/test-runner +session: agent//batch/ +``` + +A nullable session field would feel convenient for about a week, then +become the place causality goes to die. + +--- + +## Durable vs ephemeral state + +| Durable | Ephemeral / compactable | +| ---------------------------- | -------------------------- | +| Session node | Raw inbox backlog | +| Accepted intents | Transport connection state | +| Effects | Transient dispatch timers | +| Receipts | Retry bookkeeping | +| Open / close lifecycle facts | | + +Closed sessions remain queryable as provenance. Operational queue details +(raw inbox, transport bookkeeping) may be compacted or archived; the +causal skeleton stays. This distinction prevents the session graph from +becoming a dump truck. + +--- + +## Wire surface + +The EINT envelope carries addressing, not session semantics: + +```text +Envelope { + session_id : SessionId + intent_id : IntentId + correlation_id?: CorrelationId // caller-supplied opt-in + payload : +} +``` + +The engine decides everything else: is this session open, is this writer +authorized to submit here, what lane does this enter, what causal parent / +head does it attach to. + +The codec does not smuggle session behavior. It serializes facts and +addresses. + +**On correlation.** The session lane 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 lane +gives scope and ordering, not object identity. Concretely: + +- `session_id` = scope +- `intent_id` = unit of requested work +- `effect_id` / `receipt_id` = result identity +- lane position = causal order + +--- + +## Core invariants + +1. Every accepted intent belongs to exactly one session. +2. Every effect / receipt references the accepted intent that caused it. +3. A session lane has a total order of accepted intents. +4. A closed session cannot accept new intents. +5. `runUntilIdle(session, quiescent)` returns true iff there is no pending + accepted work AND no in-flight bounded child work for that session. +6. `SessionClosed` implies invariant 5 for the closed session. +7. No null / default session anywhere in the system. + +--- + +## 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. Address 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 causal history 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. + +--- + +## 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 against it, + close it cleanly. +2. Programmatically determine when a session is idle, with explicit + knowledge of which kind of idle was asked for. +3. Address intents to multiple worldlines within one session without losing + per-worldline obstruction semantics. + +### Agent hill + +An agent can submit, observe, and conclude a coherent stream of work +addressed to a single `SessionId` and programmatically determine when that +stream is fully settled in the engine's causal graph. + +--- + +## Human playback + +1. The human runs the cycle's introductory walkthrough (script TBD in + implementation phase). +2. The output shows: session opened, three intents submitted, all three + accepted onto the lane, receipts issued, effects quiesced, session + closed. +3. The human can query the closed session's lane and see the total order + of accepted intents 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 a + confirmation that all bounded child work is done in the open-session + case). +3. The agent determines that no further effects rooted in that session's + intents will be emitted. + +--- + +## Implementation outline + +This is the design phase. The implementation outline below is **proposed**; +no code lands in this cycle's Phase 1. + +1. Define `SessionId`, `PrincipalRef`, `LaneCursor`, `IntentId`, + `EffectId`, `ReceiptId`, `CorrelationId` as Wesley-IR types so all + languages emit identical shapes. +2. Add the Session node type to the causal graph schema. +3. Add the lifecycle event set as first-class events with stable IDs. +4. Extend EINT envelope shape (within 0024's encoding doctrine) to carry + `session_id` and `intent_id` headers. +5. Implement the inbox / lane split, with v1 carrying no priority or + fairness machinery. +6. Implement two-stage close with graceful and abortive modes. +7. Implement `runUntilIdle(session, until)` against the lifecycle event + stream. +8. Add system sessions as a bootstrap concern (`system/bootstrap`, + `system/indexer`, etc.). +9. Document the migration path for clients in companion docs + (`jedit/docs/method/backlog/asap/sessions-migration.md`). +10. Deprecate jedit's `JeditWorldlineSessionPort` once the engine surface + is available; remove it once jedit threads `SessionId` through its + optic-client surface. + +--- + +## Tests to write first + +To be expanded in Phase 2. Minimum coverage required: + +- A submitted intent appears on the lane in submit order, with `Accepted` + preceding `ReceiptIssued`. +- A rejected intent never appears on the lane. +- `runUntilIdle(session, receipt)` returns when all in-flight intents + have `ReceiptIssued`, even if effects are still rippling. +- `runUntilIdle(session, quiescent)` returns only when all in-flight + intents have `EffectsQuiesced`. +- Graceful close drains; abortive close cancels and quiesces cancellation + receipts; both end at `SessionClosed`. +- Two sessions targeting the same worldline: second-writer obstructs with + `baseHeadMismatch`. +- An intent submitted to a closed session is rejected at the inbox, never + reaches the lane. +- A `null` / missing session field on an envelope is a hard reject + (covers the no-null-default invariant). +- An intent with `target_worldlines.length > 1` is rejected in v1 + (covers the deferred multi-worldline invariant). + +--- + +## Risks / unknowns + +- **Wire shape break.** EINT envelopes already exist (0024). Adding + `session_id` to the header is a breaking change. Coordination with + `0024` matters: ideally the Session header is part of EINT v2 or the + next EINT revision, not a hot patch. +- **Naming collision with `echo-session-proto`.** Addressed above; the + rename must happen in the right order. If proto-split lags, this cycle's + "Session" name lands ambiguously for a window. +- **System session bootstrapping.** If every intent requires a session, + the bootstrap itself needs a session to exist before the first user + intent. Concretely: how does `system/bootstrap` come into being? It + must be a primordial node, established by genesis, not by an intent. + Design needs to spell this out before implementation. +- **Cross-session worldline interaction beyond v1.** The deferral is + honest, but the underlying problem (multi-writer concurrent merge) is + fundamental to causal computing. Deferring it is correct; pretending it + is "later work" rather than "an open research direction" would be + dishonest. +- **Wesley schema dependency.** Session as a node type means it appears + in Wesley-generated contracts. The wesley-core dependency situation + (echo currently pins `wesley-core = 0.0.4` from crates.io; wesley + trunk has moved) needs to be reconciled before this cycle's + implementation phase, otherwise the generated session types diverge. + +--- + +## 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, lane + contents, and quiescence via the standard graph surface. No special + inspection API required. + +--- + +## Non-goals + +- Multi-writer concurrent merge on a single worldline (deferred; v1 is + obstruction-only). +- Atomic multi-worldline intents (type admits plural; v1 enforces + singleton). +- Backpressure, fairness, priority, or capability enforcement on the + inbox (seam exists; policies do not). +- Replacing or renaming `echo-session-proto` (coordinated in its own + cycle). +- Sub-sessions, session forking, session merging (potentially interesting, + out of v1). +- Authentication and authorization of writers (`PrincipalRef` is + forward-compatible; v1 carries label only). +- Detached post-close sub-lanes for effects landing after `SessionClosed` + (forensic side-channel risk; explicitly out). +- Garbage-collecting closed sessions (closed sessions are durable + provenance; only operational tail is compactable). + +--- + +## Open questions for review + +1. Is `PrincipalRef` already a named shape in Echo, or are we introducing + it here? If introducing, does it belong to this cycle or to a separate + identity cycle that this one depends on? +2. Is the EINT envelope shape modification additive or breaking? If + breaking, does it ride 0024 directly or chain after it? +3. System sessions: established at genesis, or established by a + bootstrap intent submitted to a primordial session? Recursion concern. +4. Does the existing causal graph have a notion of "actor-scoped lane" + that Session subsumes, or is the lane structure new to this cycle? +5. The `PLATFORM_echo-session-proto-split` cycle is in `up-next/`. Should + it be pulled and renamed before this cycle's implementation phase to + eliminate the naming-collision window? + +--- + +## 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`. 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..dbcb7ad5 --- /dev/null +++ b/docs/design/0025-sessions-as-causal-contexts/request.md @@ -0,0 +1,99 @@ + + + +# 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 actor / context node with a writer reference, an ingress mailbox, an +accepted causal lane, and an explicit lifecycle. 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, Worldline, Connection, and Optic. +- 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. +- `runUntilIdle(session_id, until: receipt | quiescent)` semantics are + defined with explicit caller modes — no fake idle. +- Two-stage close (`graceful` vs `abortive`) is specified; detached + post-close sub-lanes are explicitly out of v1. +- 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. +- `Intent.target_worldlines` is typed as `NonEmptyList` with a + v1 invariant of length == 1. Atomic multi-worldline transactions are + deferred and named as deferred. +- Writer identity uses a `PrincipalRef`-shaped forward-compatible type even + if v1 carries only a label. +- Naming collision with existing `echo-session-proto` (transport-protocol + sense) is acknowledged and a disambiguation strategy is stated. +- Jedit migration consequences are documented in + `jedit/docs/method/backlog/asap/sessions-migration.md` and link back to + this packet. + +## Non-goals (v1) + +- Multi-writer concurrent merge on a single worldline. +- Atomic multi-worldline intents. +- Backpressure, fairness, priority, or capability enforcement on the inbox. +- Replacing the existing `echo-session-proto` transport types in this cycle + (a separate cycle, `echo-session-proto-split`, already exists for that + rename and will be coordinated with this naming reclamation). + +## 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. From 8a21f04507aff75319364f24fa658cdc88157b92 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 29 May 2026 08:02:32 -0700 Subject: [PATCH 10/17] docs(design): revise 0025 to integrate with existing warp-core primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantial Phase 1 revision after reading warp-core. The original v1 positioned Session as owning an inbox and a causal lane. That conflicts with existing primitives: - warp-core::head_inbox already provides worldline-scoped ingress (IngressTarget::DefaultWriter / InboxAddress / ExactHead, plus HeadInbox queues). A session-owned inbox would create a duplicate admission queue. - warp-core::playback::SessionId already exists with semantics close to this cycle's Session ("client's authenticated context and subscription set"). Two live SessionId concepts are forbidden. - warp-core::optic_artifact::PrincipalRef already exists and is the right shape for Session.writer_ref. Revised model: - Session is a first-class durable causal-context node with PrincipalRef, lifecycle state, admission gate, attribution surface, and a queryable event projection. - Session does NOT own an inbox. Submissions route through the existing HeadInbox after the session admission gate validates status/capability. - Session does NOT own a causal lane. Worldline head/tick remains the mutation-ordering authority. The session-scoped view of work is a derived projection (working name SessionEventLog — explicitly NOT SessionLane to avoid implying a competing causal-ordering primitive). - playback::SessionId is unified with this cycle's SessionId. The shape stays; ViewSession becomes a read-side facet attached to a Session. - System bootstrap: system/genesis is primordial (created at genesis, no originating intent); all other system sessions opened normally through it. - EINT envelope change classified as "additive to the semantic envelope model, potentially breaking to strict encoders/decoders; coordinate with 0024." - PLATFORM_echo-session-proto-split moved to graveyard. The networked inspection prototype is retired entirely; the Session name is reclaimed for the causal-context sense. Phase 1 STOP per METHOD: presenting the revised design for review. --- .../design.md | 682 +++++++++++------- .../PLATFORM_echo-session-proto-split.md | 12 + 2 files changed, 416 insertions(+), 278 deletions(-) rename docs/method/{backlog/up-next => graveyard}/PLATFORM_echo-session-proto-split.md (68%) diff --git a/docs/design/0025-sessions-as-causal-contexts/design.md b/docs/design/0025-sessions-as-causal-contexts/design.md index 00ae21b3..72f147d1 100644 --- a/docs/design/0025-sessions-as-causal-contexts/design.md +++ b/docs/design/0025-sessions-as-causal-contexts/design.md @@ -3,77 +3,125 @@ # 0025 — Sessions as Causal Contexts -_Promote Session from a client-side scaffold to a first-class addressable -causal-context node in Echo, so coherent streams of work have a durable home -in the graph instead of being mirrored in each client._ +_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` — EINT envelope is the wire surface +- `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.writer_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 name -for the **coherent stream of causal work originating from some writer**. - -The fix is to make Session a first-class concept in Echo: a durable causal -context with a writer reference, an ingress mailbox, an accepted causal -lane, and an explicit lifecycle. Intents address sessions; effects and -receipts attribute back to them; lifecycle becomes a queryable property of -the graph instead of a property of jedit's heap. - -The session-port becomes a temporary compatibility bridge with a defined -deletion criterion (see `jedit/docs/method/backlog/asap/sessions-migration.md`). +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`, 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**. --- -## What Session is, and what it is not - -Session is a **new primitive** that composes existing primitives rather than -replacing them. The clean separation: - -| Concept | Role | -| ---------------- | ----------------------------------------------------- | -| Writer / Agent | who is acting | -| **Session** | coherent stream of interaction (this cycle's subject) | -| Worldline | state lineage being edited / observed | -| Connection | ephemeral transport | -| Intent | requested causal work | -| Effect / Receipt | result of causal work | - -Session is **not** an Optic (a typed surface over data), **not** a Worldline -(a state lineage), **not** an Agent (writer identity), **not** a Connection -(transport identity). It is the missing causal context that binds those -things during a stretch of work. - -### Naming collision - -`echo-session-proto` currently uses "Session" in the transport-protocol -sense (WebSocket-style connection session). That crate is being split under -`PLATFORM_echo-session-proto-split` (rename of legacy transport types, -preserve the EINT/TTD framing). This cycle reclaims the unqualified -"Session" for the causal-context sense. Coordination plan: the proto-split -cycle renames its types to the connection-sense (`ConnectionSession`, -`TransportSession`, or whatever it lands on) before this cycle's Session -node ships. The two cycles must agree on the rename order; this cycle does -not block on completion of proto-split, only on its rename direction being -chosen. +## 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. --- @@ -81,42 +129,66 @@ chosen. ```text Session - id : SessionId - writer_ref : PrincipalRef - writer_label? : String // human-readable, optional - status : open | closing | closed + id : SessionId // unified with existing playback::SessionId + writer_ref : PrincipalRef // existing warp-core type + writer_label? : String // human-readable, optional + status : Open | Closing | Closed created_at : LogicalTime closed_at? : LogicalTime - primary_worldline? : WorldlineId // convenience, not authoritative - lane_head? : LaneCursor // last accepted intent on this session's lane + primary_worldline? : WorldlineId // convenience hint only, not authoritative ``` -**Writer identity** is `PrincipalRef`-shaped even in v1. The v1 -implementation may carry only a label, but the type is the seam where -identity-with-teeth (signatures, capabilities, audit) eventually attaches. -A bare `String` here would be a one-way street. - -**`primary_worldline`** is convenience metadata, not authoritative. Sessions -and Worldlines are orthogonal axes: a session may touch many worldlines, a -worldline may be touched by many sessions. Each intent names its target -worldline(s) explicitly; the session's `primary_worldline` is just a hint -for clients that want a default. +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. --- -## Inbox vs lane +## 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") -These are not the same thing. Conflating them is how queueing concerns get -mistaken for causal concerns. +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. -- **Inbox** is ingress. Things submitted to the session. May be rejected, - delayed, prioritized, or cancelled. Not part of causal history. -- **Lane** is accepted causal order. Things Echo has admitted into the - session's causal history. Has a total order. Immutable. +`SessionEventLog(session_id)` answers: -The submit-then-accept seam lets us add backpressure, priority, fairness, -and cancellation later without lying about causality. v1 ships with the -seam but no scheduling sophistication on top of it. +- 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. --- @@ -124,22 +196,22 @@ seam but no scheduling sophistication on top of it. ```text SessionOpened -IntentSubmitted // entered the inbox -IntentAccepted // admitted onto the lane (causal commitment) -IntentRejected // never made it onto the lane +IntentSubmitted // session-gated; entered HeadInbox +IntentAccepted // worldline-admitted; named in this session +IntentRejected // refused either at session gate or at admission 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 // no new intents accepted; drain or abort +SessionClosing // admission gate refusing new submissions SessionClosed // no accepted in-flight bounded work remains ``` ### Why two settlement events instead of one -`IntentSettled` is rejected as a name because it carries two distinct -meanings that callers need to choose between: +`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. @@ -150,83 +222,80 @@ meanings that callers need to choose between: 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 the event makes the choice explicit at the call site -instead of baked into the name. +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. If such a -participant wants to register bounded child work, it does so explicitly via -the intent → effect attribution surface; otherwise it stays out of the +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. --- -## `runUntilIdle` semantics +## `runUntilIdle` semantics — over attributed work ```text runUntilIdle(session_id, until: receipt | quiescent) ``` -The mode is explicit and required: +Defined over **attributed work**, not an owned queue: -- `until: receipt` — return when every in-flight intent on the session has - produced its receipt. Fast. Suitable for interactive UI affordances and +- `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. -- `until: quiescent` — return when every in-flight intent has additionally - reached `EffectsQuiesced`. Slower. Suitable for tests, deterministic - engine calls, and sync workflows that need actual quiet. +- `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 is still rippling is worse -than not having an idle primitive at all, because it gives bugs a hat and -a clipboard. +**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 +## Close semantics — session admission gate Close is two-stage. The intermediate state is observable. ```text SessionCloseRequested // close has been asked for -SessionClosing // no new intents accepted; in-flight work draining or being aborted +SessionClosing // admission gate refuses new submissions; in-flight work draining or being aborted SessionClosed // no accepted in-flight bounded work remains ``` -During `SessionClosing`: - -- No new intents are accepted. -- Already accepted intents remain causally attached to the session. -- Unaccepted inbox entries are rejected or cancelled. -- Accepted in-flight work either drains or is explicitly aborted, per - close mode. - -### Close modes (v1) +Phrased as a **session admission gate**, not as draining an owned queue: -- **Graceful close.** Stop accepting new intents; drain accepted work to - quiescence; then emit `SessionClosed`. -- **Abortive close.** Stop accepting new intents; cancel unstarted / - interruptible work; emit obstruction/cancellation receipts; wait for - cancellation quiescence; then emit `SessionClosed`. - -**Detached post-close sub-lanes are out of v1.** Letting effects from a -closed session continue to land on a sentinel "post-close" sub-lane is -clever and tempting. It creates a forensic side-channel that future code -will accidentally start depending on. Out for now; revisit if a real use -case forces it. +- **Reject new submissions** under this `session_id` at the session gate, + before they ever enter `HeadInbox`. +- **Cancel / reject unaccepted work** still pending in `HeadInbox` that is + attributable to this session (where the existing admission policy + permits cancellation). +- **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 work and waits for + cancellation quiescence. ### 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) @@ -240,13 +309,14 @@ Session A edits worldline X at head H1 → produces H2 Session B tries edit against H1 → baseHeadMismatch obstruction ``` -Mechanics: +Mechanics — all existing: -- A worldline still has a current head. -- An intent that targets a worldline must name the base head it expects. -- If the base head does not match, the intent obstructs. +- 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 is the right v1 because it preserves the path to real causal +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. @@ -270,89 +340,121 @@ The plural type is intentional: it leaves room for future cross-buffer atomic operations (project-wide rename, refactor-across-files) without needing a wire-shape break. Implementing those atomically — multiple base heads, all-or-nothing commit, cross-worldline obstruction, rollback / -compensation, lock ordering to avoid deadlock, cross-worldline receipt -semantics — is real machinery, not "just plural." Out of v1. +compensation, lock ordering — is real machinery, not "just plural." Out of +v1. --- -## System and headless intents +## System sessions: primordial `system/genesis` There is **no null / default session**. Every intent has a session, even system-emitted intents. Especially system-emitted intents. -System sessions are explicit and named, e.g.: +The bootstrap recursion ("the first session needs a session to open from") +is resolved with the hybrid approach: -```text -session: system/bootstrap -session: system/indexer -session: system/test-runner -session: agent//batch/ -``` +- **`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`. -A nullable session field would feel convenient for about a week, then -become the place causality goes to die. +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. --- ## Durable vs ephemeral state -| Durable | Ephemeral / compactable | -| ---------------------------- | -------------------------- | -| Session node | Raw inbox backlog | -| Accepted intents | Transport connection state | -| Effects | Transient dispatch timers | -| Receipts | Retry bookkeeping | -| Open / close lifecycle facts | | +| 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 queue details -(raw inbox, transport bookkeeping) may be compacted or archived; the -causal skeleton stays. This distinction prevents the session graph from -becoming a dump truck. +Closed sessions remain queryable as provenance. Operational tail may be +compacted or archived; the causal skeleton stays. --- ## Wire surface -The EINT envelope carries addressing, not session semantics: +The EINT envelope gains session addressing in header metadata: ```text Envelope { - session_id : SessionId + session_id : SessionId // required for session-aware ops intent_id : IntentId + target : IngressTarget // existing routing correlation_id?: CorrelationId // caller-supplied opt-in payload : } ``` -The engine decides everything else: is this session open, is this writer -authorized to submit here, what lane does this enter, what causal parent / -head does it attach to. +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. -The codec does not smuggle session behavior. It serializes facts and -addresses. +### EINT change classification -**On correlation.** The session lane 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 lane -gives scope and ordering, not object identity. Concretely: +Treat the addition as **additive to the semantic envelope model, +potentially breaking to strict encoders/decoders.** Coordinate with 0024. -- `session_id` = scope +- **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 -- lane position = causal order +- worldline head / tick = mutation order (existing primitive) +- session position in `SessionEventLog` = submission order (derived + projection) --- ## Core invariants -1. Every accepted intent belongs to exactly one session. -2. Every effect / receipt references the accepted intent that caused it. -3. A session lane has a total order of accepted intents. -4. A closed session cannot accept new intents. -5. `runUntilIdle(session, quiescent)` returns true iff there is no pending - accepted work AND no in-flight bounded child work for that session. -6. `SessionClosed` implies invariant 5 for the closed session. -7. No null / default session anywhere in the system. +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. +9. Exactly one `SessionId` concept exists in `warp-core`. The + `playback::SessionId` is unified with this cycle's `SessionId`. --- @@ -369,17 +471,19 @@ gives scope and ordering, not object identity. Concretely: ### Human jobs -1. Address an intent to a coherent stream of work without inventing a +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 causal history after the fact for provenance. +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. +session-port, and can query "what did this actor do?" as a normal graph +read. --- @@ -393,18 +497,19 @@ session-port. ### Agent jobs -1. Open a session for a bounded piece of work, submit intents against it, - close it cleanly. +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 kind of idle was asked for. -3. Address intents to multiple worldlines within one session without losing - per-worldline obstruction semantics. + 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 -addressed to a single `SessionId` and programmatically determine when that -stream is fully settled in the engine's causal graph. +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. --- @@ -412,48 +517,55 @@ stream is fully settled in the engine's causal graph. 1. The human runs the cycle's introductory walkthrough (script TBD in implementation phase). -2. The output shows: session opened, three intents submitted, all three - accepted onto the lane, receipts issued, effects quiesced, session - closed. -3. The human can query the closed session's lane and see the total order - of accepted intents without opening any source file. +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 a - confirmation that all bounded child work is done in the open-session - case). -3. The agent determines that no further effects rooted in that session's - intents will be emitted. +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 -This is the design phase. The implementation outline below is **proposed**; -no code lands in this cycle's Phase 1. +Proposed. No code in Phase 1. -1. Define `SessionId`, `PrincipalRef`, `LaneCursor`, `IntentId`, - `EffectId`, `ReceiptId`, `CorrelationId` as Wesley-IR types so all - languages emit identical shapes. -2. Add the Session node type to the causal graph schema. +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 + `writer_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. Extend EINT envelope shape (within 0024's encoding doctrine) to carry - `session_id` and `intent_id` headers. -5. Implement the inbox / lane split, with v1 carrying no priority or - fairness machinery. -6. Implement two-stage close with graceful and abortive modes. -7. Implement `runUntilIdle(session, until)` against the lifecycle event - stream. -8. Add system sessions as a bootstrap concern (`system/bootstrap`, - `system/indexer`, etc.). -9. Document the migration path for clients in companion docs - (`jedit/docs/method/backlog/asap/sessions-migration.md`). -10. Deprecate jedit's `JeditWorldlineSessionPort` once the engine surface - is available; remove it once jedit threads `SessionId` through its - optic-client surface. +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. +6. Implement `SessionEventLog(session_id)` as a read-only projection over + the existing event stream filtered by attribution. +7. Implement two-stage close with graceful and abortive modes, expressed + as session admission-gate state transitions. +8. Implement `runUntilIdle(session, until)` against attributed work. +9. Establish `system/genesis` as a primordial Session created by genesis; + open `system/bootstrap` etc. through normal Open intents. +10. 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. --- @@ -461,50 +573,67 @@ no code lands in this cycle's Phase 1. To be expanded in Phase 2. Minimum coverage required: -- A submitted intent appears on the lane in submit order, with `Accepted` - preceding `ReceiptIssued`. -- A rejected intent never appears on the lane. -- `runUntilIdle(session, receipt)` returns when all in-flight intents - have `ReceiptIssued`, even if effects are still rippling. +- 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 submission order. +- 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 - intents have `EffectsQuiesced`. + attributed intents have `IntentEffectsQuiesced`. - Graceful close drains; abortive close cancels and quiesces cancellation - receipts; both end at `SessionClosed`. -- Two sessions targeting the same worldline: second-writer obstructs with - `baseHeadMismatch`. -- An intent submitted to a closed session is rejected at the inbox, never - reaches the lane. -- A `null` / missing session field on an envelope is a hard reject - (covers the no-null-default invariant). + 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 a + hard reject (covers the no-null-default invariant). - An intent with `target_worldlines.length > 1` is rejected in v1 (covers the deferred multi-worldline invariant). +- `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). --- ## Risks / unknowns -- **Wire shape break.** EINT envelopes already exist (0024). Adding - `session_id` to the header is a breaking change. Coordination with - `0024` matters: ideally the Session header is part of EINT v2 or the - next EINT revision, not a hot patch. -- **Naming collision with `echo-session-proto`.** Addressed above; the - rename must happen in the right order. If proto-split lags, this cycle's - "Session" name lands ambiguously for a window. -- **System session bootstrapping.** If every intent requires a session, - the bootstrap itself needs a session to exist before the first user - intent. Concretely: how does `system/bootstrap` come into being? It - must be a primordial node, established by genesis, not by an intent. - Design needs to spell this out before implementation. -- **Cross-session worldline interaction beyond v1.** The deferral is - honest, but the underlying problem (multi-writer concurrent merge) is - fundamental to causal computing. Deferring it is correct; pretending it - is "later work" rather than "an open research direction" would be - dishonest. +- **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. The wesley-core dependency situation - (echo currently pins `wesley-core = 0.0.4` from crates.io; wesley - trunk has moved) needs to be reconciled before this cycle's - implementation phase, otherwise the generated session types diverge. + 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. --- @@ -514,50 +643,39 @@ To be expanded in Phase 2. Minimum coverage required: 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, lane - contents, and quiescence via the standard graph surface. No special - inspection API required. + 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-worldline intents (type admits plural; v1 enforces singleton). -- Backpressure, fairness, priority, or capability enforcement on the - inbox (seam exists; policies do not). -- Replacing or renaming `echo-session-proto` (coordinated in its own - cycle). -- Sub-sessions, session forking, session merging (potentially interesting, - out of v1). -- Authentication and authorization of writers (`PrincipalRef` is - forward-compatible; v1 carries label only). +- 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; explicitly out). + (forensic side-channel risk; out). - Garbage-collecting closed sessions (closed sessions are durable provenance; only operational tail is compactable). --- -## Open questions for review - -1. Is `PrincipalRef` already a named shape in Echo, or are we introducing - it here? If introducing, does it belong to this cycle or to a separate - identity cycle that this one depends on? -2. Is the EINT envelope shape modification additive or breaking? If - breaking, does it ride 0024 directly or chain after it? -3. System sessions: established at genesis, or established by a - bootstrap intent submitted to a primordial session? Recursion concern. -4. Does the existing causal graph have a notion of "actor-scoped lane" - that Session subsumes, or is the lane structure new to this cycle? -5. The `PLATFORM_echo-session-proto-split` cycle is in `up-next/`. Should - it be pulled and renamed before this cycle's implementation phase to - eliminate the naming-collision window? - ---- - ## Source This design was extracted from a cross-repo conversation rooted in the @@ -565,3 +683,11 @@ 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/method/backlog/up-next/PLATFORM_echo-session-proto-split.md b/docs/method/graveyard/PLATFORM_echo-session-proto-split.md similarity index 68% rename from docs/method/backlog/up-next/PLATFORM_echo-session-proto-split.md rename to docs/method/graveyard/PLATFORM_echo-session-proto-split.md index bef55021..fa5f9041 100644 --- a/docs/method/backlog/up-next/PLATFORM_echo-session-proto-split.md +++ b/docs/method/graveyard/PLATFORM_echo-session-proto-split.md @@ -3,6 +3,18 @@ # Split echo-session-proto into retained bridge contracts vs legacy transport residue +**Retired 2026-05-29.** The networked-session inspection prototype this card +proposed splitting is out of scope; the entire `echo-session-proto` direction +is being retired rather than refactored. The unqualified name "Session" is +reclaimed for the causal-context sense defined in +[0025 — Sessions as Causal Contexts](../../design/0025-sessions-as-causal-contexts/design.md). +Transport-layer concepts should use `Connection` / `TransportConnection` / +similar going forward. + +Original card text preserved below for provenance. + +--- + `echo-session-proto` still mixes two different things: - retained TTD/browser bridge contracts: From a53cac95e8449a17d68aec2e2f77460446bd27ba Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 29 May 2026 08:15:58 -0700 Subject: [PATCH 11/17] docs(design): 0025 pre-RED cleanup patch Eight targeted fixes after directional approval, before Phase 2 RED: 1. principal_ref / principal_label replace writer_ref / writer_label. Session unifies read and write contexts; principal is the canonical name. 2. Drop target_worldlines plural. v1 uses existing IngressTarget. Atomic multi-target submissions deferred as a future cycle with a future shape (NonEmptyList or AtomicIngressBatch). 3. SessionEventLog ordering pinned: engine event log's deterministic event order (LogicalTime / event sequence + content-address tie- breaker). Explicitly not mutation order. 4. IntentRejected gains stage (SessionGate | HeadInboxAdmission | Execution) and reason. Distinguishes unknown-session (no projection), closed-session (appears in that session's projection), and base-head- mismatch (attributed to the valid submitting session). 5. Abortive close grounded: IngressEnvelope must carry session_id attribution and HeadInbox needs a cancel-by-session_id API. Fallback scope call documented if the extension is too invasive. 6. system/genesis carries a concrete PrincipalRef::system("genesis")- shaped identity. No null principal anywhere, even at the bottom turtle. 7. EINT target field is an IngressAddress-shaped protocol value mapped to warp-core::head_inbox::IngressTarget at decode. Codec does not smuggle the Rust enum directly. 8. Quiescence registration rule: bounded child work must be registered before observing parent quiescence; after IntentEffectsQuiesced, no new bounded child may be registered for that intent. Makes quiesced a one-way gate per intent. Added as core invariant #10 and to the test list. Implementation outline expanded to 11 steps (added the HeadInbox attribution extension and the quiescence gate to runUntilIdle). Phase 1 STOP holds: presenting the cleaned design for Phase 2 RED green- light. --- .../design.md | 225 +++++++++++++++--- 1 file changed, 191 insertions(+), 34 deletions(-) diff --git a/docs/design/0025-sessions-as-causal-contexts/design.md b/docs/design/0025-sessions-as-causal-contexts/design.md index 72f147d1..b0f6c817 100644 --- a/docs/design/0025-sessions-as-causal-contexts/design.md +++ b/docs/design/0025-sessions-as-causal-contexts/design.md @@ -24,7 +24,7 @@ Integrates with (does not replace): 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.writer_ref`. + surface, used directly as `Session.principal_ref`. - `Worldline`, `head`, `tick`, `strand`, `braid` — mutation ordering / state-lineage primitives. Session attributes, it does not reorder. @@ -130,8 +130,8 @@ concepts should use `Connection` / `TransportConnection` / similar. ```text Session id : SessionId // unified with existing playback::SessionId - writer_ref : PrincipalRef // existing warp-core type - writer_label? : String // human-readable, optional + principal_ref : PrincipalRef // existing warp-core type + principal_label? : String // human-readable, optional status : Open | Closing | Closed created_at : LogicalTime closed_at? : LogicalTime @@ -190,6 +190,19 @@ derived, and does not own ordering or admission. 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 @@ -198,7 +211,7 @@ It is a queryable view over the existing event stream filtered by SessionOpened IntentSubmitted // session-gated; entered HeadInbox IntentAccepted // worldline-admitted; named in this session -IntentRejected // refused either at session gate or at admission +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 @@ -208,6 +221,40 @@ 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 session lookup itself failed + intent_id : IntentId + stage : SessionGate | HeadInboxAdmission | Execution + reason : UnknownSession | ClosedSession | CapabilityDenied + | BaseHeadMismatch | Cancelled | +} +``` + +Three cases that look similar but record differently: + +- **Unknown session** — rejected at the session gate. Cannot appear in + `SessionEventLog(session_id)`, because the named session does not + exist. The rejection is observable on the engine event log (for + diagnostics and operator visibility) but has no session projection + to land in. +- **Closed session** — rejected at the session gate. 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`** — rejected at `HeadInboxAdmission` by the + existing worldline/head admission logic. 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`. An +unknown-session rejection is engine-level, not session-level. + ### Why two settlement events instead of one `IntentSettled` is rejected as a name because it conflates two distinct @@ -233,6 +280,26 @@ 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 @@ -276,15 +343,34 @@ 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`. -- **Cancel / reject unaccepted work** still pending in `HeadInbox` that is - attributable to this session (where the existing admission policy - permits cancellation). +- **Cancel / reject pending ingress** still queued in `HeadInbox` and + attributable to this session — only feasible because ingress envelopes + carry `session_id` attribution (see below). - **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 work and waits for +- **Abortive close** cancels interruptible accepted work and waits for cancellation quiescence. +### Ingress attribution prerequisite + +Abortive close cannot rescind ingress it cannot find. For "cancel pending +ingress under this `session_id`" to be a real operation, this cycle must: + +- Extend `IngressEnvelope` (in `warp-core::head_inbox`) to carry + `session_id` attribution alongside its existing routing fields. +- Add a `HeadInbox` API surface for rejecting/cancelling pending + envelopes filtered by `session_id`. + +`HeadInbox` remains the queue owner; Session does not reach into it +directly. The cancel API is a request from the session admission gate +that `HeadInbox` honors per its own determinism / ordering rules. + +If the `HeadInbox` extension proves too invasive for v1, fall back to the +weaker variant: abortive close only blocks new submissions and cancels +interruptible accepted work; pending-ingress cancellation is deferred. +This must be a deliberate scope call, not silent gap. + ### Invariant `SessionClosed` means the session no longer has accepted in-flight bounded @@ -322,26 +408,37 @@ coat. Multi-writer concurrent merge semantics are intentionally deferred. --- -## Multi-worldline intents +## Single-target routing in v1; atomic multi-target deferred -The intent type admits plural targets: +V1 session-aware submission carries exactly one routing target, using the +existing warp-core address type: ```text -Intent { - target_worldlines : NonEmptyList - ... +Submission { + session_id : SessionId + intent_id : IntentId + target : IngressTarget // existing warp-core::head_inbox type + payload : } ``` -The v1 protocol enforces `target_worldlines.length == 1`. Atomic -multi-worldline transactions are deferred. +`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 plural type is intentional: it leaves room for future cross-buffer -atomic operations (project-wide rename, refactor-across-files) without -needing a wire-shape break. Implementing those atomically — multiple base -heads, all-or-nothing commit, cross-worldline obstruction, rollback / -compensation, lock ordering — is real machinery, not "just plural." Out of -v1. +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. --- @@ -365,6 +462,28 @@ 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 @@ -390,12 +509,22 @@ The EINT envelope gains session addressing in header metadata: Envelope { session_id : SessionId // required for session-aware ops intent_id : IntentId - target : IngressTarget // existing routing + 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. + 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. @@ -452,9 +581,14 @@ provides scope, not object identity. 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. +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. --- @@ -545,7 +679,7 @@ Proposed. No code in Phase 1. Narrow `ViewSession` to "read/playback view facet attached to a `SessionId`." 2. Add the `Session` node type to the causal graph schema, carrying - `writer_ref: PrincipalRef`, status, lifecycle timestamps, + `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 @@ -553,15 +687,26 @@ Proposed. No code in Phase 1. 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. -6. Implement `SessionEventLog(session_id)` as a read-only projection over - the existing event stream filtered by attribution. -7. Implement two-stage close with graceful and abortive modes, expressed + `session_id` + `intent_id` headers and an `IngressAddress`-shaped + protocol target value that maps to `warp-core::head_inbox::IngressTarget` + at decode. +6. Extend `IngressEnvelope` (in `warp-core::head_inbox`) to carry + `session_id` attribution, and add a `HeadInbox` API for + rejecting/cancelling pending envelopes by `session_id`. If the + extension proves too invasive for v1, fall back to deferring + pending-ingress cancellation per the abortive-close scope call. +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. -8. Implement `runUntilIdle(session, until)` against attributed work. -9. Establish `system/genesis` as a primordial Session created by genesis; - open `system/bootstrap` etc. through normal Open intents. -10. Coordinate the jedit migration via +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 @@ -592,14 +737,26 @@ To be expanded in Phase 2. Minimum coverage required: second session. - An envelope missing `session_id` for a session-aware operation is a hard reject (covers the no-null-default invariant). -- An intent with `target_worldlines.length > 1` is rejected in v1 - (covers the deferred multi-worldline invariant). +- 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). +- A pending ingress envelope attributable to a session being abortively + closed is cancelled by the existing `HeadInbox` cancel-by-`session_id` + API; if that API is deferred per the abortive-close scope call, + pending-ingress cancellation is explicitly out of v1 instead. --- From 34e4984bc835670572bc52c268e0e6e1f0165d84 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 29 May 2026 09:21:42 -0700 Subject: [PATCH 12/17] docs(design): 0025 pre-RED clarifications Three pre-RED clarifications binding on Phase 2 tests: 1. Abortive close v1: commit to weaker variant (B), not the full HeadInbox surgery. IngressEnvelope content-addressing is determinism-critical; bundling Session introduction with IngressEnvelope.session_id and HeadInbox cancel-by-session_id in one cycle multiplies risk inside a strict-determinism engine. V1 abortive close blocks new submissions and cancels interruptible accepted work; pending-ingress cancellation is deferred to a follow-up cycle. Tests assert the deferral explicitly so the scope call is honest, not silent. 2. Wire/runtime naming discipline pinned for RED: IngressAddress is the wire/EINT type; IngressTarget is the warp-core runtime type; the decode boundary maps one to the other. Tests for the wire surface assert on IngressAddress; tests for admission assert on IngressTarget; neither asserts the wire serializes IngressTarget directly. 3. IntentRejected gains a Decode stage and a MissingSession reason, distinct from UnknownSession. Missing means "header absent"; unknown means "header present but does not resolve." MissingSession has no SessionEventLog projection to land in; UnknownSession likewise. ClosedSession appears in that closed session's projection. BaseHeadMismatch is attributed to the valid submitting session at HeadInboxAdmission. Phase 1 STOP holds; awaiting Phase 2 RED scope confirmation. --- .../design.md | 123 ++++++++++++------ 1 file changed, 84 insertions(+), 39 deletions(-) diff --git a/docs/design/0025-sessions-as-causal-contexts/design.md b/docs/design/0025-sessions-as-causal-contexts/design.md index b0f6c817..dd3e2724 100644 --- a/docs/design/0025-sessions-as-causal-contexts/design.md +++ b/docs/design/0025-sessions-as-causal-contexts/design.md @@ -228,32 +228,41 @@ where in the pipeline the refusal happened: ```text IntentRejected { - session_id?: SessionId // absent only when session lookup itself failed - intent_id : IntentId - stage : SessionGate | HeadInboxAdmission | Execution - reason : UnknownSession | ClosedSession | CapabilityDenied - | BaseHeadMismatch | Cancelled | + 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 | } ``` -Three cases that look similar but record differently: +Four cases that look similar but record differently: -- **Unknown session** — rejected at the session gate. Cannot appear in +- **`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. The rejection is observable on the engine event log (for - diagnostics and operator visibility) but has no session projection - to land in. -- **Closed session** — rejected at the session gate. Appears in + 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`** — rejected at `HeadInboxAdmission` by the - existing worldline/head admission logic. Attributed to the valid +- **`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`. An -unknown-session rejection is engine-level, not session-level. +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 @@ -352,24 +361,42 @@ Phrased as a **session admission gate**, not as draining an owned queue: - **Abortive close** cancels interruptible accepted work and waits for cancellation quiescence. -### Ingress attribution prerequisite +### Abortive close v1 scope: pending-ingress cancellation deferred -Abortive close cannot rescind ingress it cannot find. For "cancel pending -ingress under this `session_id`" to be a real operation, this cycle must: +`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 alongside its existing routing fields. + `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; Session does not reach into it -directly. The cancel API is a request from the session admission gate -that `HeadInbox` honors per its own determinism / ordering rules. + 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. -If the `HeadInbox` extension proves too invasive for v1, fall back to the -weaker variant: abortive close only blocks new submissions and cancels -interruptible accepted work; pending-ingress cancellation is deferred. -This must be a deliberate scope call, not silent gap. +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 @@ -525,6 +552,20 @@ 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. @@ -690,11 +731,10 @@ Proposed. No code in Phase 1. `session_id` + `intent_id` headers and an `IngressAddress`-shaped protocol target value that maps to `warp-core::head_inbox::IngressTarget` at decode. -6. Extend `IngressEnvelope` (in `warp-core::head_inbox`) to carry - `session_id` attribution, and add a `HeadInbox` API for - rejecting/cancelling pending envelopes by `session_id`. If the - extension proves too invasive for v1, fall back to deferring - pending-ingress cancellation per the abortive-close scope call. +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 @@ -735,8 +775,12 @@ To be expanded in Phase 2. Minimum coverage required: - 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 a - hard reject (covers the no-null-default invariant). +- 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. @@ -753,10 +797,11 @@ To be expanded in Phase 2. Minimum coverage required: not produce a `SessionEventLog` entry (covers rejection-stage attribution). - A closed-session rejection appears in that session's `SessionEventLog` (covers closed-session provenance). -- A pending ingress envelope attributable to a session being abortively - closed is cancelled by the existing `HeadInbox` cancel-by-`session_id` - API; if that API is deferred per the abortive-close scope call, - pending-ingress cancellation is explicitly out of v1 instead. +- 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. --- From 473d830cdc22410de5a3403c889458ccd4cce135 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 29 May 2026 12:29:30 -0700 Subject: [PATCH 13/17] docs(handoff): 0025 Phase 2 handoff note Doc-only commit closing out Phase 1 design work cleanly. Captures the locked design decisions, the five pre-RED decisions that Phase 2 must resolve before writing tests, the full RED test matrix expressed as invariants (not tests), and an explicit reminder that no Rust tests, stubs, modules, or API surface have been introduced in this cycle. Path 1 chosen: stop after Phase 1, hand off Phase 2 explicitly, do not let RED start implicitly. RED tests against nonexistent warp-core APIs effectively create the public shape of the implementation; that crosses the design-only boundary scoped for this conversation. Phase 2 (RED + GREEN) begins only with explicit implementation greenlight. --- .../phase-2-handoff.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/design/0025-sessions-as-causal-contexts/phase-2-handoff.md 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. From 499ad991334bdb7dcbbef8f7bb84e603487ba004 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 29 May 2026 17:40:06 -0700 Subject: [PATCH 14/17] docs(design): reconcile 0025 internal consistency post-review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-only fixes after a strict review found contradictions and stale language. No design changes — just bringing the doc into sync with the decisions already locked across the cycle's prior commits. request.md: - Drop rejected v1 model language (Session "ingress mailbox" and "accepted causal lane" — both rejected in 8a21f045). - Drop Intent.target_worldlines: NonEmptyList typed acceptance criterion (replaced with single IngressTarget in a53cac95). - Rename "writer reference" / "writer identity" to "principal reference" / "principal identity" per 34e4984b. - Rewrite echo-session-proto-split non-goal as "retired entirely" rather than "coordinated" (retirement landed in 8a21f045). - Expand acceptance criteria to cover the IntentRejected stage/reason surface, the one-way quiescence gate, the abortive-close deferral, the system/genesis primordial bootstrap, the IngressAddress wire boundary, and the no-null-session/principal invariant. design.md: - Close-section bullet list (was: "Cancel / reject pending ingress" listed as a v1 feature with parenthetical link to the deferral subsection) rewritten to match the deferral: pending-ingress cancellation is out of v1. - Non-goal language for atomic multi-target submissions rewritten; no more "type admits plural; v1 enforces singleton" since the design body now uses a single IngressTarget (a53cac95). Future cycle picks both shape and semantics together. - Correlation closing list (session position in SessionEventLog) and test bullet for IntentSubmitted/IntentAccepted ordering both updated to engine event log deterministic order — matches the Ordering subsection. The two had silently disagreed. - Session shape code-block alignment fixed after writer_ref → principal_ref rename (visual columns no longer drifted). - ADR-0008 reference hedged to "per the head_inbox.rs module comment" since the ADR file itself wasn't verified during design. graveyard/PLATFORM_echo-session-proto-split.md: - Replace curly quotes with straight in the preserved original prose for consistency with surrounding documents. phase-2-handoff.md surveyed under the same audit; remaining echo-session-proto-split references in it are correct historical pointers to the graveyard location and do not need changes. --- .../design.md | 38 ++++++----- .../request.md | 68 ++++++++++++++----- .../PLATFORM_echo-session-proto-split.md | 2 +- 3 files changed, 73 insertions(+), 35 deletions(-) diff --git a/docs/design/0025-sessions-as-causal-contexts/design.md b/docs/design/0025-sessions-as-causal-contexts/design.md index dd3e2724..0c1ca985 100644 --- a/docs/design/0025-sessions-as-causal-contexts/design.md +++ b/docs/design/0025-sessions-as-causal-contexts/design.md @@ -57,7 +57,7 @@ becomes a temporary compatibility bridge with a defined deletion criterion ### What this cycle does _not_ do Echo already has a deterministic ingress and admission system -(`head_inbox.rs`, ADR-0008 Phase 3). Worldlines have inboxes; routing +(`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. @@ -129,13 +129,13 @@ concepts should use `Connection` / `TransportConnection` / similar. ```text Session - id : SessionId // unified with existing playback::SessionId - principal_ref : PrincipalRef // existing warp-core 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 + 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: @@ -352,9 +352,9 @@ 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`. -- **Cancel / reject pending ingress** still queued in `HeadInbox` and - attributable to this session — only feasible because ingress envelopes - carry `session_id` attribution (see below). +- **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`. @@ -602,8 +602,9 @@ provides scope, not object identity. - `intent_id` = unit of requested work - `effect_id` / `receipt_id` = result identity - worldline head / tick = mutation order (existing primitive) -- session position in `SessionEventLog` = submission order (derived - projection) +- session position in `SessionEventLog` = engine event log deterministic + order (derived projection; see §Ordering above — not mutation order + and not raw submission order) --- @@ -762,7 +763,9 @@ To be expanded in Phase 2. Minimum coverage required: 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 submission order. + `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 @@ -860,8 +863,11 @@ To be expanded in Phase 2. Minimum coverage required: is required, not optional). - Multi-writer concurrent merge on a single worldline (deferred; v1 is obstruction-only). -- Atomic multi-worldline intents (type admits plural; v1 enforces - singleton). +- 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). diff --git a/docs/design/0025-sessions-as-causal-contexts/request.md b/docs/design/0025-sessions-as-causal-contexts/request.md index dbcb7ad5..86a534bf 100644 --- a/docs/design/0025-sessions-as-causal-contexts/request.md +++ b/docs/design/0025-sessions-as-causal-contexts/request.md @@ -18,10 +18,13 @@ 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 actor / context node with a writer reference, an ingress mailbox, an -accepted causal lane, and an explicit lifecycle. Jedit then holds only a -`SessionId` and threads it through intent submission. The session-port becomes -a temporary compatibility bridge slated for deletion. +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 @@ -53,39 +56,68 @@ extends it. - 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, Worldline, Connection, and Optic. + 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. + 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. -- `Intent.target_worldlines` is typed as `NonEmptyList` with a - v1 invariant of length == 1. Atomic multi-worldline transactions are - deferred and named as deferred. -- Writer identity uses a `PrincipalRef`-shaped forward-compatible type even - if v1 carries only a label. -- Naming collision with existing `echo-session-proto` (transport-protocol - sense) is acknowledged and a disambiguation strategy is stated. +- 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-worldline intents. -- Backpressure, fairness, priority, or capability enforcement on the inbox. -- Replacing the existing `echo-session-proto` transport types in this cycle - (a separate cycle, `echo-session-proto-split`, already exists for that - rename and will be coordinated with this naming reclamation). +- 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 diff --git a/docs/method/graveyard/PLATFORM_echo-session-proto-split.md b/docs/method/graveyard/PLATFORM_echo-session-proto-split.md index fa5f9041..b0583282 100644 --- a/docs/method/graveyard/PLATFORM_echo-session-proto-split.md +++ b/docs/method/graveyard/PLATFORM_echo-session-proto-split.md @@ -39,5 +39,5 @@ This slice should answer: 2. which legacy WVP/session-transport types can be deleted outright 3. whether the retained TTD/browser framing belongs in a renamed crate -The goal is not “save every old protocol type.” The goal is to stop keeping a +The goal is not "save every old protocol type." The goal is to stop keeping a dead session-hub ontology fused to the surviving browser/TTD bridge path. From 7468488745038001498b2d45cbb59869d2a580ba Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 29 May 2026 23:01:01 -0700 Subject: [PATCH 15/17] Fix: canonicalize floats on decode + bound list pre-allocation Two security/determinism fixes in echo-wasm-abi codec, both flagged by Codex P2 on PR #382. codec.rs read_f32_le: Previously returned the raw decoded f32 bit pattern. The writer canonicalizes (NaN -> 0x7fc00000, subnormals -> +0.0, -0.0 -> +0.0) but the reader did not, so an untrusted EINT or query-var payload could submit a non-canonical NaN/subnormal/-0.0 and have it land in generated vars verbatim. Two distinct byte strings then represent the same intended value, breaking the deterministic-codec contract. Wrapped the return in canonicalize_f32 (idempotent on already-canonical inputs; honest senders see no behavior change). codec.rs read_list: Previously did Vec::with_capacity(count) before reading any element bytes. A malformed payload with u32 count = 0xFFFFFFFF followed by zero elements forced a multi-gigabyte pre-allocation (DoS or abort) before validation could run. Now caps initial_capacity at min(count, remaining_bytes_in_buf). Since any list element occupies at least one byte (e.g. a u8 tag), an honest count cannot exceed the byte budget; capping at the budget is looser than any real workload but still defeats the DoS amplification. Verified: cargo test -p echo-wasm-abi green (2/2 lib tests). Resolves PR threads (codex P2) https://github.com/flyingrobots/echo/pull/382 crates/echo-wasm-abi/src/codec.rs:275 https://github.com/flyingrobots/echo/pull/382 crates/echo-wasm-abi/src/codec.rs:305 --- crates/echo-wasm-abi/src/codec.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/echo-wasm-abi/src/codec.rs b/crates/echo-wasm-abi/src/codec.rs index 7bbf189d..d4544088 100644 --- a/crates/echo-wasm-abi/src/codec.rs +++ b/crates/echo-wasm-abi/src/codec.rs @@ -268,11 +268,19 @@ impl<'a> Reader<'a> { Ok(i32::from_le_bytes(raw)) } - /// Read a little-endian f32 (stored as canonicalized bits). + /// 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(f32::from_le_bytes(raw)) + Ok(canonicalize_f32(f32::from_le_bytes(raw))) } /// Read a bool from a single byte (`0x00` = false, `0x01` = true). @@ -297,12 +305,23 @@ impl<'a> Reader<'a> { } /// 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 mut out = Vec::with_capacity(count); + 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)?); } From 8ef2fc9731b83560998d342b1ab0fa58f1ea20bd Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 29 May 2026 23:16:03 -0700 Subject: [PATCH 16/17] Fix: wesley-gen nullable lists, no_std ID encoding, and list-decoder qualification Five P2 codex findings on echo-wesley-gen + supporting reader helper in echo-wasm-abi. codec.rs: added pub Reader::read_byte_array() so the generator can emit fixed-size array reads for no_std ID fields without duplicating take/try_into logic per generated crate. main.rs encode_arg_stmt / encode_field_stmt: - Nullable list branch was emitting w.write_list(&self.field, ...) against an Option> field. Now wraps in write_option when not required. - no_std mode maps GraphQL ID to [u8; 32], but emitters always treated ID like String. Added 'ID' if args.no_std arms in both encode_arg_stmt and encode_field_stmt that emit write_bytes (required) or write_option + write_bytes (nullable). main.rs decode_arg_expr / decode_field_expr: - Nullable list now wraps with read_option + read_list. - no_std ID arms emit r.read_byte_array::<32>() (required) or r.read_option(|r| r.read_byte_array::<32>()) (nullable). main.rs scalar_list_element_decoder: - Took a super_qualified: bool parameter. Arg-context callers pass true (Vars Decode impls live in __echo_wesley_generated; user types need super::). Field-context callers pass false. Previously user-defined list element types under Vars compiled with bare 'Tag::decode' which the compiler could not resolve. Resolves PR threads (codex P2) https://github.com/flyingrobots/echo/pull/382 crates/echo-wesley-gen/src/main.rs:1804 https://github.com/flyingrobots/echo/pull/382 crates/echo-wesley-gen/src/main.rs:1811 https://github.com/flyingrobots/echo/pull/382 crates/echo-wesley-gen/src/main.rs:1882 https://github.com/flyingrobots/echo/pull/382 crates/echo-wesley-gen/src/main.rs:1979 --- crates/echo-wasm-abi/src/codec.rs | 7 +++ crates/echo-wesley-gen/src/main.rs | 87 ++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/crates/echo-wasm-abi/src/codec.rs b/crates/echo-wasm-abi/src/codec.rs index d4544088..27961581 100644 --- a/crates/echo-wasm-abi/src/codec.rs +++ b/crates/echo-wasm-abi/src/codec.rs @@ -224,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)?; diff --git a/crates/echo-wesley-gen/src/main.rs b/crates/echo-wesley-gen/src/main.rs index 6a5b273b..f08ca854 100644 --- a/crates/echo-wesley-gen/src/main.rs +++ b/crates/echo-wesley-gen/src/main.rs @@ -1799,15 +1799,27 @@ 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_list(&self.#field_name, #inner_encode)?; + 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)?; }, } @@ -1822,6 +1834,9 @@ fn encode_arg_stmt(arg: &ir::ArgDefinition, args: &Args) -> TokenStream { "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))?; }, @@ -1838,16 +1853,28 @@ fn encode_arg_stmt(arg: &ir::ArgDefinition, args: &Args) -> TokenStream { fn decode_arg_expr(arg: &ir::ArgDefinition, args: &Args) -> TokenStream { let field_name = safe_ident(&arg.name); if arg.list { - let inner_decode = scalar_list_element_decoder(&arg.type_name, args); + // 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_list(#inner_decode)? + #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); @@ -1860,6 +1887,7 @@ fn decode_arg_expr(arg: &ir::ArgDefinition, args: &Args) -> TokenStream { "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); @@ -1876,18 +1904,28 @@ fn decode_arg_expr(arg: &ir::ArgDefinition, args: &Args) -> TokenStream { fn encode_field_stmt(field: &ir::FieldDefinition, args: &Args) -> TokenStream { let field_name = safe_ident(&field.name); if field.list { - // [T!]! required 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_list(&self.#field_name, #inner_encode)?; + 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 @@ -1906,6 +1944,9 @@ fn encode_field_stmt(field: &ir::FieldDefinition, args: &Args) -> TokenStream { "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))?; }, @@ -1923,16 +1964,26 @@ fn encode_field_stmt(field: &ir::FieldDefinition, args: &Args) -> TokenStream { fn decode_field_expr(field: &ir::FieldDefinition, args: &Args) -> TokenStream { let field_name = safe_ident(&field.name); if field.list { - let inner_decode = scalar_list_element_decoder(&field.type_name, args); + // 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_list(#inner_decode)? + #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); @@ -1945,6 +1996,7 @@ fn decode_field_expr(field: &ir::FieldDefinition, args: &Args) -> TokenStream { "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); @@ -1967,7 +2019,20 @@ fn scalar_list_element_encoder(type_name: &str, _args: &Args) -> TokenStream { } /// Generate a list element decoder closure for `read_list`. -fn scalar_list_element_decoder(type_name: &str, _args: &Args) -> TokenStream { +/// +/// `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 { match type_name { "Boolean" => quote! { |r| r.read_bool() }, "Int" => quote! { |r| r.read_i32_le() }, @@ -1975,7 +2040,11 @@ fn scalar_list_element_decoder(type_name: &str, _args: &Args) -> TokenStream { "String" | "ID" => quote! { |r| r.read_string(usize::MAX) }, _ => { let ty = safe_ident(type_name); - quote! { |r| #ty::decode(r) } + if super_qualified { + quote! { |r| super::#ty::decode(r) } + } else { + quote! { |r| #ty::decode(r) } + } } } } From f25e07a298cf13e018acaeb42561805b12f5d607 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sat, 30 May 2026 10:54:39 -0700 Subject: [PATCH 17/17] Fix: address PR #382 review threads (10 fixes across wesley-gen, codec, docs) Code Lawyer pass on the 11 unresolved review threads on PR #382. wesley-gen list element no_std ID handling (codex P2 / coderabbit Critical): 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 -> Vec<[u8; 32]> (decode), neither of which compile. Both helpers now thread args through, gate on id_is_bytes = args.no_std && type_name == "ID", and emit write_bytes / read_byte_array::<32> arms matching the scalar-helper fix from 8ef2fc97. New consumer-crate compile regression test pins this in test_no_std_id_list_field_compiles_in_consumer_crate. wesley-gen codec trait imports inside generated modules (coderabbit Critical): Generated Encode/Decode impls call .encode(w) / Type::decode(r) on nested user types via method/associated-fn syntax. Bringing echo_wasm_abi::codec::{Encode, Decode} into scope at the top of the generated module AND inside __echo_wesley_generated removes a trait-resolution dependency on the implementing impl block at every call site. wesley-gen ir.codec_id normalization (coderabbit Major): Generator advertised CODEC_ID = "le-binary-v1" but artifact hash, observer identity, and footprint certificate preimages read ir.codec_id directly, so an IR declaring "cbor-canon-v1" produced an artifact whose hash was derived under the old codec while claiming the new one. ir.codec_id is now normalized to DEFAULT_CODEC_ID immediately after parsing. wesley-gen test fixtures: cbor-canon-v1 -> le-binary-v1 (coderabbit Major): All eight inline IR snippets in tests/generation.rs updated so the fixtures prove the new wire contract rather than relying on silent metadata rewriting. wesley-gen fnv1_step rename (coderabbit Minor): fnv1a_step multiplies before xor (FNV-1) while name/doc claimed FNV-1a. Renamed helper, updated doc to call out the misnomer, noted that stable_op_id_pinned vectors lock the contract to this FNV-1 ordering. No behavioral change. wasm-abi pinned wire vectors for rope ops (coderabbit Major): replace_range_as_tick and create_checkpoint only had local roundtrips; encoder/decoder drift could co-mutate silently. Added four pinned literal-byte fixtures covering minimal + with-author for replace_range_as_tick and manual-save-with-label + auto-save- no-label for create_checkpoint. Each both encodes -> matches the literal AND decodes the literal back to the original struct. wasm-abi CodecError::Trailing regression tests (coderabbit Trivial): Locked the public decode_from_bytes contract with three tests using a tiny OneInt(i32) Decode shape: exact-payload-accepted, one- trailing-byte-rejected, many-trailing-bytes-rejected. Doc threads: - 0024-universal-le-binary-codec/design.md: removed the "Adding a new variant at the end is safe" enum-compatibility claim; framed declaration-order rule as a same-version determinism guarantee consistent with the SCHEMA_SHA256 hard-gate. - DOCS_no-published-umbrella-for-warp-optics.md: canonicalized "WARP DRIVE" -> "WARPDrive". - PLATFORM_wesley-emitted-fixture-vectors.md: fixed stale repo-root path locator. Backlog (cool ideas): - PLATFORM_wesley-gen-test-loop-speedup.md: documents the iteration- cost sources observed during this review pass (60-120s per integration test, 4m53s pre-commit) and proposes shared target- dir for generated consumer crates, pre-built wesley-gen binary in tests, and crate-scoped pre-commit cargo check. Verified: echo-wasm-abi --lib 80/80 (incl. new Trailing tests), echo-wasm-abi --test jedit_rope_cross_boundary 16/16 (incl. four new pinned-byte tests), echo-wesley-gen --bins 1/1 (stable_op_id_pinned), and five targeted wesley-gen integration tests pass. Resolves PR #382 review threads: https://github.com/flyingrobots/echo/pull/382 --- crates/echo-wasm-abi/src/codec.rs | 52 ++++++ .../tests/jedit_rope_cross_boundary.rs | 117 ++++++++++++++ crates/echo-wesley-gen/src/main.rs | 61 +++++-- crates/echo-wesley-gen/tests/generation.rs | 60 ++++++- .../0024-universal-le-binary-codec/design.md | 8 +- ...S_no-published-umbrella-for-warp-optics.md | 4 +- ...PLATFORM_wesley-emitted-fixture-vectors.md | 4 +- .../PLATFORM_wesley-gen-test-loop-speedup.md | 151 ++++++++++++++++++ 8 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 docs/method/backlog/cool-ideas/PLATFORM_wesley-gen-test-loop-speedup.md diff --git a/crates/echo-wasm-abi/src/codec.rs b/crates/echo-wasm-abi/src/codec.rs index 27961581..203675e4 100644 --- a/crates/echo-wasm-abi/src/codec.rs +++ b/crates/echo-wasm-abi/src/codec.rs @@ -661,6 +661,58 @@ mod tests { 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] diff --git a/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs b/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs index 276a4685..8829af26 100644 --- a/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs +++ b/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs @@ -345,6 +345,70 @@ fn replace_range_as_tick_vars_roundtrips_with_optional_author() { 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 { @@ -370,3 +434,56 @@ fn create_checkpoint_vars_roundtrips_with_auto_save_and_no_label() { 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-wesley-gen/src/main.rs b/crates/echo-wesley-gen/src/main.rs index f08ca854..a91db2c2 100644 --- a/crates/echo-wesley-gen/src/main.rs +++ b/crates/echo-wesley-gen/src/main.rs @@ -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)?; @@ -156,7 +163,7 @@ fn echo_ir_from_schema_sdl(schema_sdl: &str) -> Result { .map(type_definition_from_wesley) .collect(), ops, - codec_id: Some("le-binary-v1".to_string()), + codec_id: Some(DEFAULT_CODEC_ID.to_string()), registry_version: Some(DEFAULT_REGISTRY_VERSION), }) } @@ -176,20 +183,25 @@ fn operation_type_rank(operation_type: wesley_core::OperationType) -> u8 { } } -/// FNV-1a 32-bit op id derivation. 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. +/// 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 = fnv1a_step(hash, operation_type_rank(*operation_type)); + hash = fnv1_step(hash, operation_type_rank(*operation_type)); for byte in field_name.as_bytes() { - hash = fnv1a_step(hash, *byte); + hash = fnv1_step(hash, *byte); } hash } -fn fnv1a_step(hash: u32, byte: u8) -> u32 { +fn fnv1_step(hash: u32, byte: u8) -> u32 { hash.wrapping_mul(16_777_619) ^ u32::from(byte) } @@ -247,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 { @@ -259,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 = "le-binary-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")); @@ -578,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); @@ -2008,11 +2033,15 @@ fn decode_field_expr(field: &ir::FieldDefinition, args: &Args) -> TokenStream { } /// Generate a list element encoder closure for `write_list`. -fn scalar_list_element_encoder(type_name: &str, _args: &Args) -> TokenStream { +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) }, } @@ -2028,15 +2057,15 @@ fn scalar_list_element_encoder(type_name: &str, _args: &Args) -> TokenStream { /// 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 { +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); diff --git a/crates/echo-wesley-gen/tests/generation.rs b/crates/echo-wesley-gen/tests/generation.rs index d1ee3def..764cd9ca 100644 --- a/crates/echo-wesley-gen/tests/generation.rs +++ b/crates/echo-wesley-gen/tests/generation.rs @@ -1302,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": [ @@ -1355,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": [ { @@ -1532,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": [ @@ -1558,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": [ @@ -1614,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": [ @@ -1643,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": [ { @@ -1681,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": [ { @@ -1719,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": [ @@ -1812,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 index e2d2765c..5c73897a 100644 --- a/docs/design/0024-universal-le-binary-codec/design.md +++ b/docs/design/0024-universal-le-binary-codec/design.md @@ -81,8 +81,12 @@ a new codec version. **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. Adding a -new variant at the end is safe. Reordering or inserting is a breaking change. +**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. --- 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 index e2c85cdf..df086e93 100644 --- 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 @@ -115,5 +115,5 @@ from imagined needs. 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 WARP DRIVE goes from vapor to v0.0.1 — the umbrella story is - what justifies WARP DRIVE existing at all +- 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/cool-ideas/PLATFORM_wesley-emitted-fixture-vectors.md b/docs/method/backlog/cool-ideas/PLATFORM_wesley-emitted-fixture-vectors.md index 7f7cf63c..136cf646 100644 --- a/docs/method/backlog/cool-ideas/PLATFORM_wesley-emitted-fixture-vectors.md +++ b/docs/method/backlog/cool-ideas/PLATFORM_wesley-emitted-fixture-vectors.md @@ -36,8 +36,8 @@ equal `hex`. The fixture file is the cross-boundary contract. Today (post-0024) cross-language byte parity is asserted by hand-duplicated hex literals in two places: -- `echo/crates/echo-wasm-abi/tests/jedit_rope_cross_boundary.rs` -- `jedit/spec/rope-codec.spec.mjs` +- `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`.) 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//