fix(870): an empty timeline page is not exhaustion — the walk reads to its cap #896

Merged
timothy merged 4 commits from fix/870-timeline-walk-filtered-page into main 2026-08-30 13:29:39 +02:00
Owner

fixes #870
refs #803, #706, #664, #751, #893

The defect

count_pr_mutations in .gitea/workflows/review-verdict.yml treated an empty page past page 1 as
proof it had reached the end of the PR timeline. Gitea does not mean that.
ListIssueCommentsAndTimeline applies the LIMIT/OFFSET in FindComments at the DATABASE level and
filters AFTERWARDS, dropping every CommentTypeCode row and every inaccessible cross-reference into
a nil slice that serializes as bare null. A page whose 50 rows are all inline review comments is
byte-identical to a page past the end, while later pages still hold events — and rows are ASCENDING,
so the events a fence looks for are the furthest from page 1.

Fifty inline comments, which a PR author can create on their own PR, truncated both walks at the
same place: both counts agreed, the sha comparison agreed, and an ABA force-push (H1 -> H2 -> H1)
yielded an exemption success over a diff no single head justified.

The fix — NARROWED, not closed, and the docs say so

The walk no longer infers the end from an empty page before its cap. Such a page is skipped; the
loop reads every page to its pre-existing 20-page cap and trusts the counts only when the LAST page
came back empty — the contract the sibling status walk already stated for itself. An empty FIRST
page and any unreadable shape still end the walk untrusted.

One empty page is still trusted: the terminator at the cap, for the same unprovable reason the
page-2 terminator was. The price of the defeat rises about 10x — the 50-row filtered block is
unchanged, but the timeline it must sit in grows from ~100 rows to over 1000, with the block pinned
to offsets 950..999. That residual is stated in the workflow, docs/ci-cd.md,
docs/remote-state-inventory.md and the decision record, in one unit, rather than left in the issue.

"Require K consecutive empty pages" was rejected: K is arbitrary and only moves the bar to 50K
filtered rows. The cap is not arbitrary — it is the coverage this walk already claimed to perform.

Measured at Gitea 1.27.1, because the cheaper fixes are ruled out by measurement

probe result
X-Total-Count on /issues/{n}/timeline post-filter length of the PAGE — ?limit=1 returns 1 on a 14-row timeline
X-Total-Count on /activities/feeds a TRUE total (5739) — so it is a property of the handler, not the server
?limit=200 on the timeline clamps to 50
the endpoint's query params (live swagger) since, before, page, limit — no row-type filter

A since-bounded walk was considered and not taken on its own: it narrows the attack to the run's
own window but leaves the identical ambiguity inside it, and costs the "a real page was actually
read" property that an anomalous empty page 1 provides.

Also in here

Two changes answering the amplification this fix introduces (~2 requests per walk to a fixed 20,
three walks per exempt run):

  • each page bounded --connect-timeout 5 --max-time 15, applied at this call rather than in gh so
    no other call site changes behaviour. The third walk runs after the exemption success is posted,
    where a hang leaves the green standing with no repair attempted;
  • each page retried once, mirroring page_statuses and its stated reason. This walk had no retry
    when it made ~2 requests; at 20 it needs the sibling's.

Plus 2>/dev/null || x="" on the four jq reads, matching the sibling — unreachable today only
because of an ordering three lines above, which is the reasoning the adjacent comment says it is
retiring.

Costs, stated

  • Worst case 40 requests and 20 sleeps per walk; wall-clock pessimum 20x(15+1+15) = 620s, and
    the job has no timeout-minutes. Strictly better than the predecessor, which had no timeout at
    all — but bounded HERE only: page_statuses is still unbounded in the same post-POST window.
  • The suite roughly doubled: test_pr_changed_files.py 202s -> 474s (286 -> 294 tests, same
    machine, -p no:randomly, 528383cf3 vs this branch). testing.mutation-claims-are-executed
    carried a ~4min script-tests figure measured before this; it is dated there rather than left.

Verification

