# io-events seq reset past 256KB — lane JIT

Authored by doyle 2026-09-06 ~08:50Z. Finding: perri (adapter side, retraction of
his own refutation), mechanism measured by doyle on the raw log bytes. Product
change in `spt-store` → todlando, thin lane, lands AFTER v0.67.1 and after #276
(same builder). Source cited at `04e32c8c` (= main `8a21a3b0` for every file
here). Releases issue: #277 (minted 2026-09-06 08:44Z).

Issue: `BigscreenVR/spt-bs-releases#277`

## 0. Measured

- doyle funnel `--after 0`: 1183 events, 313 distinct seqs, max dup 10, 121
  COMMUNE frames carrying `!!wake!!`. 9 resets to seq 1 (one is a 1→1 duplicate
  a `<` test misses — use `<=`).
- Raw log `%LOCALAPPDATA%\spt-core\owlery\doyle\io-events.log` (1.81MB, 1191
  rows): for EVERY reset, the reconstructed `len_before - 256KB` window start
  byte is a UTF-8 continuation byte (0x80-0xBF) and the window does not decode.
  9/9 explained; 0 continuation-byte window starts that did NOT reset.
- Box census (15 perches): resets only on the four logs >256KB (doyle 9,
  todlando 5, perri 2, deployah 1); 0 on all 11 logs under 256KB.
- Trim arithmetic: doyle rows=1191, `last-first+1` = 147 → retention floor
  (1000+250) never reached by the counter; log grows unbounded.

## 1. Mechanism (`crates/spt-store/src/iolog.rs`)

`last_seq_at` (:199-232): `seek(len - TAIL_WINDOW)` then `read_to_string` into a
`String`. A seek landing mid-codepoint makes `read_to_string` return
`InvalidData`; the fn answers 0 (:215-217); `append_locked` (:270) mints
`0 + 1 = 1`. The fragment line is discarded anyway (:227-230), so the decode
failure buys nothing — it is a pure false zero. `first_seq_at` (:236-247)
already reads bytes + `from_utf8_lossy`; the tail reader should have matched it.

Downstream of a false zero:
1. Every later append continues from the false block (2, 3, …) — seq is no
   longer a cursor; api.md:482 ("this log's own cursor") is false.
2. `read_after_at(after)` filters by VALUE (:338): a carried cursor above the
   new block is BLIND (empty forever until the block climbs past it); a low
   cursor REPLAYS every older block's rows above it — including COMMUNE frames
   that carry live wake markers. That is the adapter-side refire.
3. Seed path (`ioevents.rs:143`) seeds at the false head → the next poll replays
   older blocks too.
4. `trim_locked` (:291-309): `rows = last - first + 1` under-counts (never
   trims) OR, when the current block outgrows the head seq, filters
   `s >= keep_from` by value across ALL blocks and deletes the NEWEST block's
   low-seq rows. Retention floor is not a floor.

## 2. Shape (binding)

**A. Fix the reader** — `last_seq_at`: read the tail window as BYTES
(`read_to_end`), drop everything up to and including the first `\n` when
`start > 0` (the fragment rule already there), then decode with
`String::from_utf8_lossy` (a row's JSON may legitimately be UTF-8; the seq
prefix is ASCII, `line_seq` only needs the prefix). A decode failure is no
longer a possible arm — delete the `is_err → 0` return. Keep the
"window reached no line start → whole-file read" arm (:220-225).

**B. Make the monotonic invariant EXPLICIT** — `append_locked` mints
`max(last_seq_at, <highest seq ever handed out>) + 1`. Cheapest true source of
"highest ever": the tail read now returns the max over the tail WINDOW (not the
last line), and the existing logs on the box carry blocks whose max is above the
last line (doyle: last 147, global max 313). Decide between (i) max over the
tail window only (bounded cost, still wrong if the older block lies outside the
window) and (ii) one whole-file max scan ONLY when the last line's seq is below
the first line's seq (`first_seq_at` is already read per trim; a single
`last < first` comparison detects a reset-damaged log cheaply and pays the
full scan once per append until repaired). Lean (ii) + repair (C).

