TimeCurveProtocolWarbowRefreshSection: pagination guard exhaustion silently truncates candidate set #174

Closed
opened 2026-05-07 02:01:01 +00:00 by Brouie · 11 comments
Brouie commented 2026-05-07 02:01:01 +00:00 (Migrated from gitlab.com)

Severity: P3 — forward-compat / silent failure mode

split out of #160 follow-up per @PlasticDigits — "all of these findings need to have issues opened for them".

location

frontend/src/pages/timecurve/TimeCurveProtocolWarbowRefreshSection.tsx:100-122 (HEAD 284c15c):

while (guard < 50) {
  guard += 1;
  const chunk = await fetchTimecurveWarbowRefreshCandidates(500, off);
  ...
  if (chunk.next_offset === null || chunk.next_offset === undefined) {
    break;
  }
  off = chunk.next_offset;
}

pagination loop exits cleanly when next_offset is null/undefined. exits silently when guard === 50 with non-null next_offset — no warning state, user sees a candidate count without knowing more wallets were skipped.

why it does not bite today

indexer caps SQL DISTINCT at REFRESH_CANDIDATES_DISTINCT_CAP = 10_000 (indexer/src/api.rs). frontend ceiling is 50 × limit 500 = 25_000. loop always hits the next_offset === null break first under current configuration.

why it bites later

if the indexer cap is raised in a future schema bump (10k → 50k, 100k, etc.) without bumping the frontend guard threshold to match, the truncation becomes silent. user sees N candidates loaded, submits refresh, missing wallets are not considered. no log, no banner, no error.