Three tests reproduce the defeat (head axis over both empty shapes, and its base-axis twin) and go
red against the shipped predecessor, granting state: success on the hidden ABA. Four more hold
the new clauses: a quiet-PR positive control, a filtered page before a full cap page, a
single-transient-blip fixture parameterised over both blip shapes, and a structural timeout guard.
Each was mutated and witnessed red; an independent reviewer re-measured all seven.

Full suite: 1355 passed, 2 skipped, rebased on 528383cf3.

Review

Two independent cold reviews, then a re-review of the fix commit. They found no Blocker or High in
the code and were right about the prose: the first draft claimed the hole was "closed" in six places,
one of them contradicting the paragraph above it. The re-review caught the cost arithmetic being
wrong twice (sleeps, then wall-clock) and a fixture that proved nothing — the retry's bad-body
coverage was pinned by a 502 HTML page, which jq fails on, so it took the same path as a transport
error and the mutant survived. The shape that discriminates is a JSON error object, which is what
this Gitea actually returns.

One reviewer claim was refuted by execution: Codex reported that deleting the per-iteration
empty=no reset would leave all four new tests green. Deleting it turns three red — a stale yes
also suppresses the tally on every later non-empty page.

The cross-family arm was unavailable for the final round (Codex hit its usage quota mid-session);
the re-review is same-family, from a cold brief in an isolated worktree.

Follow-up filed

#893page_statuses still terminates on its first empty page, and whether /statuses/{sha}
shares the post-pagination filtering that made this a defect is not established. Evidence in the
issue, stated as evidence rather than proof.

