Files
pad/web/src
xarmian a2195997f7 fix(web): an entry missing from a refreshed first page is not necessarily deleted (BUG-2773) (#1203)
* fix(web): an entry missing from a refreshed first page is not necessarily deleted (BUG-2773)

The SSE refresh re-fetches the FIRST page — the newest N entries — and treated
anything previously on it and now absent as deleted. Once enough newer entries
exist, a perfectly alive entry rolls off that window, and it disappeared from
the reader's view; for anyone who had pressed Load More it vanished from the
MIDDLE of a timeline whose neighbours on both sides were still shown. A full
reload brought it back, which is the tell that this was display state and not
data.

Per the lead's ruling (option 1): deletion is inferred only for a position the
fresh page still COVERS — at or newer than its oldest entry, compared in the
same (created_at, id) space the server's cursor uses. Anything older is out of
window and left alone. An empty fresh page covers nothing and so deletes
nothing: it means every row in that window was unrenderable, not that the
history was erased.

Every test leg pairs a roll-off with a real deletion, because a fix that simply
stopped removing anything passes the roll-off half alone. The refresh helper
asserts the refresh actually FIRED — the first version of these tests waited
400ms against a 500ms debounce, and one leg passed vacuously on a refresh that
never happened.

Mutation matrix, each detected by its own leg: the old rule (3 legs); string
comparison instead of instants at the boundary (the sub-second leg); an empty
fresh page covering everything; the id tie-break dropped.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): a final page covers everything, and coverage replaces the first-page gate (codex round 1)

Two findings, both real.

has_more was ignored. A refresh whose page is FINAL returned the whole
history, so an entry the client holds and that page does not contain has
nothing to have rolled off into — it is gone, deleted or no longer renderable.
Coverage now extends to everything on a final page, which is also what makes an
empty FINAL page clear the view while an empty page with more behind it still
deletes nothing. Both directions have a leg; asserting either alone would let
"empty always clears" or "empty never clears" pass.

firstPageIds is retired rather than repaired. It tracked the last first-page
fetch so older-page entries would not be judged by a first-page comparison —
which coverage now does directly and better: an older-page entry sits below the
floor and is left alone, while an entry INSIDE coverage that the page does not
contain is gone regardless of which page delivered it. Keeping both would have
leaked: an entry preserved as a roll-off dropped out of the tracked set, so a
later window expanding back over it could never remove it again.

Mutation matrix, six mutations, each detected: no final-page rule (2 legs); the
old missing-means-deleted rule (3); never deleting (5); string comparison at
the boundary; the id tie-break dropped; an empty non-final page treated as
covering.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): fence overlapping SSE refreshes, and state what inference cannot do (codex round 2)

Only the newest dispatched refresh may write. The item/workspace check catches
a switch but not two refreshes of the SAME item in flight at once — the retry
path fires 2s after a failure while a newly debounced one is already running —
and an older response landing last re-adds an entry the newer one removed or
removes one it added. A monotonic seq, a plain let rather than $state because
it is read and written inside the refresh (CONVE-1688). Its test holds both in
flight and resolves them out of order; it fails with the fence removed.

Two other round-2 findings are DOCUMENTED, not fixed, because they are limits
of inferring deletions from a first-page comparison rather than defects of this
change, and the lead ruled the event-based alternative out of scope for this
unit:

  - an entry deleted below a non-final page's floor is never inspected again
    and stays until a reload. The old rule removed it — by removing every
    rolled-off entry with it, which is the bug being fixed. Strictly better,
    not complete.
  - whether a row renders depends on the window it was fetched in, since the
    cross-source drops need both rows in one fetch, so an entry that rendered
    on an older page can be absent-and-covered here. That matches what a fresh
    load shows, which is the ceiling for any first-page comparison.

The fourth finding — the refresh not adopting has_more/next_cursor — is
declined a second time, on the same grounds and now with its concrete harm
checked: after paging to the end the reader already HOLDS everything behind
page one, so a fresh has_more=true strands nothing.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* test+docs: a leg that fails against the OLD rule, and three claims corrected (codex round 3)

The two final-page legs passed against main: the old gate removed any entry
missing from a refreshed first page unconditionally, so they discriminated
against an intermediate version of this fix and not against the behaviour it
replaces. Added the leg that does — an entry loaded via LOAD MORE, which the
old gate preserved unconditionally because it had never been on a first page,
and which a final refreshed page must now remove. Verified by restoring the
pre-fix rule: that leg fails, along with three others.