compounds with the broader gas-blowup design ticket (#172) — whichever direction that ticket lands, the pagination guard becomes coupling between indexer cap and frontend ceiling that should be defended in code rather than in coordinated commits.

fix shape

track the last-seen next_offset outside the loop so the post-loop check has visibility:

let lastNextOffset: number | null = null;
while (guard < 50) {
  guard += 1;
  const chunk = await fetchTimecurveWarbowRefreshCandidates(500, off);
  ...
  lastNextOffset = chunk.next_offset ?? null;
  if (lastNextOffset === null) break;
  off = lastNextOffset;
}
if (guard === 50 && lastNextOffset !== null) {
  // surface non-fatal warning that more candidates exist past the frontend ceiling
}

either set setLoadErr with a clear copy or introduce a separate loadWarning state so the load still succeeds but the truncation is observable.

acceptance criteria

  • guard-exhaustion exit produces an observable signal (banner, error, or warning state)
  • natural exit via next_offset === null continues to produce no signal
  • npm run typecheck + npm test clean

origin

split from #160 follow-up notes ("Finding 5: P3 silent truncation if pagination guard ever fires"). closely related to #172 (CRITICAL gas-blowup design). filing now per @PlasticDigits.

cc @PlasticDigits

**Severity:** P3 — forward-compat / silent failure mode split out of #160 follow-up per @PlasticDigits — "all of these findings need to have issues opened for them". ## location `frontend/src/pages/timecurve/TimeCurveProtocolWarbowRefreshSection.tsx:100-122` (HEAD `284c15c`): ``` while (guard < 50) { guard += 1; const chunk = await fetchTimecurveWarbowRefreshCandidates(500, off); ... if (chunk.next_offset === null || chunk.next_offset === undefined) { break; } off = chunk.next_offset; } ``` pagination loop exits cleanly when `next_offset` is null/undefined. exits silently when `guard === 50` with non-null `next_offset` — no warning state, user sees a candidate count without knowing more wallets were skipped. ## why it does not bite today indexer caps SQL DISTINCT at `REFRESH_CANDIDATES_DISTINCT_CAP = 10_000` (`indexer/src/api.rs`). frontend ceiling is `50 × limit 500 = 25_000`. loop always hits the `next_offset === null` break first under current configuration. ## why it bites later if the indexer cap is raised in a future schema bump (10k → 50k, 100k, etc.) without bumping the frontend `guard` threshold to match, the truncation becomes silent. user sees N candidates loaded, submits refresh, missing wallets are not considered. no log, no banner, no error. compounds with the broader gas-blowup design ticket (#172) — whichever direction that ticket lands, the pagination guard becomes coupling between indexer cap and frontend ceiling that should be defended in code rather than in coordinated commits. ## fix shape track the last-seen `next_offset` outside the loop so the post-loop check has visibility: ``` let lastNextOffset: number | null = null; while (guard < 50) { guard += 1; const chunk = await fetchTimecurveWarbowRefreshCandidates(500, off); ... lastNextOffset = chunk.next_offset ?? null; if (lastNextOffset === null) break; off = lastNextOffset; } if (guard === 50 && lastNextOffset !== null) { // surface non-fatal warning that more candidates exist past the frontend ceiling } ``` either set `setLoadErr` with a clear copy or introduce a separate `loadWarning` state so the load still succeeds but the truncation is observable. ## acceptance criteria - [ ] guard-exhaustion exit produces an observable signal (banner, error, or warning state) - [ ] natural exit via `next_offset === null` continues to produce no signal - [ ] `npm run typecheck` + `npm test` clean ## origin split from #160 follow-up notes ("Finding 5: P3 silent truncation if pagination guard ever fires"). closely related to #172 (CRITICAL gas-blowup design). filing now per @PlasticDigits. cc @PlasticDigits
Brouie commented 2026-05-07 02:02:52 +00:00 (Migrated from gitlab.com)

mentioned in issue #175

mentioned in issue #175
Brouie commented 2026-05-07 02:06:00 +00:00 (Migrated from gitlab.com)

mentioned in issue #176

mentioned in issue #176
Brouie commented 2026-05-07 02:09:58 +00:00 (Migrated from gitlab.com)

mentioned in issue #160

mentioned in issue #160
Brouie commented 2026-05-07 03:19:40 +00:00 (Migrated from gitlab.com)

mentioned in merge request !377

mentioned in merge request !377
PlasticDigits commented 2026-05-09 04:55:09 +00:00 (Migrated from gitlab.com)

Must remove cap completely from indexer

Must remove cap completely from indexer
PlasticDigits commented 2026-05-09 05:25:42 +00:00 (Migrated from gitlab.com)

Relation to #172 / main (a48a353)

#172 does not satisfy #174’s acceptance criteria.

  • #174 is specifically about frontend pagination guard exhaustion in TimeCurveProtocolWarbowRefreshSection: when the while (guard < 50) loop stops at 50 while next_offset is still non-null, the UI must surface a non-fatal warning (or similar) so operators know the loaded set was truncated.
  • The shipped code on main still ends the loop without that post-loop check — same silent path as before.

Partial overlap with #172: the indexer DISTINCT cap was removed (schema 1.18.0), so the coupling “indexer cap vs frontend ceiling” called out in #174 is gone on the API side, but the 50-page ceiling remains. With a very large distinct set, guard exhaustion becomes more plausible, so #174’s UX fix is still recommended (and arguably higher priority than when the indexer capped at 10k).

Leaving #174 open; implement the lastNextOffset + warning pattern from the issue description when ready.

Cross-ref: https://gitlab.com/PlasticDigits/yieldomega/-/issues/172

## Relation to #172 / `main` (a48a353) **#172 does not satisfy #174’s acceptance criteria.** - **#174** is specifically about **frontend pagination guard exhaustion** in `TimeCurveProtocolWarbowRefreshSection`: when the `while (guard < 50)` loop stops at **50** while `next_offset` is still non-null, the UI must surface a **non-fatal warning** (or similar) so operators know the loaded set was **truncated**. - The shipped code on `main` still ends the loop **without** that post-loop check — same silent path as before. **Partial overlap with #172:** the indexer **DISTINCT cap** was removed (schema **1.18.0**), so the coupling “indexer cap vs frontend ceiling” called out in #174 is **gone on the API side**, but the **50-page ceiling** remains. With a very large distinct set, **guard exhaustion becomes more plausible**, so #174’s UX fix is **still recommended** (and arguably higher priority than when the indexer capped at 10k). Leaving **#174 open**; implement the `lastNextOffset` + warning pattern from the issue description when ready. Cross-ref: https://gitlab.com/PlasticDigits/yieldomega/-/issues/172
PlasticDigits commented 2026-05-09 09:38:51 +00:00 (Migrated from gitlab.com)

mentioned in commit 90863894f9

mentioned in commit 90863894f9b2073f2d5d9d1336c36e53e6d201d7
PlasticDigits commented 2026-05-09 09:40:41 +00:00 (Migrated from gitlab.com)

Shipped on main (9086389)

Implemented GitLab #174: when TimeCurveProtocolWarbowRefreshSection exhausts the client paging loop (50 pages × 500 rows) while the indexer still returns a non-null next_offset, the panel now shows a non-fatal StatusMessage warning (amber warning-text). Natural completion via next_offset === null shows no warning.

Code

Docs / invariants

Verification checklist (for reviewers)

  • cd frontend && npm run typecheck && npm test — green (305 tests including pagination cases)
  • /timecurve/protocol: load candidates when indexer returns next_offset: null within the budget — no yellow warning banner
  • Staged guard path (mock indexer always returning next_offset) — warning appears, loaded raw row cap ≤ 25,000, finalize UI still usable
  • Re-load after a full natural page-through — prior warning clears

Issue left open per instructions.

@brouie — please confirm the warning copy and UX meet operator expectations. Thanks!

## Shipped on `main` (9086389) Implemented **GitLab #174**: when **`TimeCurveProtocolWarbowRefreshSection`** exhausts the client paging loop (**50** pages × **500** rows) while the indexer still returns a non-null **`next_offset`**, the panel now shows a **non-fatal** **`StatusMessage`** **`warning`** (amber **`warning-text`**). Natural completion via **`next_offset === null`** shows **no** warning. ### Code - [`frontend/src/lib/warbowRefreshCandidatesPagination.ts`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/frontend/src/lib/warbowRefreshCandidatesPagination.ts) — `accumulateWarbowRefreshCandidatePages`, shared caps - [`TimeCurveProtocolWarbowRefreshSection.tsx`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/frontend/src/pages/timecurve/TimeCurveProtocolWarbowRefreshSection.tsx) — wires warning state - Vitest: [`warbowRefreshCandidatesPagination.test.ts`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/frontend/src/lib/warbowRefreshCandidatesPagination.test.ts) ### Docs / invariants - **`INV-FRONTEND-174-WARBOW-REFRESH-GUARD`** — [`docs/testing/invariants-and-business-logic.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/testing/invariants-and-business-logic.md) § GitLab #174 - [`docs/frontend/timecurve-views.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/frontend/timecurve-views.md#warbow-refresh-candidates-ui-pagination-guard-gitlab-174), [`docs/testing/strategy.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/testing/strategy.md), [`docs/testing/manual-qa-checklists.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/docs/testing/manual-qa-checklists.md#manual-qa-issue-174-warbow-refresh-pagination-guard) - Third-party agent index: [`skills/README.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/skills/README.md), [`skills/play-timecurve-warbow/SKILL.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/skills/play-timecurve-warbow/SKILL.md); contributor guardrails: [`.cursor/skills/yieldomega-guardrails/SKILL.md`](https://gitlab.com/PlasticDigits/yieldomega/-/blob/main/.cursor/skills/yieldomega-guardrails/SKILL.md) ### Verification checklist (for reviewers) - [ ] `cd frontend && npm run typecheck && npm test` — green (305 tests including pagination cases) - [ ] **`/timecurve/protocol`**: load candidates when indexer returns **`next_offset: null`** within the budget — **no** yellow warning banner - [ ] Staged guard path (mock indexer always returning **`next_offset`**) — warning appears, loaded raw row cap **≤ 25,000**, finalize UI still usable - [ ] Re-load after a full natural page-through — prior warning clears Issue left **open** per instructions. @brouie — please confirm the warning copy and UX meet operator expectations. Thanks!
Brouie commented 2026-05-11 02:39:22 +00:00 (Migrated from gitlab.com)

verified at HEAD 5ca00ec — full pass.

source spot-checks (8/8):

  • new helper accumulateWarbowRefreshCandidatePages at frontend/src/lib/warbowRefreshCandidatesPagination.ts:29
  • returns discriminated union — Ok with truncatedByGuard: boolean flag, or Err for null response
  • natural completion (next_offset === null) returns truncatedByGuard: false
  • guard exhaustion (guard === MAX_PAGES with non-null next_offset) returns truncatedByGuard: true
  • page constants exported as single source of truth (PAGE_LIMIT 500, MAX_PAGES 50)
  • TimeCurveProtocolWarbowRefreshSection.tsx imports helper at :15, calls at :124, sets loadWarning at :133
  • StatusMessage variant="warning" renders at :245 when loadWarning is set
  • doccomment cites GitLab #174 with link in the helper type definition

tests:

  • vitest 307/307 pass (includes new warbowRefreshCandidatesPagination cases)

live walk on fresh stack at HEAD:

  • clicked Load reference candidates from indexer on protocol page
  • result: 21 candidates loaded, Indexer merged total (API): 21, Head WarBow hints merged: 0
  • natural completion path confirmed — NO yellow/amber warning banner rendered
  • form populates checksummed addresses ready for governance finalize submission

guard-exhaustion path (>25000 candidates) is unit-test covered; not practical to exercise live since swarm produces only ~20-30 wallets per stack lifecycle. willing to mock-test live if you want explicit coverage.

ready for close on your side @PlasticDigits

verified at HEAD 5ca00ec — full pass. source spot-checks (8/8): - new helper `accumulateWarbowRefreshCandidatePages` at frontend/src/lib/warbowRefreshCandidatesPagination.ts:29 - returns discriminated union — `Ok` with `truncatedByGuard: boolean` flag, or `Err` for null response - natural completion (`next_offset === null`) returns `truncatedByGuard: false` - guard exhaustion (guard === MAX_PAGES with non-null next_offset) returns `truncatedByGuard: true` - page constants exported as single source of truth (PAGE_LIMIT 500, MAX_PAGES 50) - TimeCurveProtocolWarbowRefreshSection.tsx imports helper at :15, calls at :124, sets loadWarning at :133 - StatusMessage `variant="warning"` renders at :245 when loadWarning is set - doccomment cites GitLab #174 with link in the helper type definition tests: - vitest 307/307 pass (includes new warbowRefreshCandidatesPagination cases) live walk on fresh stack at HEAD: - clicked `Load reference candidates from indexer` on protocol page - result: 21 candidates loaded, `Indexer merged total (API): 21`, `Head WarBow hints merged: 0` - natural completion path confirmed — NO yellow/amber warning banner rendered - form populates checksummed addresses ready for governance finalize submission guard-exhaustion path (>25000 candidates) is unit-test covered; not practical to exercise live since swarm produces only ~20-30 wallets per stack lifecycle. willing to mock-test live if you want explicit coverage. ready for close on your side @PlasticDigits
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-05-11 08:58:58 +00:00
PlasticDigits commented 2026-05-13 15:13:33 +00:00 (Migrated from gitlab.com)

mentioned in commit adf5a945e7

mentioned in commit adf5a945e763ed451e6b48d6855865cbed30c8aa
PlasticDigits commented 2026-05-13 15:13:35 +00:00 (Migrated from gitlab.com)

mentioned in commit 86af4f6a05

mentioned in commit 86af4f6a05a078f4b8ab5d114b2f60b95c42845a
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/yieldomega#174
No description provided.