fixes #870 refs #803, #706, #664, #751, #893 ## The defect `count_pr_mutations` in `.gitea/workflows/review-verdict.yml` treated an empty page past page 1 as proof it had reached the end of the PR timeline. Gitea does not mean that. `ListIssueCommentsAndTimeline` applies the LIMIT/OFFSET in `FindComments` at the DATABASE level and filters AFTERWARDS, dropping every `CommentTypeCode` row and every inaccessible cross-reference into a nil slice that serializes as bare `null`. A page whose 50 rows are all inline review comments is byte-identical to a page past the end, while later pages still hold events — and rows are ASCENDING, so the events a fence looks for are the furthest from page 1. Fifty inline comments, which a PR author can create on their own PR, truncated both walks at the same place: both counts agreed, the sha comparison agreed, and an ABA force-push (`H1 -> H2 -> H1`) yielded an exemption `success` over a diff no single head justified. ## The fix — NARROWED, not closed, and the docs say so The walk no longer infers the end from an empty page **before** its cap. Such a page is skipped; the loop reads every page to its pre-existing 20-page cap and trusts the counts only when the LAST page came back empty — the contract the sibling status walk already stated for itself. An empty FIRST page and any unreadable shape still end the walk untrusted. One empty page is still trusted: the terminator at the cap, for the same unprovable reason the page-2 terminator was. The price of the defeat rises about 10x — the 50-row filtered block is unchanged, but the timeline it must sit in grows from ~100 rows to over 1000, with the block pinned to offsets 950..999. That residual is stated in the workflow, `docs/ci-cd.md`, `docs/remote-state-inventory.md` and the decision record, in one unit, rather than left in the issue. "Require K consecutive empty pages" was rejected: K is arbitrary and only moves the bar to 50K filtered rows. The cap is not arbitrary — it is the coverage this walk already claimed to perform. ## Measured at Gitea 1.27.1, because the cheaper fixes are ruled out by measurement | probe | result | |---|---| | `X-Total-Count` on `/issues/{n}/timeline` | post-filter length of the PAGE — `?limit=1` returns 1 on a 14-row timeline | | `X-Total-Count` on `/activities/feeds` | a TRUE total (5739) — so it is a property of the handler, not the server | | `?limit=200` on the timeline | clamps to 50 | | the endpoint's query params (live swagger) | `since`, `before`, `page`, `limit` — no row-type filter | A `since`-bounded walk was considered and not taken on its own: it narrows the attack to the run's own window but leaves the identical ambiguity inside it, and costs the "a real page was actually read" property that an anomalous empty page 1 provides. ## Also in here Two changes answering the amplification this fix introduces (~2 requests per walk to a fixed 20, three walks per exempt run): - each page bounded `--connect-timeout 5 --max-time 15`, applied at this call rather than in `gh` so no other call site changes behaviour. The third walk runs after the exemption `success` is posted, where a hang leaves the green standing with no repair attempted; - each page retried once, mirroring `page_statuses` and its stated reason. This walk had no retry when it made ~2 requests; at 20 it needs the sibling's. Plus `2>/dev/null || x=""` on the four jq reads, matching the sibling — unreachable today only because of an ordering three lines above, which is the reasoning the adjacent comment says it is retiring. ## Costs, stated - Worst case **40 requests and 20 sleeps** per walk; wall-clock pessimum 20x(15+1+15) = **620s**, and the job has no `timeout-minutes`. Strictly better than the predecessor, which had no timeout at all — but bounded HERE only: `page_statuses` is still unbounded in the same post-POST window. - The suite roughly doubled: `test_pr_changed_files.py` **202s -> 474s** (286 -> 294 tests, same machine, `-p no:randomly`, 528383cf3 vs this branch). `testing.mutation-claims-are-executed` carried a `~4min script-tests` figure measured before this; it is dated there rather than left. ## Verification Three tests reproduce the defeat (head axis over both empty shapes, and its base-axis twin) and go **red against the shipped predecessor**, granting `state: success` on the hidden ABA. Four more hold the new clauses: a quiet-PR positive control, a filtered page before a full cap page, a single-transient-blip fixture parameterised over both blip shapes, and a structural timeout guard. **Each was mutated and witnessed red**; an independent reviewer re-measured all seven. Full suite: **1355 passed, 2 skipped**, rebased on `528383cf3`. ## Review Two independent cold reviews, then a re-review of the fix commit. They found no Blocker or High in the code and were right about the prose: the first draft claimed the hole was "closed" in six places, one of them contradicting the paragraph above it. The re-review caught the cost arithmetic being wrong twice (sleeps, then wall-clock) and a fixture that proved nothing — the retry's bad-body coverage was pinned by a 502 HTML page, which jq fails on, so it took the same path as a transport error and the mutant survived. The shape that discriminates is a JSON error object, which is what this Gitea actually returns. One reviewer claim was **refuted by execution**: Codex reported that deleting the per-iteration `empty=no` reset would leave all four new tests green. Deleting it turns three red — a stale `yes` also suppresses the tally on every later non-empty page. The cross-family arm was unavailable for the final round (Codex hit its usage quota mid-session); the re-review is same-family, from a cold brief in an isolated worktree. ## Follow-up filed **#893** — `page_statuses` still terminates on its first empty page, and whether `/statuses/{sha}` shares the post-pagination filtering that made this a defect is not established. Evidence in the issue, stated as evidence rather than proof.
timothy added 4 commits 2026-08-30 12:39:01 +02:00
`count_pr_mutations` in `review-verdict.yml` treated an empty page past page 1 as proof it had
reached the end of the PR timeline. Gitea does not mean that. `ListIssueCommentsAndTimeline`
applies the LIMIT/OFFSET in `FindComments` at the DATABASE level and filters AFTERWARDS,
dropping every `CommentTypeCode` row and every inaccessible cross-reference into a nil slice
that serializes as bare `null`. A page whose 50 rows are all inline review comments is
therefore byte-identical to a page past the end, while later pages still hold events — and
rows are ordered ASCENDING, so the events a fence looks for are the furthest from page 1.

Fifty inline comments, which a PR author can create on their own PR, truncated both walks at
the same place: both counts agreed, the sha comparison agreed, and an ABA force-push
(`H1 -> H2 -> H1`) yielded an exemption `success` over a diff no single head justified.

The walk no longer infers the end at all. Past page 1 an empty page is SKIPPED; the loop reads
every page up to its pre-existing 20-page cap and the trust decision moves to after the loop —
`rt_ok=yes` only when the LAST page read came back empty. That is the contract the sibling
status walk already stated for itself ("the 20th request has to be the EMPTY terminator"), so
the two now agree instead of one silently claiming coverage it did not perform. An empty FIRST
page and any unreadable shape still end the walk untrusted.

"Require K consecutive empty pages" was rejected: K is arbitrary and only moves the bar to 50K
filtered rows. The cap is not arbitrary — it is the coverage this walk already claimed.