**C. Repair damaged logs once, in place** — on the first append that detects
`last < first`, rewrite the file under the exclusive lock: keep every row in
FILE order, renumber `global_max + 1 …` (so every existing row gets a seq above
any cursor ever handed out on that perch). State the consequence in the PR +
docs: **a session carrying an old cursor will see the retained history ONCE**
after the repair. That is the honest replay: it is bounded (≤1250 rows), it
happens once, and it is what `--after` promised. Adapters that must not re-arm
on old COMMUNE frames need perri's 0.38.4 refusal (frame older than the current
session) — cite it in the PR body as the adapter half, not a core dependency.
Fork for the lane: if todlando measures the one-time replay as unacceptable
for a specific adapter surface, stop-and-refer with the measurement; do not
silently choose "drop history" — history loss is the digest's contract to
break, not this log's.

**D. Trim by POSITION, not by value** — `trim_locked` counts rows by lines (the
file is read whole for the rewrite anyway; count first, rewrite only when over
the bound) and keeps the last `IO_LOG_MAX_ROWS` LINES. With (A)+(B) the seq is
monotonic again so value-vs-position agree; position is the one that stays
right if anything ever regresses (A).

**E. Docs** — api.md:482 stays true after the fix; add ONE sentence in the
`--after` bullet (:453-456): a cursor above the log's head answers empty, so an
adapter that persists a cursor across core upgrades should treat a long silence
with a non-empty digest as a cursor to re-seed (`--session-id` seeds itself;
`--token` callers own that). Cross-check `docs-site/src/shells/frames.md` and
`reference/json-shapes.md` for any "seq never repeats" prose — read every hit.

**F. Traceability** (`traceable-reqs.toml` FIRST, then `check`, exit 2 = did
not parse; no `"` in titles):
- Mint `REQ-HAZARD-IOLOG-SEQ-MONOTONIC` — *the io-events seq is strictly
  increasing for the life of a perch's log; a tail read never answers a false
  zero* — stages `impl`, `unit`. KNOWN-HAZARDS new entry (Failure: 256KB tail
  seek mid-codepoint → `read_to_string` InvalidData → seq 1; 9/9 on doyle's log,
  four perches on one box; Invariant: tail reads decode lossy after the
  fragment drop, append mints above the global max; mapping: `iolog.rs
  last_seq_at/append_locked/trim_locked`) + table row.
- `REQ-IO-EVENT-ADAPTER-LOG` gains the repair + position-trim units.

## 3. Tests (all in this lane)

- **The mechanism unit** `[unit->REQ-HAZARD-IOLOG-SEQ-MONOTONIC]`: build a log
  whose byte length puts `len - TAIL_WINDOW` INSIDE a 3-byte char (pad payloads
  with `\u{4e16}`; compute the offset, assert the byte at it is 0x80-0xBF as the
  test's own precondition), then `append_at` and assert the seq is `last + 1`,
  not 1. **Mutation proof in the PR body**: restore `read_to_string` and this
  test mints 1.
- Repair unit: a hand-built log with two blocks (…203, 1, 2, 3) → one append →
  file rows in original order, seqs `204..`, new row `204 + n`, no row lost
  (byte-compare payloads).
- Trim unit: a log of 1251 rows with a reset inside it → trim keeps the LAST
  1000 lines by position; the newest rows survive (the value-filter would have
  deleted them — write that as the negative control).
- Existing `appends_assign_monotonic_seqs_from_one`, `seqs never rewind over a
  trim` (:545) stay; `io_events_poll_e2e` blind-cursor arm: a `--after` above
  the head still answers empty (that contract is right; the log was wrong).
- Cross-OS: the byte-offset arithmetic is OS-neutral but run the mechanism unit
  on kitsubito too — CRLF never enters this file (rows end `\n`, :164) but say
  so in the test comment.

## 4. Base + branch + gate

Base `origin/main` after v0.67.1 lands; branch `fix/277-iolog-seq-reset`,
worktree `.worktrees/iolog-277`, own pool, claim from inside. Gate: treqs →
workspace-bins prebuild → `xtask check` → clippy → `nextest -p spt-store -p spt`
filtered `iolog|io_events|boundary_events` → kitsubito same legs → exit FILES
read, pick-audit 1/1 at land. Field acceptance: after the node binary applies
and the daemon restarts, `spt api io-events doyle --after 0 --json` shows
distinct seqs == events, and one more turn appends `max + 1`.

## 5. Out of scope

- Adapter frame-leg session refusal (perri, 0.38.4).
- The digest's own `seq` (different counter, different store, not measured).
- `worker_seq` (named as kin in :253) — census it in the PR body (does it tail-
  read UTF-8 the same way?) and file separately if so; do not fold it in.