Two comments were false and are corrected rather than softened:

- Sub-second timestamps are NOT reachable from this server. The store writes
  RFC3339 seconds and the handler truncates the structured kinds' hand-written
  ones to match (handlers_timeline.go's stamp()). The instant-vs-string
  comparison is a guard on what the ordering MEANS, not a live scenario, and
  both the code and the test that pins it now say so. The claim came from the
  BUG-2765 unit and was wrong there too.
- Comment-linked activities are excluded by the store's SQL whether or not the
  comment is in the window, so they are not an example of window-dependent
  rendering. The version-coincidence suppression is; the note names that one
  now.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): a stale Load More page cannot resurrect a deleted entry (codex round 4)

A paging request can be in flight while a refresh removes an entry as deleted.
Its page is older than the refresh and still carries that row, so appending it
verbatim put the entry back on screen — visibly undoing a deletion the reader
had already seen happen.

The refresh now records what it removed, and Load More filters its page through
those tombstones. Chosen over discarding the whole stale page (the reader's
click would do nothing) and over a generation fence (same). The set is cleared
whenever loadTimeline resets the view, so it is bounded by one mount's
deletions.

Its test holds the paging response, deletes the entry via a refresh in between,
then lands the stale page and asserts both halves: the deleted row does not
come back AND the rest of the page still does — without the second, discarding
everything would pass. Fails with the tombstone check removed.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): discard a stale Load More page instead of tombstoning what it may resurrect (codex round 5)

Round 4's tombstone set answered the right question with the wrong machinery,
and round 5 found the bill: it grew for the life of a mount with no prune, its
clear point raced older paging continuations (a same-item mutation calls
loadTimeline, clears the set, and a stale page can then resurrect a deleted
entry anyway), and it would suppress a same-id structured entry legitimately
rewritten. Each of those is fixable; together they are a sign the mechanism was
too clever for the race it guards.

Replaced with the fence already used for overlapping refreshes: loadMore
captures the refresh sequence before its await and discards the response if a
refresh applied in between. One integer, no growth, no lifetime, nothing to
clear at a switch. The page is dropped WHOLE rather than filtered — it was
assembled before the deletion and nothing in it reflects the current view — and
the cursor is untouched, so Load More is still offered and the next click
fetches the same page against the current state.

The test now asserts all three halves: the deleted entry does not come back,
the rest of the stale page does not land either, and the button is still there.
Without the third that would read as a silent drop rather than a deliberate
one. Fails with the fence removed.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): one view generation for reloads as well as refreshes (codex round 6)

The fence only counted SSE refreshes, so the path that most obviously replaces
the view did not advance it: a local comment delete calls loadTimeline, page 1
comes back without the entry, and a Load More page in flight from before that
re-adds it. Identity and sequence both pass, because neither noticed.

One counter now, incremented by every reload and every refresh, captured by
every continuation that writes entries — including loadTimeline itself, which
could otherwise overwrite a newer view with an older page-1 response.
"The view was replaced" is the same fact whichever path replaced it.

Tested through the harness-reachable form: a Load More page held across a
switch AWAY and BACK. By the time it resolves the identity check passes again —
same item — and only the generation can tell that the view it was fetched
against has been replaced twice. Fails with the reload's increment removed.

The local-delete path itself is not directly driven: it needs the comment
controls, which need mutationsEnabled plus canEditItem plus the confirm flow.
It goes through the same single increment as the switch case, but that is an
argument, not a test, and this note is here so nobody reads the coverage as
wider than it is.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): count view replacements that LANDED, and fence the cleanup paths (codex round 7)

Two regressions my own fence introduced.

The generation advanced at DISPATCH, so a refresh that then FAILED still
invalidated an in-flight reload: the reload's good response was discarded and
the view stayed empty until something else redrew it. It now advances where the
write lands — a request that never writes is not a replacement. Its test holds
the initial load, fails a refresh across it, and asserts the load still renders;
it fails when counting goes back to dispatches.

The catch and finally only checked identity, so a stale request's cleanup could
clear the spinner a newer one owns or restore an old error over a current load.
Both are gated on ownership now.

Ownership needed a flag rather than a bare reqGen === viewGen: the writer
advances the generation ITSELF, so after writing it reads as stale by its own
test — the first version of this blocked its own `loading = false` and left an
empty page under a permanent spinner. Caught by two existing tests failing, not
by review, which is the instrument working.

