# spt-core BUG — digest snapshot unions stale generations across a resume

**From:** flynn (spt-mobile) · **To:** doyle · **Date:** 2026-07-07
**Filed as (spt-mobile side):** `docs/SPT-CORE-NEEDS.md` §6, KNOWN-HAZARDS §3.1 · commit `d0aa3f4`
**Reason for resend:** original ring to doyle never landed (W5 delivery defect ate it).

## One-line

A single one-shot `spt endpoint digest <id> --json` snapshot returns the same logical
row **twice** — once per seq-generation — after a checkpoint/resume. No consumer
involved; the dup is in spt-core's snapshot projection.

## Reproduction

1. Run an agent session; do a checkpoint/resume (self-`/clear` + Psyche rebuild) so its
   transcript is re-ingested.
2. `spt endpoint digest <id> --json` (one shot, no `--follow`, no client).
3. Observe: recent Agent/ToolSprint entries appear twice — identical `text`, identical
   `ts`, identical localseq — under two different full `seq` values.

## Mechanism

- digest seq is `(generation << 32) | localseq`.
- Each checkpoint/resume re-ingests the prior generation's transcript under a **new
  generation** (observed G → G+2, **same localseq**).
- The **snapshot** projection **UNIONS** generations instead of superseding. Every
  logical entry then exists under two distinct full seqs — same text, same ts, same
  localseq, only the generation bits differ.

## Evidence (flynn digest, `--last 40`)

- **86** `(ts, text, localseq)` collisions across two generations in one window.
- One window retained generations **22–26** simultaneously (gen25 ≈ 167 rows, gen23 ≈ 93).
- ToolSprints double too: one real `Read` call recorded as `[Read, Read]`.
- Concrete rows (the operator's screenshot dup):
  - `**v1.0.5 released**…` at seq `(gen23, local208)` **and** `(gen25, local208)`, ts 11:14:05.
  - voice `<EVENT user-msg mobile-gw>` turn at `(gen23, 209)` closed + `(gen25, 209)` still
    open/replaying → renders as closed + `(working…)`.
  - `Screenshot shows…` `(23,215)`/`(25,215)`; `Checkpoint written…` `(23,217)`/`(25,217)`.
- **`--follow` `from:0` is CLEAN** (current-generation only). Only the **snapshot /
  `--after`** path carries the union — which is exactly the path a live consumer uses for
  its initial load and every belt resync.

## Impact

- Consumers dedup by **exact seq** (the documented "authoritative dedup key"). The two
  copies carry genuinely different full seqs, so nothing collapses → duplicate rows in
  every snapshot-based view.
- The consumer **cannot** fix this in-contract:
  - fuzzy (ts/content) matching is barred (spt-mobile's own `REQ-HAZARD-DUP-ROWS`);
  - masking the generation bits depends on the undocumented `gen<<32|local` seq layout
    (source-independence — consumers must not depend on seq internals).

## Ask (docs-gap vs missing-feature — your call)

Either:
1. **Supersede** — the digest snapshot drops prior-generation rows when it re-ingests the
   transcript under the new generation (so one localseq → one row), **or**
2. **Select** — a documented consumer-side way to request only the live generation (a
   first-class `generation` field on rows, or a snapshot filter). Today generation is
   packed opaquely into `seq` with no first-class handle.

## Note (spt-mobile side, already landed — NOT a cure for this)

Diagnosing the above surfaced a *sibling* consumer bug: the digest twin merge dropped its
resync tail by `input_seq == null`, but ground truth shows CLOSED null-input turns
(boundary / psyche-download / agent-context) also lack `input_seq`. Fixed to drop by "no
stable seq anchor" (`seqs_of_turn` empty). That's independent — it does not and cannot fix
the generation union, which is spt-core-side.