Three measurements at Gitea 1.27.1 rule out the cheaper fixes, and are recorded because each is
the kind of thing a later reader would otherwise re-propose:

- `X-Total-Count` on this endpoint is the POST-FILTER LENGTH OF THE PAGE, not a total:
  `?limit=1` returns `X-Total-Count: 1` on a PR whose timeline holds 14 rows, and a page past
  the end returns 0. `/activities/feeds` DOES return a true total (5739), so this is a property
  of the handler, not the server.
- `limit` clamps to 50 here (`?limit=200` returned 50 on #303/#237/#91/#202).
- The endpoint's only query parameters are `since`, `before`, `page`, `limit` — no row-type
  filter, so the paged set and the serialized set cannot be made to agree. A `since`-bounded
  walk was considered and not taken on its own: it narrows the attack to the run's own window
  but leaves the identical ambiguity inside it, and costs the "a real page was actually read"
  property that an anomalous empty page 1 provides.

Residual, stated rather than left in the issue: the walk confirms offsets 0..949 and requires
its last page to be empty, so hiding an event now needs a timeline over 1000 rows AND a
fully-filtered block at offsets 950..999 — ~1000 rows instead of 50, and the same over-long
-history residual the status walk already accepts. An over-cap timeline fails CLOSED.

Cost: a fixed 20 requests per walk instead of ~2, measured at 2.6s against the live instance.

Tests: a new stub knob `STUB_TIMELINE_FILTERED_PAGES` serves a filtered intermediate page — the
shape the double could not previously produce. Three new tests reproduce the defeat (head axis
over both empty shapes, and its base-axis twin) and go RED against the shipped predecessor,
granting `state: success` on the hidden ABA. A positive control keeps the walk from simply
refusing every filtered page, and `test_a_timeline_that_FILLS_the_page_cap_is_untrusted` guards
the new post-loop clause — making that trust unconditional reddens it. Full suite: 1351 passed,
2 skipped.

Fixes #870
Refs: #803, #706, #664, #751
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two independent cold reviews (Codex GPT-5.6 cross-family; a fresh Opus agent in an isolated
worktree, which executed the walk against synthetic page shapes). Both landed on the same
substantive defect, and it was in the prose, not the code.

THE OVERCLAIM. The fix moves the unprovable inference from page 2 to the page cap; it does not
remove it. An empty page 20 is still trusted, for the same reason an empty page 2 used to be —
this endpoint cannot distinguish a filtered page from the end of the list at ANY offset. Six
sentences across three docs and the workflow said otherwise, including "no longer accepted as
exhaustion at all", "STOP INFERRING THE END AT ALL", and — in the paragraph that describes the
case where it fails OPEN — "an over-cap timeline fails CLOSED ... it does not pass silently".
All rewritten to NARROWED, not closed, with the residual stated where a reader meets the claim.

THE PRICE, IN ONE UNIT. "50 rows instead of 1000" compared an authored-comment count against a
total-row count and flattered the change. Before: ~100 timeline rows (a non-empty page 1, then a
50-row filtered block at a 50-aligned offset). After: over 1000, with the block pinned to offsets
950..999. About 10x, and the attacker authors 50 inline comments either way.

THE PARITY CLAIM WAS FALSE. "The two walks now agree" — they do not. `page_statuses` still returns
on its FIRST empty page; what the two share is only the cap rule. Worse, the diff asserted parity
it had not established: whether `/statuses/{sha}` is subject to the same post-pagination filtering
was never checked. Measured instead of asserted (Gitea 1.27.1, 2026-08-30): that endpoint returns
a TRUE `X-Total-Count` (105 at both `?limit=1` and `?limit=50`) where `/issues/{n}/timeline`
returns the post-filter page length. Consistent with counting before filtering — evidence, not
proof, since no filtering predicate has been exhibited either way. Filed as #893 rather than
resolved here.

CODE CHANGES, both answering the amplification this fix introduced (~2 requests per walk to a
fixed 20, three walks per exempt run):

- Each page is now bounded `--connect-timeout 5 --max-time 15`. `gh` is bare `curl -sf` with no
  timeout, and the THIRD call site runs after the exemption `success` is posted, so a hang leaves
  the green standing with no repair attempted. Bounded at this call rather than inside `gh`, so no
  other call site changes behaviour.
- Each page is retried once, mirroring `page_statuses` and its stated reason — "one transient blip
  would cost that head its exemption until a human clears it by hand". That walk retries; this one
  did not, and it just took 10x the round trips.
- The four jq reads carry `2>/dev/null || x=""`, as `page_statuses`'s do. Unreachable today thanks
  to the ordering above them, which is exactly the "safe because of a property three lines up"
  reasoning the adjacent comment says it is retiring.

STALE CONTRACT TEXT swept: the function's own header still said "trusted ONLY when paging reached a
validated EMPTY page" and listed a short page as untrusted (false before and after — a short page
is tallied and the walk continues); the record's `mechanics:` field still described the old
terminator and omitted the new fixtures.