(This message is a re-write: the first one was passed through a double-quoted
shell string and the backticked span was executed and blanked — CONVE-13, the
convention that exists because of exactly this. Caught by re-reading the
artifact rather than the success line, which is the other half of it.)

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): ticket plus high-water mark, so newest wins and a failure costs nothing (codex round 8)

One counter could not express both halves, and each single-counter version
failed a different way. Counting DISPATCHES let a refresh that then failed
invalidate an in-flight reload (round 7). Counting APPLIES let an OLDER
response landing first claim the view and lock the newer one out behind it
(round 8) — the mirror of the case the out-of-order test already covered, and
the reason that test alone was not enough.

Every view-replacing request now takes a unique increasing ticket at dispatch
and may write only if it beats the high-water mark of what has actually
written, which it then owns. Newest wins among concurrent responses, and a
request that never lands costs nothing.

Second round-8 finding, same root: an overlapped load declines to clear the
spinner once a newer write has landed, so the writer has to. Without it an
empty refresh result sat under a permanent spinner and the list never rendered.

Mutation checks, each with its anchor count asserted after two mutations
silently failed to apply and left a GREEN run that proved nothing: the refresh
not clearing the spinner fails the new spinner leg; the round-7 applied-counter
semantics fail the new newest-wins leg; the loadMore fence removed fails the
switch-away-and-back and stale-page legs.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): a reload clears loadingMore too (codex round 9)

loadMore's cleanup is identity-guarded, so a page resolving while the reader is
on another item never cleared the flag. Coming back found Load More permanently
disabled — a dead control, which reads as "there is nothing more" rather than
as a bug. Pre-existing (the identity guard is TASK-2112's), and one line to
close now that loadTimeline is already the single place paging state resets.

The existing switch test released its stale page AFTER returning, which is the
ordering that never exercised this; the new leg resolves it while away and
asserts the button comes back enabled. Fails with the reset removed.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): the coverage boundary is the server's cursor, not the oldest row returned (codex round 10)

How far back a page LOOKED and how far back it RETURNED rows are different
positions, and BUG-2765 made the first one available: next_before is where the
next page starts, so everything at or newer than it was examined. When one
source exhausts its over-fetch window while another returns an older rendered
row, the cursor sits NEWER than the page's oldest entry — and judging by the
returned floor then treats a live entry from the exhausted source, one this
page never reached, as deleted.

Using the cursor makes the rule say what it means. The returned floor stays as
the fallback for a server predating that field; it is the slightly-too-eager
version, and still narrower than the rule this unit replaces.

Two comments corrected rather than left: "the oldest entry the fresh page
reached" was the oldest RETURNED, and "has_more=false means the server returned
everything it has" ignored that rows are fetched and dropped as unrenderable —
which is precisely why absence from a final page still means gone.

The new leg builds the shape only a server can produce (cursor newer than the
oldest returned row) and asserts both sides of the boundary: the entry below
the cursor is kept, the one above it is removed. Fails with the boundary put
back to the returned floor.

Refs: BUG-2765, BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* docs: three of my own claims corrected, including one a commit message overstated (codex round 12)

- The refresh's comment still said only the newest DISPATCHED refresh may
  write. Round 8 replaced that with ticket-versus-high-water, which lets an
  older response write first and be replaced by the newer one — deliberately,
  and with its own test. The comment described the scheme two rounds ago.
- The final-page test still said has_more=false means the server "returned
  everything it has". It reached the end of the rows; some were dropped as
  unrenderable on the way, which is why absence from a final page still means
  gone. My round-10 commit claimed this wording was corrected — it was, in the
  component, and not here. The claim was true of half the sites and written as
  though it covered both.
- The file header said every leg pairs a roll-off with a real deletion. That
  was true of the first leg and stopped being true as the legs accumulated; and
  the empty-final-page leg described "both refreshes" when it makes one. Both
  now say what the tests do.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X

* fix(web): a superseded refresh does not retry (codex round 13)

The retry exists so a transient failure does not leave the panel quietly stale
(BUG-2508). Once a newer refresh has written the view there is nothing stale to
repair: the retry is traffic for a question already answered, and its answer
would arrive older than what is on screen. Guarded on the same high-water mark
every other write path uses.

Its test fails an older refresh after a newer one landed and asserts the
request COUNT does not move across the retry backoff — the entries look
identical either way, so the count is the only thing that distinguishes the
two behaviours. Fails with the guard removed.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-25 18:01:22 -04:00
..