diff --git a/crates/spt-store/src/iolog.rs b/crates/spt-store/src/iolog.rs index 15e5673..5881999 100644 --- a/crates/spt-store/src/iolog.rs +++ b/crates/spt-store/src/iolog.rs @@ -141,6 +141,14 @@ pub struct IoLogRow { pub mid: bool, } +/// A poll's selected rows and the global prefix maximum from the same snapshot. +#[derive(Debug, Default)] +pub struct IoLogRead { + pub rows: Vec, + /// Independent of the cursor, output limit, and whether row JSON parses. + pub head: u64, +} + /// The log file for an already-resolved perch. // [impl->REQ-HAZARD-SINGLE-PATH-SOURCE] pub fn io_log_file_at(perch_path: &Path) -> PathBuf { @@ -190,12 +198,13 @@ fn line_seq(line: &str) -> Option { /// rather than answering wrong. const TAIL_WINDOW: u64 = 256 * 1024; -/// The highest seq the log holds, or 0 when it holds nothing. +/// The highest seq in the complete rows of the tail window, or 0 if empty. /// -/// **Read from the tail, not by counting**: this runs on every append, and a -/// whole-file read per append would make the log's cost quadratic in its own -/// length. +/// Healthy logs are increasing, so this is their head. Append integrity and +/// poll snapshots scan all prefixes instead: a damaged log's older maximum can +/// lie outside this window. // [impl->REQ-IO-EVENT-ADAPTER-LOG] +// [impl->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] pub fn last_seq_at(perch_path: &Path) -> u64 { let path = io_log_file_at(perch_path); let Ok(mut f) = std::fs::File::open(&path) else { @@ -211,41 +220,91 @@ pub fn last_seq_at(perch_path: &Path) -> u64 { if f.seek(SeekFrom::Start(start)).is_err() { return 0; } - let mut buf = String::new(); - if f.read_to_string(&mut buf).is_err() { + let mut buf = Vec::new(); + if f.read_to_end(&mut buf).is_err() { return 0; } - // A window that reached no line start at all fell short of even ONE row; - // re-read whole rather than answer from a fragment. - if start > 0 && !buf.contains('\n') { - return std::fs::read_to_string(&path) - .ok() - .and_then(|s| s.lines().rev().find_map(line_seq)) - .unwrap_or(0); - } - let mut lines: Vec<&str> = buf.lines().collect(); - if start > 0 && !lines.is_empty() { - // The first line in a mid-file window is a fragment of an earlier row. - lines.remove(0); - } - lines.iter().rev().find_map(|l| line_seq(l)).unwrap_or(0) -} - -/// The lowest seq the log holds, or 0 when it holds nothing. Read from the head, -/// for the same reason [`last_seq_at`] reads from the tail. -fn first_seq_at(perch_path: &Path) -> u64 { - let Ok(mut f) = std::fs::File::open(io_log_file_at(perch_path)) else { - return 0; + let complete = if start > 0 { + // Drop the fragment BEFORE decoding: the seek can bisect a codepoint. + match buf.iter().position(|&b| b == b'\n') { + Some(end) if end + 1 < buf.len() => &buf[end + 1..], + _ => { + // No complete row remains, including a window ending at the + // only row's newline. Read whole rather than answer falsely 0. + return std::fs::read(&path) + .ok() + .map(|bytes| { + String::from_utf8_lossy(&bytes) + .lines() + .filter_map(line_seq) + .max() + .unwrap_or(0) + }) + .unwrap_or(0); + } + } + } else { + &buf[..] }; - let mut buf = vec![0u8; TAIL_WINDOW as usize]; - let Ok(n) = f.read(&mut buf) else { return 0 }; - buf.truncate(n); - String::from_utf8_lossy(&buf) + String::from_utf8_lossy(complete) .lines() - .find_map(line_seq) + .filter_map(line_seq) + .max() .unwrap_or(0) } +/// Read only the ASCII sequence prefix; payload bytes need not decode. +fn byte_line_seq(line: &[u8]) -> Option { + let tab = line.iter().position(|&b| b == b'\t')?; + std::str::from_utf8(&line[..tab]).ok()?.trim().parse().ok() +} + +/// One full read supplies integrity, global maximum, retention count, and any +/// rewrite bytes. Healthy logs are bounded to 1250 payload-capped rows (on-disk +/// bytes include JSON escaping); legacy oversized logs pay their full scan +/// until repaired. Healthy appends scan but do not rewrite the file. +/// Endpoints alone cannot prove integrity: 1, 2, 1, 3 has increasing endpoints. +// [impl->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] +struct LogScan { + body: Vec, + rows: usize, + head: u64, + damaged: bool, +} + +impl LogScan { + fn read(path: &Path) -> io::Result { + let body = match std::fs::read(path) { + Ok(body) => body, + Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(), + Err(e) => return Err(e), + }; + let mut rows = 0; + let mut head = 0; + let mut previous = None; + let mut damaged = false; + for line in body.split_inclusive(|&b| b == b'\n') { + rows += 1; + if let Some(seq) = byte_line_seq(line) { + damaged |= previous.is_some_and(|prev| seq <= prev); + previous = Some(seq); + head = head.max(seq); + } + } + Ok(Self { + body, + rows, + head, + damaged, + }) + } +} + +fn next_seq(seq: u64) -> io::Result { + seq.checked_add(1) + .ok_or_else(|| io::Error::other("io-events sequence exhausted")) +} + /// Append one event, assigning it the next seq. Answers the seq assigned. /// /// **Serialized under an exclusive advisory lock.** The daemon publishes from @@ -266,46 +325,92 @@ pub fn append_at(perch_path: &Path, row: &IoLogRow) -> io::Result { result } +// [impl->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] +// [impl->REQ-IO-EVENT-ADAPTER-LOG] fn append_locked(perch_path: &Path, row: &IoLogRow) -> io::Result { - let seq = last_seq_at(perch_path).saturating_add(1); + let path = io_log_file_at(perch_path); + let mut scan = LogScan::read(&path)?; + let mut repaired = None; + if scan.damaged { + // Repair only retained history, in file order, above EVERY old prefix. + // Old cursors see this history once; normal positional retention bounds + // that replay. Never deserialize/reserialize the JSON half. + let keep = retained_rows(scan.rows); + let offset = scan + .body + .split_inclusive(|&b| b == b'\n') + .take(scan.rows - keep) + .map(<[u8]>::len) + .sum::(); + let retained = &scan.body[offset..]; + let mut body = Vec::with_capacity(retained.len()); + for line in retained.split_inclusive(|&b| b == b'\n') { + if byte_line_seq(line).is_some() { + scan.head = next_seq(scan.head)?; + let tab = line.iter().position(|&b| b == b'\t').unwrap(); + write!(&mut body, "{}", scan.head)?; + body.extend_from_slice(&line[tab..]); + } else { + // Corrupt unkeyed lines remain unreadable, but are not lost. + body.extend_from_slice(line); + } + } + scan.rows = keep; + repaired = Some(body); + } + // Preflight the entire repair AND new row before mutating the file: neither + // saturation nor a partially committed repair may consume the last cursor. + let seq = next_seq(scan.head)?; let mut stamped = row.clone(); stamped.seq = seq; let line = compose_line(&stamped).map_err(io::Error::other)?; + if let Some(body) = repaired { + crate::atomic::atomic_write_bytes(&path, &body)?; + scan.body = body; + } let mut f = std::fs::OpenOptions::new() .create(true) .append(true) - .open(io_log_file_at(perch_path))?; + .open(&path)?; f.write_all(line.as_bytes())?; - // The trim is best-effort ON PURPOSE: a retention sweep that fails must not - // cost the caller the event it just recorded. An over-long log is a disk - // cost; a lost event is a hole in the record an adapter cannot detect. - let _ = trim_locked(perch_path, seq); + drop(f); + // Retention remains best-effort after the event is safely appended. Reuse + // the scan bytes rather than reading the entire file again for a trim. + scan.rows += 1; + if retained_rows(scan.rows) < scan.rows { + scan.body.extend_from_slice(line.as_bytes()); + let _ = trim_locked(perch_path, &scan.body, scan.rows); + } Ok(seq) } /// Drop the oldest rows when the log has run past its bound plus slack. /// -/// Seqs are contiguous — assigned +1 per append and trimmed only from the front -/// — so the row count is `last - first + 1` and needs no scan to compute. +/// Count physical lines, not sequence distance; reset blocks need exactly the +/// same newest-N retention as healthy logs. // [impl->REQ-IO-EVENT-ADAPTER-LOG] -fn trim_locked(perch_path: &Path, last: u64) -> io::Result<()> { - let first = first_seq_at(perch_path); - if first == 0 { - return Ok(()); +// [impl->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] +fn retained_rows(rows: usize) -> usize { + if rows as u64 > IO_LOG_MAX_ROWS + IO_LOG_TRIM_SLACK { + IO_LOG_MAX_ROWS as usize + } else { + rows } - let rows = last.saturating_sub(first).saturating_add(1); - if rows <= IO_LOG_MAX_ROWS + IO_LOG_TRIM_SLACK { +} + +// [impl->REQ-IO-EVENT-ADAPTER-LOG] +// [impl->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] +fn trim_locked(perch_path: &Path, body: &[u8], rows: usize) -> io::Result<()> { + let drop = rows - retained_rows(rows); + if drop == 0 { return Ok(()); } - let keep_from = last.saturating_sub(IO_LOG_MAX_ROWS - 1); - let path = io_log_file_at(perch_path); - let body = std::fs::read_to_string(&path)?; - let kept: String = body - .lines() - .filter(|l| line_seq(l).is_some_and(|s| s >= keep_from)) - .map(|l| format!("{l}\n")) - .collect(); - crate::atomic::atomic_write_string(&path, &kept).map_err(io::Error::other) + let offset = body + .split_inclusive(|&b| b == b'\n') + .take(drop) + .map(<[u8]>::len) + .sum::(); + crate::atomic::atomic_write_bytes(&io_log_file_at(perch_path), &body[offset..]) } /// Every row with `seq` strictly greater than `after`, oldest first. @@ -316,7 +421,8 @@ fn trim_locked(perch_path: &Path, last: u64) -> io::Result<()> { /// **Takes the SHARED lock**: a trim rewrites the file, and a reader that raced /// it would see a half-written log. Readers do not exclude each other. // [impl->REQ-IO-EVENT-ADAPTER-LOG] -pub fn read_after_at(perch_path: &Path, after: u64, limit: Option) -> Vec { +// [impl->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] +pub fn read_after_at(perch_path: &Path, after: u64, limit: Option) -> IoLogRead { let lock = std::fs::OpenOptions::new() .create(true) .write(true) @@ -326,23 +432,23 @@ pub fn read_after_at(perch_path: &Path, after: u64, limit: Option) -> Vec if let Some(l) = lock.as_ref() { let _ = l.lock_shared(); } - let body = std::fs::read_to_string(io_log_file_at(perch_path)).unwrap_or_default(); + let body = std::fs::read(io_log_file_at(perch_path)).unwrap_or_default(); if let Some(l) = lock.as_ref() { let _ = FileExt::unlock(l); } - let mut out: Vec = Vec::new(); - for line in body.lines() { - // The cheap prefix test FIRST: an already-seen row costs an integer - // parse, never a JSON one. - match line_seq(line) { - Some(s) if s > after => {} - _ => continue, - } - if let Some(row) = parse_line(line) { - out.push(row); + let mut out = IoLogRead::default(); + for line in body.split_inclusive(|&b| b == b'\n') { + // Keep scanning prefixes after the limit: head belongs to this entire + // shared-lock snapshot, not the last delivered (or decodable) row. + let Some(seq) = byte_line_seq(line) else { + continue; + }; + out.head = out.head.max(seq); + if seq <= after || limit.is_some_and(|n| out.rows.len() >= n) { + continue; } - if limit.is_some_and(|n| out.len() >= n) { - break; + if let Some(row) = std::str::from_utf8(line).ok().and_then(parse_line) { + out.rows.push(row); } } out @@ -418,6 +524,280 @@ mod tests { p } + fn fixture(seqs: impl IntoIterator) -> Vec { + let mut body = Vec::new(); + for (position, seq) in seqs.into_iter().enumerate() { + // Deliberately noncanonical JSON, including an unknown field and + // escape spellings: repair must preserve bytes, not just meaning. + writeln!( + &mut body, + "{seq}\t{{ \"payload\": \"p{}\\u4e16\", \"kind\": \"MSG_IN\", \"at_ms\": 1, \"future\": true }}", + position + 1 + ) + .unwrap(); + } + body + } + + fn payload_bytes(body: &[u8]) -> Vec<&[u8]> { + body.split_inclusive(|&b| b == b'\n') + .map(|line| &line[line.iter().position(|&b| b == b'\t').unwrap() + 1..]) + .collect() + } + + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn a_tail_window_inside_three_byte_utf8_never_resets_the_seq() { + let d = tmp("utf8-window"); + let mut body = Vec::new(); + let last = 32; + let mut event = row("AGENT_OUTPUT", &"\u{4e16}".repeat(4_000)); + for seq in 1..=last { + event.seq = seq; + body.extend_from_slice(compose_line(&event).unwrap().as_bytes()); + } + // Adjust only trailing JSON whitespace until the fixed window bisects + // 世. Rows use literal LF on every OS; no text-mode CRLF translation. + for _ in 0..3 { + let start = body.len() - TAIL_WINDOW as usize; + if (0x80..=0xbf).contains(&body[start]) { + break; + } + body.insert(body.len() - 1, b' '); + } + let start = body.len() - TAIL_WINDOW as usize; + assert!( + (0x80..=0xbf).contains(&body[start]), + "window begins at a continuation byte" + ); + let lead = if body[start - 1] == 0xe4 { + start - 1 + } else { + start - 2 + }; + assert_eq!(&body[lead..lead + 3], "\u{4e16}".as_bytes()); + std::fs::write(io_log_file_at(&d), body).unwrap(); + // Mutation-sensitive even though append's independent integrity scan + // can mask last_seq_at regressing to read_to_string/InvalidData -> 0. + assert_eq!(last_seq_at(&d), last); + assert_eq!(append_at(&d, &row("USER_INPUT", "next")).unwrap(), last + 1); + } + + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn a_tail_without_a_complete_row_falls_back_to_the_whole_file() { + for terminated in [false, true] { + let d = tmp(if terminated { + "giant-lf" + } else { + "giant-no-lf" + }); + let mut event = row("AGENT_OUTPUT", &"x".repeat(TAIL_WINDOW as usize + 100)); + event.seq = 87; + let mut body = compose_line(&event).unwrap(); + if !terminated { + body.pop(); + } + std::fs::write(io_log_file_at(&d), body).unwrap(); + assert_eq!(last_seq_at(&d), 87, "terminated={terminated}"); + } + } + + // [unit->REQ-IO-EVENT-ADAPTER-LOG] + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn reset_blocks_repair_above_the_max_without_changing_payload_bytes() { + let d = tmp("repair-blocks"); + let body = fixture([201, 202, 203, 1, 2, 3]); + std::fs::write(io_log_file_at(&d), &body).unwrap(); + assert_eq!(last_seq_at(&d), 203, "tail maximum, not its last line"); + assert_eq!(append_at(&d, &row("USER_INPUT", "new")).unwrap(), 210); + let repaired = std::fs::read(io_log_file_at(&d)).unwrap(); + assert_eq!(&payload_bytes(&repaired)[..6], payload_bytes(&body)); + let read = read_after_at(&d, 203, None); + assert_eq!( + read.rows.iter().map(|r| r.seq).collect::>(), + (204..=210).collect::>() + ); + assert_eq!(read.rows.last().unwrap().payload, "new"); + // The next append must not repair already-renumbered history again. + assert_eq!(append_at(&d, &row("USER_INPUT", "later")).unwrap(), 211); + let later = std::fs::read(io_log_file_at(&d)).unwrap(); + assert!(later.starts_with(&repaired)); + } + + // [unit->REQ-IO-EVENT-ADAPTER-LOG] + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn equal_adjacent_seqs_are_repaired() { + let d = tmp("repair-equal"); + std::fs::write(io_log_file_at(&d), fixture([1, 1])).unwrap(); + assert_eq!(append_at(&d, &row("USER_INPUT", "new")).unwrap(), 4); + let read = read_after_at(&d, 1, None); + assert_eq!( + read.rows.iter().map(|r| r.seq).collect::>(), + vec![2, 3, 4] + ); + } + + // [unit->REQ-IO-EVENT-ADAPTER-LOG] + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn an_internal_reset_is_repaired_even_when_last_exceeds_first() { + let d = tmp("repair-internal"); + std::fs::write(io_log_file_at(&d), fixture([1, 2, 1, 3])).unwrap(); + assert_eq!(append_at(&d, &row("USER_INPUT", "new")).unwrap(), 8); + let read = read_after_at(&d, 3, None); + assert_eq!( + read.rows.iter().map(|r| r.seq).collect::>(), + (4..=8).collect::>() + ); + } + + // [unit->REQ-IO-EVENT-ADAPTER-LOG] + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn repair_uses_the_global_max_even_when_it_is_outside_the_tail() { + let d = tmp("repair-global"); + let mut body = fixture([9_000]); + let first_len = body.len(); + let mut event = row("AGENT_OUTPUT", &"\u{4e16}".repeat(4_000)); + for seq in 1..=30 { + event.seq = seq; + body.extend_from_slice(compose_line(&event).unwrap().as_bytes()); + } + assert!(body.len() - TAIL_WINDOW as usize > first_len); + std::fs::write(io_log_file_at(&d), &body).unwrap(); + assert_eq!( + last_seq_at(&d), + 30, + "the older maximum is outside the window" + ); + assert_eq!(append_at(&d, &row("USER_INPUT", "new")).unwrap(), 9_032); + let repaired = std::fs::read(io_log_file_at(&d)).unwrap(); + assert_eq!(&payload_bytes(&repaired)[..31], payload_bytes(&body)); + let read = read_after_at(&d, 9_000, None); + assert_eq!( + read.rows.iter().map(|r| r.seq).collect::>(), + (9_001..=9_032).collect::>() + ); + } + + // [unit->REQ-IO-EVENT-ADAPTER-LOG] + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn trimming_1251_reset_rows_keeps_the_newest_1000_by_position() { + let d = tmp("trim-reset-position"); + let body = fixture((1..=250).chain(1..=1_000).chain([2_000])); + let lines: Vec<_> = body.split_inclusive(|&b| b == b'\n').collect(); + assert_eq!(lines.len(), 1_251); + let expected = lines[251..].concat(); + // Negative control: last-N by seq value drops 999 newest rows here. + let value_filtered = lines + .iter() + .filter(|line| byte_line_seq(line).is_some_and(|seq| seq >= 1_001)) + .copied() + .collect::>() + .concat(); + assert_ne!(value_filtered, expected); + std::fs::write(io_log_file_at(&d), &body).unwrap(); + trim_locked(&d, &body, lines.len()).unwrap(); + assert_eq!(std::fs::read(io_log_file_at(&d)).unwrap(), expected); + let read = read_after_at(&d, 0, None); + assert_eq!(read.rows.first().unwrap().payload, "p252世"); + assert_eq!(read.rows.last().unwrap().payload, "p1251世"); + assert_eq!(read.rows.len(), IO_LOG_MAX_ROWS as usize); + } + + // [unit->REQ-IO-EVENT-ADAPTER-LOG] + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn oversized_repair_retains_bounded_newest_history() { + let d = tmp("repair-bounded"); + // The old global maximum is also OUTSIDE the retained suffix. + let body = fixture([9_000].into_iter().chain(1..=1_299)); + std::fs::write(io_log_file_at(&d), &body).unwrap(); + assert_eq!(append_at(&d, &row("USER_INPUT", "new")).unwrap(), 10_001); + let repaired = std::fs::read(io_log_file_at(&d)).unwrap(); + assert_eq!( + &payload_bytes(&repaired)[..1_000], + &payload_bytes(&body)[300..] + ); + let read = read_after_at(&d, 9_000, None); + assert_eq!(read.rows.len(), 1_001); + assert_eq!(read.rows.first().unwrap().seq, 9_001); + assert_eq!(read.rows.last().unwrap().seq, 10_001); + } + + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn sequence_exhaustion_refuses_without_modifying_history() { + // Healthy exhaustion, repair exhaustion midway, and enough room for + // repair but not its new row must ALL refuse before touching the file. + for (name, seqs) in [ + ("exhausted-healthy", vec![u64::MAX]), + ("exhausted-repair", vec![u64::MAX - 1, 1]), + ("exhausted-new-row", vec![u64::MAX - 2, 1]), + ] { + let d = tmp(name); + let body = fixture(seqs); + std::fs::write(io_log_file_at(&d), &body).unwrap(); + assert!(append_at(&d, &row("USER_INPUT", "new")).is_err()); + assert_eq!(std::fs::read(io_log_file_at(&d)).unwrap(), body); + } + let d = tmp("last-available-seq"); + std::fs::write(io_log_file_at(&d), fixture([u64::MAX - 1])).unwrap(); + assert_eq!(append_at(&d, &row("USER_INPUT", "last")).unwrap(), u64::MAX); + let body = std::fs::read(io_log_file_at(&d)).unwrap(); + assert!(append_at(&d, &row("USER_INPUT", "overflow")).is_err()); + assert_eq!(std::fs::read(io_log_file_at(&d)).unwrap(), body); + } + + // [unit->REQ-IO-EVENT-ADAPTER-LOG] + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn healthy_sequence_gaps_do_not_trigger_repair_or_retention() { + let d = tmp("healthy-gaps"); + let body = fixture([1, 5_000]); + std::fs::write(io_log_file_at(&d), &body).unwrap(); + assert_eq!(append_at(&d, &row("USER_INPUT", "new")).unwrap(), 5_001); + assert!(std::fs::read(io_log_file_at(&d)) + .unwrap() + .starts_with(&body)); + let read = read_after_at(&d, 0, None); + assert_eq!( + read.rows.iter().map(|r| r.seq).collect::>(), + vec![1, 5_000, 5_001] + ); + } + + // [unit->REQ-IO-EVENT-ADAPTER-LOG] + // [unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC] + #[test] + fn a_poll_reports_the_true_snapshot_head_independent_of_selected_rows() { + let d = tmp("snapshot-head"); + let mut body = fixture([201, 202, 1, 2]); + body.extend_from_slice(b"203\tnot json\n"); + std::fs::write(io_log_file_at(&d), body).unwrap(); + let capped = read_after_at(&d, 0, Some(1)); + assert_eq!( + capped.rows.iter().map(|r| r.seq).collect::>(), + vec![201] + ); + assert_eq!(capped.head, 203, "scan past the cap and corrupt JSON"); + let uncapped = read_after_at(&d, 201, None); + assert_eq!( + uncapped.rows.iter().map(|r| r.seq).collect::>(), + vec![202] + ); + assert_eq!(uncapped.head, 203); + for (after, limit) in [(202, None), (203, None), (900, None), (0, Some(0))] { + let read = read_after_at(&d, after, limit); + assert!(read.rows.is_empty()); + assert_eq!(read.head, 203, "after={after}, limit={limit:?}"); + } + } + // [unit->REQ-IO-EVENT-ADAPTER-LOG] appends assign a contiguous, monotonic // seq from 1, and a read with no cursor returns them oldest-first. #[test] @@ -426,7 +806,7 @@ mod tests { assert_eq!(append_at(&d, &row("USER_INPUT", "a")).unwrap(), 1); assert_eq!(append_at(&d, &row("AGENT_OUTPUT", "b")).unwrap(), 2); assert_eq!(append_at(&d, &row("MSG_IN", "c")).unwrap(), 3); - let all = read_after_at(&d, 0, None); + let all = read_after_at(&d, 0, None).rows; assert_eq!(all.iter().map(|r| r.seq).collect::>(), vec![1, 2, 3]); assert_eq!( all.iter().map(|r| r.payload.as_str()).collect::>(), @@ -446,15 +826,18 @@ mod tests { for p in ["a", "b", "c", "d"] { append_at(&d, &row("USER_INPUT", p)).unwrap(); } - let after2 = read_after_at(&d, 2, None); + let after2 = read_after_at(&d, 2, None).rows; assert_eq!(after2.iter().map(|r| r.seq).collect::>(), vec![3, 4]); assert!( - read_after_at(&d, 4, None).is_empty(), + read_after_at(&d, 4, None).rows.is_empty(), "a cursor at the head sees nothing" ); - let capped = read_after_at(&d, 0, Some(2)); + let capped = read_after_at(&d, 0, Some(2)).rows; assert_eq!(capped.len(), 2, "limit bounds the answer"); - assert_eq!(capped[0].seq, 1, "and keeps the OLDEST new rows, so no row is skipped"); + assert_eq!( + capped[0].seq, 1, + "and keeps the OLDEST new rows, so no row is skipped" + ); } // [unit->REQ-IO-EVENT-ADAPTER-LOG] a payload full of newlines is ONE row. @@ -466,7 +849,7 @@ mod tests { let body = "line one\nline two\ttabbed\nline three"; append_at(&d, &row("AGENT_OUTPUT", body)).unwrap(); append_at(&d, &row("USER_INPUT", "next")).unwrap(); - let all = read_after_at(&d, 0, None); + let all = read_after_at(&d, 0, None).rows; assert_eq!(all.len(), 2, "the newlines did not mint extra rows"); assert_eq!(all[0].payload, body, "and the body round-trips verbatim"); assert_eq!(all[1].seq, 2); @@ -482,7 +865,7 @@ mod tests { r.truncated = true; let assigned = append_at(&d, &r).unwrap(); assert_eq!(assigned, 1); - let back = read_after_at(&d, 0, None); + let back = read_after_at(&d, 0, None).rows; assert_eq!(back[0].seq, 1, "the LINE key is the log's own cursor"); assert_eq!( back[0].digest_seq, @@ -518,19 +901,23 @@ mod tests { for i in 1..=brim { append_at(&d, &row("USER_INPUT", &format!("p{i}"))).unwrap(); } - let at_brim = read_after_at(&d, 0, None); + let at_brim = read_after_at(&d, 0, None).rows; assert_eq!( at_brim.len() as u64, brim, "the slack is REAL — the log runs past the bound before it rewrites" ); - assert_eq!(at_brim.first().unwrap().seq, 1, "and the first row is still row 1"); + assert_eq!( + at_brim.first().unwrap().seq, + 1, + "and the first row is still row 1" + ); // ── Half two: ONE more append crosses it, and the trim lands on the // BOUND rather than merely back under the slack. let total = brim + 1; append_at(&d, &row("USER_INPUT", &format!("p{total}"))).unwrap(); - let all = read_after_at(&d, 0, None); + let all = read_after_at(&d, 0, None).rows; assert_eq!( all.len() as u64, IO_LOG_MAX_ROWS, @@ -544,7 +931,7 @@ mod tests { ); assert_eq!(last_seq_at(&d), total, "seqs never rewind over a trim"); assert_eq!( - read_after_at(&d, 1, None).len() as u64, + read_after_at(&d, 1, None).rows.len() as u64, IO_LOG_MAX_ROWS, "a cursor into the DROPPED region still sees everything that survives" ); @@ -555,7 +942,7 @@ mod tests { append_at(&d, &row("USER_INPUT", &format!("q{i}"))).unwrap(); } assert!( - (read_after_at(&d, 0, None).len() as u64) <= brim, + (read_after_at(&d, 0, None).rows.len() as u64) <= brim, "the retention ceiling holds across the slack cycle" ); } @@ -573,7 +960,7 @@ mod tests { std::fs::write(&path, &body).unwrap(); let seq = append_at(&d, &row("USER_INPUT", "after")).unwrap(); assert_eq!(seq, 3, "the seq walk read the PREFIX, which was intact"); - let rows = read_after_at(&d, 0, None); + let rows = read_after_at(&d, 0, None).rows; assert_eq!(rows.iter().map(|r| r.seq).collect::>(), vec![1, 3]); } } diff --git a/crates/spt/src/api/engineroom.rs b/crates/spt/src/api/engineroom.rs index ff8bb9f..58f722f 100644 --- a/crates/spt/src/api/engineroom.rs +++ b/crates/spt/src/api/engineroom.rs @@ -165,11 +165,12 @@ pub fn refuse(verdict: EngineRoomAuth, caller_id: &str) -> i32 { /// typed a code — loud, not silent, and never a foreign agent quietly seated at /// the controls of a room the operator believes is theirs. // [impl->REQ-ER-RESERVED-ID-SPAWN-REFUSAL] -pub fn reserved_bind_refusal(id: &str, engine_room_hosted: bool) -> Option { +pub fn reserved_bind_refusal(id: &str, engine_room_hosted: impl FnOnce() -> bool) -> Option { // One predicate, one sentence: the shared refusal, unchanged, so this seam // cannot drift its own spelling of the rule. let refusal = engineroom::reserved_id_refusal(id)?; - (!engine_room_hosted).then_some(refusal) + // Ordinary ids never need the broker probe or its refusal diagnostics. + (!engine_room_hosted()).then_some(refusal) } /// The observable behind [`reserved_bind_refusal`]: is this node's broker @@ -890,29 +891,28 @@ mod tests { #[test] fn the_perch_minting_verbs_admit_the_reserved_id_only_as_a_completion() { let er = engineroom::ENGINE_ROOM_ID; - let refusal = reserved_bind_refusal(er, false).expect("a first mover is refused"); assert!( - refusal.contains("spt rc engine-room"), - "and the refusal names the ONE entry rather than only saying no: {refusal}" + reserved_bind_refusal(er, || false).is_some(), + "a first mover is refused" ); assert_eq!( - refusal, - engineroom::reserved_id_refusal(er).expect("the shared sentence"), - "one predicate, one sentence — this seam does not spell the rule its own way" - ); - assert_eq!( - reserved_bind_refusal(er, true), + reserved_bind_refusal(er, || true), None, "a hosted engine-room session means its bring-up already passed the gate, \ and this bind is that bring-up's completion" ); - for hosted in [true, false] { - assert_eq!( - reserved_bind_refusal("todlando", hosted), - None, - "an ordinary id is untouched at these verbs, hosted or not" - ); - } + } + + // [unit->REQ-ER-RESERVED-ID-SPAWN-REFUSAL] ordinary binds must not dial the + // engine-room probe or emit its unrelated refusal diagnostics (releases#279). + #[test] + fn ordinary_perch_minting_never_probes_the_engine_room() { + assert_eq!( + reserved_bind_refusal("todlando", || { + panic!("an ordinary id must not probe engine-room hosting") + }), + None + ); } /// A sessions reply holding exactly the rows and in-flight entries named. diff --git a/crates/spt/src/api/ioevents.rs b/crates/spt/src/api/ioevents.rs index c536a3f..37fbcdd 100644 --- a/crates/spt/src/api/ioevents.rs +++ b/crates/spt/src/api/ioevents.rs @@ -140,7 +140,7 @@ pub fn poll( match read_cursor(session, owner) { None => { // THE SEED PATH. Silent by construction. - let head = iolog::last_seq_at(perch_path); + let head = iolog::read_after_at(perch_path, u64::MAX, None).head; write_cursor(session, owner, head); Poll { rows: Vec::new(), @@ -176,10 +176,9 @@ pub fn poll( /// a reader that refused the whole poll over one such row would turn a forward /// compatible record into a broken hook. fn read_bounded(perch_path: &std::path::Path, from: u64, limit: Option) -> Scan { - let all = iolog::read_after_at(perch_path, from, None); - // The highest seq this poll actually LOOKED AT, ignored rows included. See - // [`Scan::cursor`] for why that is not the same as the last row returned. - let scanned_to = all.last().map(|r| r.seq); + // Rows and the true head come from one locked snapshot. Even an oversized + // cursor must report where the log stands, not echo the caller's cursor. + let iolog::IoLogRead { rows: all, head } = iolog::read_after_at(perch_path, from, None); // The limit is applied AFTER the vocabulary filter, and `more` is measured // against what survived it — a page full of rows this binary would have // dropped is not a page, and reporting it as capped would send the adapter @@ -208,7 +207,7 @@ fn read_bounded(perch_path: &std::path::Path, from: u64, limit: Option) - Scan { rows, more, - scanned_to, + head, } } @@ -216,8 +215,8 @@ fn read_bounded(perch_path: &std::path::Path, from: u64, limit: Option) - struct Scan { rows: Vec, more: bool, - /// The highest seq examined, INCLUDING rows the vocabulary filter dropped. - scanned_to: Option, + /// Global maximum seq in the snapshot, independent of selection and limit. + head: u64, } impl Scan { @@ -226,20 +225,14 @@ impl Scan { /// **Capped: the last row HANDED OVER.** A poll that hit `--limit` must /// leave the rest for the next poll rather than skip them. /// - /// **Uncapped: the last row EXAMINED, not the last row returned.** These - /// differ exactly when the newest rows were dropped by the vocabulary - /// filter, and taking the last returned row there would pin the cursor - /// behind them — every later poll would re-read a growing tail of rows it - /// has already decided to ignore. Nothing emits an unknown kind today, so - /// this is the FORWARD-COMPATIBILITY path: it is reached when a newer core - /// writes a kind this binary does not know, which is the whole scenario the - /// ignore-rather-than-refuse posture exists to survive. A stall there would - /// turn graceful degradation into a slow leak. + /// **Uncapped: the snapshot's true head**, including rows ignored by the + /// vocabulary filter or excluded by an oversized incoming cursor. Reporting + /// a lower head lets a persisted pre-reset cursor recover on the next poll. fn cursor(&self, from: u64) -> u64 { if self.more { return self.rows.last().map(|r| r.seq).unwrap_or(from); } - self.scanned_to.unwrap_or(from) + self.head } } @@ -488,6 +481,40 @@ mod tests { ); } + // [unit->REQ-IO-EVENT-POLL-VERB] + #[test] + fn an_oversized_cursor_reports_the_true_head() { + let d = tmp("above-head"); + put(&d, IO_KIND_USER_INPUT, "first"); + put(&d, IO_KIND_AGENT_OUTPUT, "second"); + let p = poll(&d, None, "owner", Some(3), None); + assert!(p.rows.is_empty()); + assert_eq!(p.cursor, 2, "an empty answer reports the head, not --after"); + let empty = tmp("above-empty-head"); + assert_eq!(poll(&empty, None, "owner", Some(3), None).cursor, 0); + } + + // [unit->REQ-IO-EVENT-POLL-VERB] + #[test] + fn a_session_cursor_above_head_recovers_for_the_next_event() { + let _home = crate::testutil::isolated_home(); + let d = tmp("recover-head"); + let sid = format!("sid-recover-head-{}", std::process::id()); + put(&d, IO_KIND_USER_INPUT, "before"); + write_cursor(&sid, "owner", 99999); + let quiet = poll(&d, Some(&sid), "owner", None, None); + assert!(quiet.rows.is_empty()); + assert_eq!(quiet.cursor, 1); + put(&d, IO_KIND_AGENT_OUTPUT, "after recovery"); + let next = poll(&d, Some(&sid), "owner", None, None); + assert_eq!(next.cursor, 2); + assert_eq!( + next.rows.iter().map(|r| r.payload.as_str()).collect::>(), + vec!["after recovery"] + ); + let _ = std::fs::remove_dir_all(perch::session_dir(&sid)); + } + // [unit->REQ-IO-EVENT-POLL-VERB] an unknown kind is IGNORED, not refused — // the poll still answers with the rows around it. #[test] diff --git a/crates/spt/src/api/reporting.rs b/crates/spt/src/api/reporting.rs index 7ce9c43..d401bf2 100644 --- a/crates/spt/src/api/reporting.rs +++ b/crates/spt/src/api/reporting.rs @@ -1699,7 +1699,7 @@ mod tests { let perch = perch::resolve_perch_path("alice", ParentHint::Infer); assert_eq!(cmd_boundary("alice", "clear", "sid-1"), 0); assert_eq!(cmd_boundary("alice", "compact", "sid-2"), 0); - let rows = spt_store::iolog::read_after_at(&perch, 0, None); + let rows = spt_store::iolog::read_after_at(&perch, 0, None).rows; let kinds: Vec<&str> = rows.iter().map(|r| r.kind.as_str()).collect(); assert_eq!(kinds, vec!["clear", "compact"], "one row per edge, in order"); for r in &rows { @@ -1720,7 +1720,7 @@ mod tests { let perch = perch::resolve_perch_path("alice", ParentHint::Infer); assert_eq!(cmd_boundary("alice", "compact", "sid-1"), 0); assert_eq!(cmd_boundary("alice", "wharrgarbl", "sid-2"), 0); - let rows = spt_store::iolog::read_after_at(&perch, 0, None); + let rows = spt_store::iolog::read_after_at(&perch, 0, None).rows; let kinds: Vec<&str> = rows.iter().map(|r| r.kind.as_str()).collect(); assert_eq!(kinds, vec!["compact", "clear"]); assert!( @@ -1746,7 +1746,7 @@ mod tests { let perch = perch::resolve_perch_path("alice", ParentHint::Infer); assert_eq!(cmd_boundary("alice", "clear", "sid-1"), 0); assert_eq!(cmd_boundary("alice", "clear", "sid-1"), 0, "still a success"); - let rows = spt_store::iolog::read_after_at(&perch, 0, None); + let rows = spt_store::iolog::read_after_at(&perch, 0, None).rows; assert_eq!(rows.len(), 1, "the re-bind crossed no edge: {rows:?}"); assert_eq!(spt_store::sessions::read_all(&perch).len(), 1); } diff --git a/crates/spt/src/api/startup.rs b/crates/spt/src/api/startup.rs index 2e61531..5b98ac6 100644 --- a/crates/spt/src/api/startup.rs +++ b/crates/spt/src/api/startup.rs @@ -922,7 +922,7 @@ pub fn cmd_listen( // different verb. // [impl->REQ-ER-RESERVED-ID-SPAWN-REFUSAL] if let Some(refusal) = - crate::api::engineroom::reserved_bind_refusal(id, crate::api::engineroom::engine_room_hosted()) + crate::api::engineroom::reserved_bind_refusal(id, crate::api::engineroom::engine_room_hosted) { spt_proto::emit_line_err!("RESERVED_ID:{id}: {refusal}"); return EXIT_REFUSED; @@ -1108,7 +1108,7 @@ pub fn cmd_bind( // rule is not-as-a-first-mover rather than never. // [impl->REQ-ER-RESERVED-ID-SPAWN-REFUSAL] if let Some(refusal) = - crate::api::engineroom::reserved_bind_refusal(id, crate::api::engineroom::engine_room_hosted()) + crate::api::engineroom::reserved_bind_refusal(id, crate::api::engineroom::engine_room_hosted) { spt_proto::emit_line_err!("RESERVED_ID:{id}: {refusal}"); return EXIT_REFUSED; diff --git a/crates/spt/tests/bind_adapter_profile_persist_e2e.rs b/crates/spt/tests/bind_adapter_profile_persist_e2e.rs index e3f4617..b48aaa2 100644 --- a/crates/spt/tests/bind_adapter_profile_persist_e2e.rs +++ b/crates/spt/tests/bind_adapter_profile_persist_e2e.rs @@ -154,6 +154,12 @@ fn bind_over_created_profile_endpoint_preserves_the_profile() { out.status.success(), "the re-bind must succeed (same-session reconnect):\n{bind_err}" ); + // [int->REQ-ER-RESERVED-ID-SPAWN-REFUSAL] No engine room exists in this + // isolated home; an ordinary bind must not evaluate its diagnostic probe. + assert!( + !bind_err.contains("ER_HOSTED_PROBE:"), + "an ordinary bind must not emit an engine-room probe diagnostic:\n{bind_err}" + ); // The load-bearing claim: the bare-parent bind did NOT clobber the profile — // info.json.adapter still reads the full composite (the A-4 regression fix). // [int->REQ-HAZARD-ADAPTER-PROFILE-STAMP-CLOBBER] diff --git a/crates/spt/tests/io_events_poll_e2e.rs b/crates/spt/tests/io_events_poll_e2e.rs index f0a7671..d897954 100644 --- a/crates/spt/tests/io_events_poll_e2e.rs +++ b/crates/spt/tests/io_events_poll_e2e.rs @@ -250,6 +250,14 @@ fn a_fresh_session_seeds_over_real_history_and_then_sees_only_what_follows() { "TOOL_USE has no emitter and this verb does not invent one: {whole}" ); + // [int->REQ-IO-EVENT-POLL-VERB] An oversized cursor must not echo + // itself forever while a non-empty log sits below it. + let head = whole["cursor"].as_u64().expect("log head"); + let above_head = (head + 1).to_string(); + let beyond = poll(&spt_bin, home.path(), author, sid, &["--after", &above_head]); + assert!(kinds(&beyond).is_empty(), "{beyond}"); + assert_eq!(beyond["cursor"].as_u64(), Some(head), "{beyond}"); + // ── (6) The explicit cursor wrote no session state to collide with the // hook's own — the session cursor is exactly where (4) left it. let after_whole = poll(&spt_bin, home.path(), author, sid, &[]); diff --git a/docs-site/src/harness-contract/api.md b/docs-site/src/harness-contract/api.md index d0bb93a..a5a94be 100644 --- a/docs-site/src/harness-contract/api.md +++ b/docs-site/src/harness-contract/api.md @@ -453,7 +453,9 @@ digest and diffing it. - `--after ` answers with events newer than a seq you carry yourself, the way [`endpoint digest --after`](../cli/reference.md) does. It writes no session cursor and wins over the session cursor when both are passed. This is the mode - for a `--token` caller, which has no session identity. + for a `--token` caller, which has no session identity. A cursor above the log's + head returns no events and the actual head as `cursor`, so the caller can + resume from that lower value instead of remaining blind. A poll carrying **neither** cursor is refused by name (`IO_EVENTS_NO_CURSOR`, exit 2) rather than answered with an empty poll — a cursorless poll could only @@ -465,9 +467,18 @@ and replaying a backlog into a turn-boundary hook is the cost the delta discipline exists to avoid. Poll again after the next turn and you get that turn's events. + +On the first append to a log damaged by the former sequence-reset bug, core +repairs its retained rows under the log lock: file order and payloads are kept, +and sequences are reassigned above the old global maximum. A session carrying +an old cursor therefore sees retained history **once**, bounded by the log's +1250-row retention ceiling. This is not a new emission: adapters acting on old +`COMMUNE` content must still reject frames older than their current session. + **`--limit` says when it capped.** The answer carries `more`, and the rows it deferred are the next poll's first rows — a bounded poll never silently reads as -a complete one. +a complete one. While `more` is true, `cursor` is the last event handed over; +otherwise it is the log snapshot's true head, including ignored kinds. **`--json` is the adapter shape and is emitted even when empty:** diff --git a/docs/KNOWN-HAZARDS.md b/docs/KNOWN-HAZARDS.md index abb35be..8ab726b 100644 --- a/docs/KNOWN-HAZARDS.md +++ b/docs/KNOWN-HAZARDS.md @@ -450,6 +450,12 @@ Hard-won edge cases harvested from the sister project (`claude_skill_owl`, ~80 c - **Origin:** operator ruling 2026-09-06 on doyle's own funnel measurement (releases#276); built by todlando. +### 6.13 IO event sequences never reset at a byte-window boundary + +- **Failure:** seeking 256 KiB before EOF could land inside a UTF-8 codepoint; `read_to_string` returned `InvalidData`, the tail reader answered zero, and append minted sequence 1. Nine of nine measured resets on doyle's log matched this mechanism; four perches on the box were affected (releases#277). Duplicate/reset blocks blinded high cursors and replayed old COMMUNE rows to low cursors; value-based trim also undercounted or removed newer rows. +- **Invariant:** decode tail bytes lossily only after discarding the leading row fragment; append above the global maximum. Detect non-increasing sequences, including equal adjacent values and resets hidden between a lower first and higher last row. Under the exclusive lock, repair retained rows in file order above their old maximum, preserving payloads. Retention keeps the newest rows by position, not sequence value. +- **spt-core mapping:** `spt_store::iolog::{last_seq_at, append_locked, trim_locked}` and the mechanism, repair, and position-retention regression units. Repair can replay retained history once to an old cursor; it does not make old events new. + --- ## 7. Boundary & delivery integrity (added 2026-05-31 — Stage A red-team) @@ -1042,6 +1048,7 @@ The kill-path rule above generalizes: `daemon.pid` is not authority for *"which | 6.10 | Phase-significant loop timing is a durable absolute-deadline grid (no per-fire write; update preserves phase, crash resets, one-shot never resets) | durable loop timing / self-update (ADR-0018 Q4) | | 6.11 | Brain respawn execs the applied bytes (canonical exe captured at broker start, not per-spawn current_exe) + promotion bytes-gate (exe_hash == artifact, else rollback) | daemon respawn path / self-update (ADR-0018 Q3) | | 6.12 | The echo-commune brief never transits the agent's `-commune.md` (one writer on that path — the agent); the echo routes direct into the two-tier store | echo-commune seam / context tiering (releases#276) | +| 6.13 | IO event tail reads cannot mint false zero; append exceeds the global max, reset repair preserves file order, and trim retains by position | `spt_store::iolog` | | 7.1 | Local `api` mutation authenticated to endpoint | api surface / broker IPC | | 7.2 | Idempotent delivery across brain restart | broker↔brain IPC | | 7.3 | Psyche outbound captured + `from=`/target stripped + reply-to-sender / notify-to-own-user | live-Psyche driver / daemon relay (ADR-0012) | diff --git a/traceable-reqs.toml b/traceable-reqs.toml index 7f2f861..ff9cf40 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -4285,6 +4285,7 @@ requirements = [ [[groups]] name = "adapter-harness-contract" requirements = [ + "REQ-HAZARD-IOLOG-SEQ-MONOTONIC", "REQ-ADAPTER-ADD-SURFACE-ERRORS", "REQ-ADAPTER-FLOOR-ENFORCE", "REQ-ADAPTER-FLOOR-VS-STAGED-CORE", @@ -7473,6 +7474,12 @@ required_stages = [] # DEFERRED by ratification (#16, #17). Activate in the lan id = "REQ-IO-EVENT-ADAPTER-LOG" title = "ADAPTER-CONSUMABLE IO EVENTS LAND IN A PER-ENDPOINT APPEND-ONLY LOG REGISTERED AS A THIRD BUS SINK (releases#234, operator ruling 2). The funnel's claim was that a second reader costs a REGISTRATION and never a rework, and on the sink side that holds exactly as claimed — this consumer is one `bus.register` line in `default_bus`. THE READER IS FREE, THE STORE IS NOT, and this requirement is the honest half of that claim: neither existing sink writes a per-endpoint, ordered, cursorable surface — the shell-link sink spools per linked shell and the last-msg sink keeps two overwritten slots — so a delta-cursored poll needs a NEW DURABLE STORE, and that store is what this covers. EVERY ROW CARRIES ITS OWN MONOTONIC seq AS THE LINE'S KEY RATHER THAN AS A JSON FIELD: a row is `` TAB ``, so a cursor scan parses an integer prefix and never the body, and the ordering key cannot become an accident of serialization field order. THE LOG'S seq AND THE DIGEST seq ARE DIFFERENT NUMBERS AND ARE SPELLED DIFFERENTLY (`seq` versus `digest_seq`), because the digest remains the content surface that a truncated payload points at and one name for two counters is a consumer following the wrong one. THE LOG IS BOUNDED PER ENDPOINT AND TRIMMED OLDEST-FIRST so that an adapter which stops polling cannot grow it without limit; the bound is a STATED CHOICE derived from a measured event rate rather than a guessed number, and the trim is amortized against a slack so an ordinary append is not a whole-file rewrite. APPENDS ARE SERIALIZED under an exclusive advisory lock on a stable sentinel, because the daemon publishes from several edges and two racing appends must not mint a colliding seq. A SINK FAILURE IS STILL ONLY A REPORT: this store may not become the first sink whose bad day reaches the operation it observes." required_stages = ["doc", "impl", "unit"] # ACTIVATED in the delivering lane (todlando, CONDUIT W3, 2026-08-28). + +[[requirements]] +id = "REQ-HAZARD-IOLOG-SEQ-MONOTONIC" +title = "IO event log appends mint strictly above the global sequence maximum; a tail window beginning inside a UTF-8 codepoint never produces a false zero. Reset-damaged history is renumbered above its prior maximum in file order under the exclusive lock, and retention keeps the newest rows by position (releases#277)." +required_stages = ["impl", "unit"] + [[requirements]] id = "REQ-IO-EVENT-POLL-VERB" title = "spt api io-events IS THE DELTA-CURSORED POLL A HARNESS ADAPTER READS IO EVENTS THROUGH (releases#234; the operator DELEGATED the mechanics and CHOSE POLL over push). It answers with the rows the caller has not yet been shown and with nothing else. TWO CURSOR MODES OVER ONE ORDERING: `--session-id ` keeps a per-session cursor exactly as `api now-signal` keeps per-session seen-sets, and `--after ` lets a caller carry its own cursor exactly as `endpoint digest --after` already does; naming both is what keeps a stateless adapter and a session-keyed hook off two different verbs. THE CURSOR KEY IS THE AUTH SESSION ID AND NOT A SECOND FLAG BESIDE IT: the harness session is ONE identity, and a `--session` for the cursor sitting one character from a `--session-id` for the gate would be two ways to be wrong about it on a verb an adapter wires once; a token-authenticated caller has no session identity and uses `--after`. A POLL WITH NEITHER CURSOR IS REFUSED BY NAME (`IO_EVENTS_NO_CURSOR`, exit 2) RATHER THAN ANSWERED WITH SILENCE, because a caller who asked an unanswerable question must not read the answer as nothing having happened. A NEW SESSION'S FIRST POLL SEES NOTHING AND SEEDS ITS CURSOR SILENTLY — history is the digest's job, and replaying an unbounded backlog into a turn-boundary hook is the exact cost the now-signal's delta discipline exists to avoid, with EDGE_TRANSITIONS the standing precedent for seeding silently for that reason. PROVING THIS NEEDS THE SEEDED-EMPTY FIRST POLL ASSERTED BESIDE A NON-EMPTY SECOND ONE, because an assertion that the first poll is empty passes just as well against a verb that emits nothing ever. ALL SIX EMITTED KINDS ARE VISIBLE — USER_INPUT, AGENT_OUTPUT, MSG_IN, MSG_OUT, COMMUNE, COMMUNE_FAIL — and AN UNKNOWN KIND IS IGNORED RATHER THAN REFUSED, the same posture the now-signal category vocabulary takes toward a name it does not know. TOOL_USE STAYS UNEMITTED AND THIS VERB DOES NOT CHANGE THAT: measurement says the harness adapter is its natural emitter, which is a question back to deployah and then the operator and must not ride in on this verb. THE PAYLOAD BOUND IS THE 16KB CLASS WITH A truncated FLAG AND THE DIGEST POINTER, MATCHING THE SHELL FRAME AS A CHOICE AND NOT AS AN INHERITANCE — `IoEvent.payload` is deliberately unbounded at the bus layer and the cap belongs to the frame — so that one event reads identically through either transport and a consumer needing the whole body follows the pointer into the digest. THE POLL IS AUTHENTICATED THE WAY `api poll` IS, AND FOR THE SAME REASON: it hands back the session's VERBATIM user input and agent output, which is the payload class addressed to the endpoint's occupant rather than to whoever asks. This is a DELIBERATE DEPARTURE from its sibling reader `api now-signal`, which is ungated because it renders DERIVED summaries — a ten-word excerpt, a category count — and never a raw payload; the gate follows the content, not the verb family. Proof is the `--session-id` an adapter already passes to `api state`, or a capability token, so the gate costs a compliant adapter nothing."