TESTS (+3, and one reviewer claim refuted):

- `test_a_FILTERED_page_before_a_FULL_cap_page_is_still_untrusted` — filtered page 19, full page 20.
  This kills the "trust if ANY page was empty" mutant, which the Opus review proved survived the
  previous round's suite; every earlier fixture ends on an empty page, so a latching flag and a
  correct one agree there.
- `test_a_SINGLE_transient_blip_does_not_cost_the_exemption`, with a `flaky-first` stub mode. The
  existing `transport-error`/`unreadable` modes fail EVERY attempt, so a retrying walk and a
  non-retrying one refuse identically and neither could measure this.
- `test_the_timeline_read_is_TIME_BOUNDED` — structural; a hang cannot be provoked through a stub
  that answers instantly.

Each was mutated and witnessed red. Codex claimed the per-iteration `empty=no` reset was unguarded
and that deleting it would leave all four new tests green; deleting it turns THREE of them red — a
stale `yes` also suppresses the tally on every later non-empty page. The Opus review mutated the
same line independently and got the same red. Recorded because a wrong mechanism claim in a review
is as executable as a right one.

Full suite: 1354 passed, 2 skipped.

Fixes #870
Refs: #803, #706, #664, #751, #893
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Executing the shipped walk against synthetic page sequences showed the cost sentence was
telling half the truth: "20 requests per walk, measured at 2.6s" is the healthy path, but
the per-page retry added in the previous commit makes the worst case 40 requests and 19
one-second sleeps. A reader sizing the change against the job's budget would have sized it
against the better half.

Measured by execution (the walk extracted from the YAML and run against scripted responses):
a healthy walk is exactly 20 requests; an empty page 1 short-circuits at 1; a persistent
failure stops at 2; a single blip costs 21. The retry's kind/raw correspondence holds on
every path — a non-array first attempt followed by a failed second leaves the walk untrusted
rather than parsing a stale body.

Refs: #870
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(870): round-2 review — cost arithmetic was wrong twice, and one fixture proved nothing
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
Review verdict / Set review-verdict status (pull_request_target) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 11m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
review-verdict/h10 Review-verdict: MERGEABLE @ eb9cc6c (base: main)
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 8m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 15s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 18m47s
eb9cc6cb31
Cold re-review of the fix commit (Opus, isolated worktree; the cross-family arm was unavailable,
Codex having hit its usage quota mid-session). No Blocker, no High on the code — it executed the
walk and every mutant of it. Every finding was in prose, cost accounting, or a test that did not
discriminate. All are addressed here.

COST ARITHMETIC, WRONG TWICE, BOTH NOW MEASURED RATHER THAN REASONED.

- Worst-case sleeps: the comment said 19, reasoning that the last page could not sleep. It can.
  Executing the walk against scripted responses gives 40 requests and 20 sleeps.
- Wall clock: the comment said ~335s, on the premise that only the last page can pay two timeouts.
  That bounds TIMEOUTS, not elapsed time — attempt 1 can burn its full 15s and attempt 2 still
  succeed slowly, so the page costs ~31s and the walk continues. The pessimum is 20x(15+1+15) =
  620s per walk, ~21min across the exempt path's three, and the job has no `timeout-minutes`.
  Stated rather than tuned, because the direction is the justification: the predecessor had no
  timeout at all. And it is bounded HERE only — `page_statuses` is still unbounded and runs in the
  same post-POST window, so the "a hang leaves the green standing" mode remains reachable there.

