// ── Session journal record types ──────────────────────────────────────────── /// Record type discriminant for session start events. pub const SESSION_RECORD_START: u8 = 0x01; /// Record type discriminant for session signal events. pub const SESSION_RECORD_SIGNAL: u8 = 0x02; /// Record type discriminant for session close events. pub const SESSION_RECORD_CLOSE: u8 = 0x03; /// Monotonic sequence number for session writes. /// /// Incremented once per session write operation (preference signal, /// annotation, search query). Used by the receiver to enforce idempotent /// replay: a write with seqno <= hwm is a no-op. #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, )] pub struct SessionSeqNo(pub u64); impl SessionSeqNo { pub const ZERO: Self = Self(0); #[must_use] pub const fn next(self) -> Self { Self(self.0 + 1) } } impl std::fmt::Display for SessionSeqNo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ssn:{}", self.0) } } /// A session event decoded from the session journal. /// /// These events are stored in a separate append-only file (`sessions.log`) /// alongside the signal WAL. They are used to restore active sessions on /// crash recovery. #[derive(Debug, Clone, PartialEq)] pub enum SessionWalEvent { /// A session was started. Start { session_id: u64, user_id: u64, started_at_ns: u64, agent_id: String, policy_name: String, }, /// A signal was written within a session. Signal { session_id: u64, entity_id: u64, weight: f32, ts_ns: u64, signal_name: String, annotation: Option, // m8p4 extensions (default None for legacy events) session_seqno: Option, idempotency_key: Option, }, /// A session was closed. Close { session_id: u64 }, } /// Encode a session event to bytes for the session journal. /// /// Format: `[len: u32 LE][type: u8][payload bytes]` /// /// **Start payload**: `[session_id: u64 LE][user_id: u64 LE][started_at_ns: u64 LE]` /// `[agent_id_len: u16 LE][agent_id: bytes][policy_name_len: u16 LE][policy_name: bytes]` /// /// **Signal payload**: `[session_id: u64 LE][entity_id: u64 LE][weight: f32 LE][ts_ns: u64 LE]` /// `[signal_name_len: u16 LE][signal_name: bytes][has_annotation: u8]` /// `[if has_annotation: annotation_len: u16 LE, annotation: bytes]` /// /// **Close payload**: `[session_id: u64 LE]` #[must_use] #[allow(clippy::cast_possible_truncation)] pub fn encode_session_event(event: &SessionWalEvent) -> Vec { // Encode the payload first, then prepend the length+type header. let mut payload = Vec::new(); match event { SessionWalEvent::Start { session_id, user_id, started_at_ns, agent_id, policy_name, } => { payload.push(SESSION_RECORD_START); payload.extend_from_slice(&session_id.to_le_bytes()); payload.extend_from_slice(&user_id.to_le_bytes()); payload.extend_from_slice(&started_at_ns.to_le_bytes()); payload.extend_from_slice(&(agent_id.len() as u16).to_le_bytes()); payload.extend_from_slice(agent_id.as_bytes()); payload.extend_from_slice(&(policy_name.len() as u16).to_le_bytes()); payload.extend_from_slice(policy_name.as_bytes()); } SessionWalEvent::Signal { session_id, entity_id, weight, ts_ns, signal_name, annotation, session_seqno, idempotency_key, } => { payload.push(SESSION_RECORD_SIGNAL); payload.extend_from_slice(&session_id.to_le_bytes()); payload.extend_from_slice(&entity_id.to_le_bytes()); payload.extend_from_slice(&weight.to_le_bytes()); payload.extend_from_slice(&ts_ns.to_le_bytes()); payload.extend_from_slice(&(signal_name.len() as u16).to_le_bytes()); payload.extend_from_slice(signal_name.as_bytes()); match annotation { Some(ann) => { payload.push(1u8); payload.extend_from_slice(&(ann.len() as u16).to_le_bytes()); payload.extend_from_slice(ann.as_bytes()); } None => { payload.push(0u8); } } // m8p4: session_seqno (optional u64) match session_seqno { Some(seqno) => { payload.push(1u8); payload.extend_from_slice(&seqno.0.to_le_bytes()); } None => { payload.push(0u8); } } // m8p4: idempotency_key (optional u128) match idempotency_key { Some(key) => { payload.push(1u8); payload.extend_from_slice(&key.to_le_bytes()); } None => { payload.push(0u8); } } } SessionWalEvent::Close { session_id } => { payload.push(SESSION_RECORD_CLOSE); payload.extend_from_slice(&session_id.to_le_bytes()); } } let len = payload.len() as u32; let mut buf = Vec::with_capacity(4 + payload.len()); buf.extend_from_slice(&len.to_le_bytes()); buf.extend(payload); buf } /// Decode all session events from a session journal file's contents. /// /// Stops at the first truncated or malformed record. This is the correct /// behavior for crash recovery: a torn write at the end of the file is /// simply ignored. #[must_use] pub fn decode_session_events(bytes: &[u8]) -> Vec { let mut events = Vec::new(); let mut pos = 0; while pos + 4 <= bytes.len() { let record_len = u32::from_le_bytes([bytes[pos], bytes[pos + 1], bytes[pos + 2], bytes[pos + 3]]) as usize; pos += 4; if pos + record_len > bytes.len() || record_len == 0 { // Truncated or zero-length record -- stop. break; } let record_end = pos + record_len; let record_type = bytes[pos]; pos += 1; match record_type { SESSION_RECORD_START => { if let Some(event) = decode_start_record(bytes, &mut pos, record_end) { events.push(event); } else { break; } } SESSION_RECORD_SIGNAL => { if let Some(event) = decode_signal_record(bytes, &mut pos, record_end) { events.push(event); } else { break; } } SESSION_RECORD_CLOSE => { if pos + 8 > record_end { break; } let session_id = read_u64_le(bytes, &mut pos); events.push(SessionWalEvent::Close { session_id }); } _ => { // Unknown record type -- skip handled by `pos = record_end` below. } } // Ensure pos is at the expected record boundary. pos = record_end; } events } /// Helper: read a little-endian u64 from `bytes` at `*pos`, advancing `*pos`. fn read_u64_le(bytes: &[u8], pos: &mut usize) -> u64 { let v = u64::from_le_bytes([ bytes[*pos], bytes[*pos + 1], bytes[*pos + 2], bytes[*pos + 3], bytes[*pos + 4], bytes[*pos + 5], bytes[*pos + 6], bytes[*pos + 7], ]); *pos += 8; v } /// Helper: read a little-endian u16 from `bytes` at `*pos`, advancing `*pos`. fn read_u16_le(bytes: &[u8], pos: &mut usize) -> u16 { let v = u16::from_le_bytes([bytes[*pos], bytes[*pos + 1]]); *pos += 2; v } /// Decode a Start record from the payload region. fn decode_start_record(bytes: &[u8], pos: &mut usize, end: usize) -> Option { if *pos + 24 > end { return None; } let session_id = read_u64_le(bytes, pos); let user_id = read_u64_le(bytes, pos); let started_at_ns = read_u64_le(bytes, pos); if *pos + 2 > end { return None; } let agent_len = read_u16_le(bytes, pos) as usize; if *pos + agent_len > end { return None; } let agent_id = String::from_utf8_lossy(&bytes[*pos..*pos + agent_len]).to_string(); *pos += agent_len; if *pos + 2 > end { return None; } let policy_len = read_u16_le(bytes, pos) as usize; if *pos + policy_len > end { return None; } let policy_name = String::from_utf8_lossy(&bytes[*pos..*pos + policy_len]).to_string(); *pos += policy_len; Some(SessionWalEvent::Start { session_id, user_id, started_at_ns, agent_id, policy_name, }) } /// Decode a Signal record from the payload region. fn decode_signal_record(bytes: &[u8], pos: &mut usize, end: usize) -> Option { // session_id(8) + entity_id(8) + weight(4) + ts_ns(8) = 28 if *pos + 28 > end { return None; } let session_id = read_u64_le(bytes, pos); let entity_id = read_u64_le(bytes, pos); let weight = f32::from_le_bytes([ bytes[*pos], bytes[*pos + 1], bytes[*pos + 2], bytes[*pos + 3], ]); *pos += 4; let ts_ns = read_u64_le(bytes, pos); if *pos + 2 > end { return None; } let sig_len = read_u16_le(bytes, pos) as usize; if *pos + sig_len > end { return None; } let signal_name = String::from_utf8_lossy(&bytes[*pos..*pos + sig_len]).to_string(); *pos += sig_len; if *pos + 1 > end { return None; } let has_annotation = bytes[*pos] != 0; *pos += 1; let annotation = if has_annotation { if *pos + 2 > end { return None; } let ann_len = read_u16_le(bytes, pos) as usize; if *pos + ann_len > end { return None; } let ann = String::from_utf8_lossy(&bytes[*pos..*pos + ann_len]).to_string(); *pos += ann_len; Some(ann) } else { None }; // m8p4: session_seqno (backward-compatible — absent in legacy records) let session_seqno = if *pos < end { let has_seqno = bytes[*pos]; *pos += 1; if has_seqno != 0 && *pos + 8 <= end { let v = read_u64_le(bytes, pos); Some(SessionSeqNo(v)) } else { None } } else { None }; // m8p4: idempotency_key (backward-compatible — absent in legacy records) let idempotency_key = if *pos < end { let has_key = bytes[*pos]; *pos += 1; if has_key != 0 && *pos + 16 <= end { let v = u128::from_le_bytes([ bytes[*pos], bytes[*pos + 1], bytes[*pos + 2], bytes[*pos + 3], bytes[*pos + 4], bytes[*pos + 5], bytes[*pos + 6], bytes[*pos + 7], bytes[*pos + 8], bytes[*pos + 9], bytes[*pos + 10], bytes[*pos + 11], bytes[*pos + 12], bytes[*pos + 13], bytes[*pos + 14], bytes[*pos + 15], ]); *pos += 16; Some(v) } else { None } } else { None }; Some(SessionWalEvent::Signal { session_id, entity_id, weight, ts_ns, signal_name, annotation, session_seqno, idempotency_key, }) } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; #[test] fn session_start_roundtrip() { let event = SessionWalEvent::Start { session_id: 42, user_id: 100, started_at_ns: 1_000_000_000, agent_id: "test-agent".to_string(), policy_name: "default_policy".to_string(), }; let encoded = encode_session_event(&event); let decoded = decode_session_events(&encoded); assert_eq!(decoded.len(), 1); assert_eq!(decoded[0], event); } #[test] fn session_signal_roundtrip_with_annotation() { let event = SessionWalEvent::Signal { session_id: 7, entity_id: 999, weight: 1.5, ts_ns: 2_000_000_000, signal_name: "reward".to_string(), annotation: Some("jazz fusion".to_string()), session_seqno: None, idempotency_key: None, }; let encoded = encode_session_event(&event); let decoded = decode_session_events(&encoded); assert_eq!(decoded.len(), 1); assert_eq!(decoded[0], event); } #[test] fn session_signal_roundtrip_without_annotation() { let event = SessionWalEvent::Signal { session_id: 7, entity_id: 999, weight: 1.5, ts_ns: 2_000_000_000, signal_name: "view".to_string(), annotation: None, session_seqno: None, idempotency_key: None, }; let encoded = encode_session_event(&event); let decoded = decode_session_events(&encoded); assert_eq!(decoded.len(), 1); assert_eq!(decoded[0], event); } #[test] fn session_close_roundtrip() { let event = SessionWalEvent::Close { session_id: 42 }; let encoded = encode_session_event(&event); let decoded = decode_session_events(&encoded); assert_eq!(decoded.len(), 1); assert_eq!(decoded[0], event); } #[test] fn session_multiple_events_roundtrip() { let events = vec![ SessionWalEvent::Start { session_id: 1, user_id: 10, started_at_ns: 100, agent_id: "agent-a".to_string(), policy_name: "policy-1".to_string(), }, SessionWalEvent::Signal { session_id: 1, entity_id: 42, weight: 1.0, ts_ns: 200, signal_name: "view".to_string(), annotation: None, session_seqno: None, idempotency_key: None, }, SessionWalEvent::Signal { session_id: 1, entity_id: 43, weight: 2.0, ts_ns: 300, signal_name: "reward".to_string(), annotation: Some("good content".to_string()), session_seqno: None, idempotency_key: None, }, SessionWalEvent::Close { session_id: 1 }, ]; let mut all_bytes = Vec::new(); for event in &events { all_bytes.extend(encode_session_event(event)); } let decoded = decode_session_events(&all_bytes); assert_eq!(decoded.len(), events.len()); for (orig, dec) in events.iter().zip(decoded.iter()) { assert_eq!(orig, dec); } } #[test] fn session_decode_truncated_stops_cleanly() { let event = SessionWalEvent::Start { session_id: 1, user_id: 10, started_at_ns: 100, agent_id: "agent".to_string(), policy_name: "policy".to_string(), }; let encoded = encode_session_event(&event); // Truncate the record mid-way. let truncated = &encoded[..encoded.len() / 2]; let decoded = decode_session_events(truncated); assert!(decoded.is_empty(), "truncated record should be skipped"); } #[test] fn session_decode_partial_second_record_stops() { let e1 = SessionWalEvent::Close { session_id: 1 }; let e2 = SessionWalEvent::Start { session_id: 2, user_id: 20, started_at_ns: 200, agent_id: "agent".to_string(), policy_name: "policy".to_string(), }; let mut all_bytes = encode_session_event(&e1); let e2_bytes = encode_session_event(&e2); // Add partial second record. all_bytes.extend_from_slice(&e2_bytes[..e2_bytes.len() / 2]); let decoded = decode_session_events(&all_bytes); assert_eq!(decoded.len(), 1); assert_eq!(decoded[0], e1); } #[test] fn session_decode_empty_bytes() { let decoded = decode_session_events(&[]); assert!(decoded.is_empty()); } #[test] fn session_seqno_ordering() { assert!(SessionSeqNo(1) > SessionSeqNo(0)); assert_eq!(SessionSeqNo(5).next(), SessionSeqNo(6)); assert_eq!(SessionSeqNo::ZERO, SessionSeqNo(0)); } #[test] fn session_signal_with_seqno_roundtrip() { let event = SessionWalEvent::Signal { session_id: 1, entity_id: 42, weight: 1.0, ts_ns: 1000, signal_name: "view".to_string(), annotation: None, session_seqno: Some(SessionSeqNo(7)), idempotency_key: Some(0xDEAD_BEEF_CAFE_BABE_u128), }; let encoded = encode_session_event(&event); let decoded = decode_session_events(&encoded); assert_eq!(decoded.len(), 1); assert_eq!(decoded[0], event); } #[test] fn session_signal_legacy_roundtrip_no_seqno() { // Legacy events (seqno=None) decode correctly. let event = SessionWalEvent::Signal { session_id: 2, entity_id: 99, weight: 2.0, ts_ns: 2000, signal_name: "like".to_string(), annotation: None, session_seqno: None, idempotency_key: None, }; let encoded = encode_session_event(&event); let decoded = decode_session_events(&encoded); assert_eq!(decoded[0], event); } }