--- id: SMH-PLAN-82POBOKB type: plan title: "Session Arena: Payloads as Bytes" spec: SMH-SPEC-SPEC0001 status: approved depends_on: [SMH-PLAN-CORE0002] --- # Session Arena: Payloads as Bytes ## Entry - Memory law in `.system/RULES.md` (index arena, bytes at rest, lifetime groups). - `smith-alloc` bounds pin the current cost: `fork_at(1000)` = 2002 allocations, mock turn = 220. - SMH-RESEARCH-4KD6V6IH names `Entry.content: serde_json::Value` and per-entry `String`/`Vec` as the floor. ## Problem `Entry` owns a heap tree (`Message` → `Vec` → `String` | `serde_json::Value`). Every traversal that must own its result (fork, load, request build) pays two or more allocations per entry, and every entry at rest is scattered across the heap. Frames already encode entries as canonical CBOR; the tree is re-created on load only to be re-encoded on write. ## Decisions - Entry content lives as canonical CBOR bytes, at rest and in memory; it is decoded on demand into the consuming scope (turn, frame) and never held decoded in the session. - The session owns one contiguous payload arena (`Vec`); branch records are fixed-size and point into it by `u32` span. Ancestry, trace, fork, and compaction-boundary logic never decode content. - The record carries what traversal needs: `EntryKind` with pairing identifiers (`MessageId` + `Role`, `ToolCallId`, `ok`, compaction flag). Anything else is a decode. - Wire shape follows: an entry frame is `{id, parent, timestamp_ms, kind, content: bytes}` where `content` is the canonical CBOR of `EntryContent`. `VERSION_ENTRY` becomes 2; no migration (prototype phase). - Canonicalization happens once, at `EntryContent` → bytes; decoded content is never canonicalized again. - Text pool (spans into one `String` per session) is a later step; it only pays once request build serializes straight from bytes. ## Shapes ```text Session { id, branches: Vec, payloads: Vec, active_branch_idx, compaction_boundary } Branch { branch_id, entries: Vec, selected_idx } Entry { id: EntryId, parent: Option, timestamp_ms: i64, kind: EntryKind, content: Span } Span { offset: u32, len: u32 } EntryKind { Message { id: MessageId, role: Role } | ToolCall { call_id } | ToolResult { call_id, ok } | Meta { compaction: bool } } EntryFrame{ id, parent, timestamp_ms, kind, content: Vec } wire shape, owned, serde EntryContent unchanged, decoded view ``` Session API: - `Session::append(&mut self, content: &EntryContent) -> Result<&Entry>` encodes once into the arena after the selected leaf; stored parent links outside that chain only enter through frames. - `Session::content(&self, entry: &Entry) -> Result` decodes on demand. - `Session::bytes(&self, entry: &Entry) -> &[u8]` for writers and hashing. - `Session::fork_at` copies the retained records and one arena slice. - `Session::from_frames` copies each frame's `content` bytes into the arena; no decode. - `Entry::message(..)`/`Entry::new(..)` disappear; construction goes through the session or `EntryFrame`. ## Order 1. Add `EntryKind`, `Span`, `EntryFrame`; frame v2 encodes `EntryFrame`; frame tests on the new shape. 2. Move the arena into `Session`; `append`, `content`, `bytes`, `fork_at`, `from_frames` over spans; session tests. 3. Writer and tools record through `Session::append` and write `EntryFrame` from arena bytes. 4. Agent request build and compaction decode per turn; trace and RPC read `EntryKind`. 5. Tighten bounds: `fork_at(1000)` ≤ 3, mock turn measured and pinned, new bound for `from_frames(1000)`. 6. Deslop and compress passes. ## Interfaces - `smith-core::session`: `Session::{append, content, bytes}`, `EntryKind`, `Span`, `EntryFrame`. - `smith-core::frame`: `Frame::Known { entry: EntryFrame }`, `VERSION_ENTRY = 2`. - `smith-core::trace`: reads `EntryKind` only. ## Risks - Every content read is a decode; hot readers must decode once per turn, not per access. Request build is the only per-turn reader today. - `u32` spans cap the arena at 4 GiB per session; the p99 session is ≈ 10 MiB, so the cap is a hard ceiling recorded, not reached. - Frame decode still allocates the `EntryFrame.content` `Vec` per frame before the arena copy; the reader can hand a slice instead in a later step. - Sibling branches duplicate their shared prefix records (not bytes: spans point at the same arena); acceptable, unchanged from today. ## Exit - `cargo x check`, `lint`, `arch`, `audit` green; bounds tightened, none loosened. - Spec text for the in-memory model and frame v2 lives in SMH-SPEC-SPEC0001 Sessions.