TWO COSTS THE CHANGE DID NOT STATE, now measured on this machine:

- The suite roughly doubled. `test_pr_changed_files.py`: 202s -> 474s (286 -> 294 tests,
  `-p no:randomly`, 528383cf3 vs this branch), because every fence fixture now drives 20 stub
  requests per walk instead of 2. `testing.mutation-claims-are-executed` carried a `~4min
  script-tests` figure measured before this; it is dated there rather than left to rot.
- The exemption `success` is live from its POST until the post-write repair, and the walk in
  between went from ~2 requests to 20. The comment documenting that window was written when it was
  two round trips.

A FIXTURE THAT PROVED NOTHING, caught by running the mutant it claimed to kill. The retry's
coverage of a non-array BODY was pinned by a fixture serving a 502 HTML page — but jq fails on
that, `|| kind=""` fires, and it takes the same path as a transport error. Narrowing the retry to
`if [ -n "$kind" ]` passed it. The shape that discriminates is a well-formed JSON OBJECT, which is
what this Gitea actually returns on an error (measured: `?since=NOTATIME` returns an object). Under
the narrowing an error object ends the retry and is then refused by the `case`, costing the
exemption. Fixture corrected, mutant now red on `[body]` and green on `[transport]` — which is the
point of parameterising over both.

PROSE, the same class the previous round removed six times:

- The workflow stated the attacker's cost two ways 45 lines apart ("50 rows before, ~1000 now"
  against "50 comments either way"). Neither was right: the 50-row filtered block is unchanged, but
  the timeline it must sit in grows from ~100 rows to over 1000, and on their own PR the padding is
  the attacker's to supply. Stated once now, in one unit, in all five places.
- The record's residual intro said the second residual "was closed by #870" directly above a
  heading reading "NARROWED ... not closed".
- "No terminator on this endpoint can do better" dropped both of the record's hedges. It is not a
  proof that none could exist: `?limit=49&page=21` reaches offsets 980..1028 with page edges that
  do not coincide with a 50-row walk's. Strictly better, still not a terminator.
- Retired vocabulary swept: two sites still described trust as "reached a validated empty page",
  and one listed "a non-array body" as untrusted three lines from the paragraph explaining that a
  bare `null` IS the accepted empty page.
- The retry's break set and the `case` arms are two enumerations of {array, null} whose agreement
  is what makes the stale-`kind` path harmless. Nothing said they must stay in sync; now it does.

DELIBERATELY NOT DONE: no `test_MUTATION_*`-named fixture for the new clauses. The convention is for
clauses disarmed in place via `_run_classify(mutate=...)`; these are page-LAYOUT properties, covered
by behavioural fixtures that were each mutation-witnessed red (7 of 7, independently re-measured by
the reviewer). Naming them for the convention would not add coverage.

Full suite green.

Fixes #870
Refs: #803, #706, #664, #751, #893
Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

Review-verdict: MERGEABLE @ eb9cc6c

Two independent cold reviews plus a re-review of the fix commit; no Blocker or High in the code. Findings were prose overclaims (closed vs narrowed), cost arithmetic wrong twice, and one fixture that did not discriminate - all fixed and re-measured. 7 new tests, each mutation-witnessed red. Full suite 1355 passed, 2 skipped, rebased on 528383cf3. Residual stated, follow-up filed as 893.

Review-verdict: MERGEABLE @ eb9cc6c Two independent cold reviews plus a re-review of the fix commit; no Blocker or High in the code. Findings were prose overclaims (closed vs narrowed), cost arithmetic wrong twice, and one fixture that did not discriminate - all fixed and re-measured. 7 new tests, each mutation-witnessed red. Full suite 1355 passed, 2 skipped, rebased on 528383cf3. Residual stated, follow-up filed as 893.
timothy merged commit 0e40ac283b into main 2026-08-30 13:29:39 +02:00
timothy deleted branch fix/870-timeline-walk-filtered-page 2026-08-30 13:29:40 +02:00
Sign in to join this conversation.