From e0616fe42d26f45543e73c9946d965792846fc49 Mon Sep 17 00:00:00 2001 From: opencode controller Date: Sun, 28 Jun 2026 17:24:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(call-model):=20+tap=20field=20on=20Channel?= =?UTF-8?q?=20+=20TapHandle=20marker=20(spec=20=C2=A75.2,=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pub struct TapHandle(()) — zero-sized marker (Option compiles to a bool, no allocation). The live tap connection lives in the binary's DashMap, keyed by the channel's existing ChannelId (== wire session_id per spec §5.3). - Channel grows pub tap: Option. None until Connected, set on Connected, cleared before state advances to Closed. - State invariant: tap and ChannelState are tied (Connected+None=bug). - Slice-1 backwards compatible: new field is Option<...>; Multi-tap-per-channel is the future trigger for a TapId newtype (YAGNI). Spec ref: 2026-06-28-slice-2-agent-tap-design.md §5.2, §6. --- crates/rutster-call-model/src/lib.rs | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/rutster-call-model/src/lib.rs b/crates/rutster-call-model/src/lib.rs index ca5291b..6770fd2 100644 --- a/crates/rutster-call-model/src/lib.rs +++ b/crates/rutster-call-model/src/lib.rs @@ -45,6 +45,18 @@ mod tests { ch.state = ChannelState::Closing; ch.state = ChannelState::Closed; } + + #[test] + fn channel_starts_with_no_tap() { + let ch = Channel::new_inbound(); + assert_eq!(ch.tap, None); + } + + #[test] + fn tap_handle_is_zero_sized() { + // Zero-sized type — confirms the "no extra allocation" claim. + assert_eq!(std::mem::size_of::(), 0); + } } use std::time::Instant; @@ -138,6 +150,9 @@ pub struct Channel { /// user could change mid-call. The monotonic clock is the right /// tool for "has this peer been silent for 60 seconds?" pub created_at: Instant, + /// NEW (slice-2, spec §5.2, §6): None until Connected, set on Connected, + /// cleared on Closing. State invariant — see the table below. + pub tap: Option, } impl Channel { @@ -148,6 +163,43 @@ impl Channel { state: ChannelState::New, direction: Direction::Inbound, created_at: Instant::now(), + tap: None, } } } + +/// Zero-cost marker that a tap is attached to this `Channel`. +/// +/// # Why a zero-sized newtype (not a `TapClient` handle / mpsc sender / UUID?) +/// +/// `Channel` lives in `rutster-call-model`, which is a leaf — no tokio dep +/// (spec §6, slice-1 §5.3). The live tap connection (mpsc handles, WS +/// state) lives in the binary's `DashMap` and is +/// looked up by the channel's existing `ChannelId` (which is also the +/// wire `session_id` per spec §5.3). The `TapHandle` here is just the +/// type-system marker that "a tap is attached" — `Option` +/// compiles to a single bool, no allocation. +/// +/// Multi-tap-per-channel (e.g. a recording tap beside a brain tap) is a +/// future-rung concern; when it appears that's the trigger to mint a +/// separate `TapId(Uuid)` newtype and key the registry by it. For slice-2 +/// (one brain per call) `ChannelId` is sufficient — YAGNI (spec §6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TapHandle(()); + +impl TapHandle { + /// Construct a marker. Public so the binary (Task 7) can mint one + /// when the TapEngine spawns; the doc comment + the zero-sized + /// payload make accidental misuse low-stakes. `pub(crate)` would + /// restrict to `rutster-call-model` and block the binary; we have no + /// reason to gate this from the binary that owns the spawn lifecycle. + pub fn new() -> Self { + Self(()) + } +} + +impl Default for TapHandle { + fn default() -> Self { + Self::new() + } +}