Skip to content

api(ledger): the public anchor listing's cursor drops rows that share a created_at #9715

Description

@JSONbored

⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.

Context

GET /v1/public/decision-ledger/anchors (src/api/routes.ts:1341-1355) is the public listing of every anchoring attempt. Its route comment states the point: "This is what makes anchoring's own health a publicly checkable fact — a failure is recorded and served exactly like a success ... so 'anchoring has been failing for a week' is something anyone can observe."

loadPublicLedgerAnchors (src/review/ledger-anchor-persistence.ts:98-141) implements keyset pagination whose cursor does not match its sort key: the query filters WHERE created_at < ? (cursor: created_at only) while ordering BY created_at DESC, id DESC, and nextBefore = page[page.length - 1]?.createdAt (:127).

Because the ORDER BY tiebreaks on id but the WHERE clause filters on created_at alone, any group of rows sharing one created_at that straddles a page boundary is silently skipped: the next page starts strictly before that timestamp, so the remaining tied rows are never returned on any page.

Ties are not hypothetical here. recordLedgerAnchorAttempt (:47) defaults createdAt to nowIso() (src/utils/json.ts:14-16, millisecond precision), and one scheduler tick records an attempt per backend — LedgerAnchorBackend is "rekor" | "git" | "ots" | "bittensor" (:10), four rows written back to back. The function's own doc comment (:44-46) already concedes the collision risk: createdAt is injectable so a test can "control ordering deterministically instead of racing real wall-clock resolution across several inserts in a tight loop".

So an attempt — including a failure, the row this endpoint exists to make visible — can be permanently unreachable through the paginated public API while sitting in the table.

Requirements

  • Make the cursor match the sort key: page on (created_at, id), not created_at alone.
  • nextBefore must carry both components so the next page resumes exactly after the last returned row. Encode them in one opaque string cursor (the field stays a single string | null so the route and the published response shape are unchanged for a caller that only ever round-trips the value).
  • The WHERE clause becomes the standard row-comparison form: (created_at < :ts) OR (created_at = :ts AND id < :id), preserving ORDER BY created_at DESC, id DESC.
  • A before value that does not parse as the new cursor must be treated as "no cursor" (first page) rather than throwing — this is an unauthenticated public route.
  • LedgerAnchorListFilter.before's doc comment (:105) must be updated: it currently says "return rows strictly older than this ISO timestamp", which stops being true.
  • No change to recordLedgerAnchorAttempt, to the row shape, or to the backend/limit filters.

⚠️ Required pattern: keep the single env.DB.prepare(...) call and the existing fetch-one-extra-row limit + 1 technique at src/review/ledger-anchor-persistence.ts:120-128 — the change is the predicate and the cursor payload, not the pagination strategy. It does NOT satisfy this issue to switch to OFFSET-based paging; to widen the created_at comparison to <= (which re-serves rows and can loop forever); to add a second query to re-fetch ties; or to make nextBefore a structured object, which changes the published response shape.

Deliverables

  • loadPublicLedgerAnchors filters on the composite (created_at, id) row comparison and returns a nextBefore cursor carrying both values.
  • A malformed or legacy before value yields the first page (no throw, no 500).
  • A new test inserts five attempts that share one created_at (using the injectable createdAt parameter), pages through them with limit: 2, and asserts the concatenation of all pages contains all five ids exactly once. This test fails on current main.
  • A regression test asserts that paging with distinct created_at values still returns rows newest-first with no duplicates and terminates with nextBefore === null.
  • A regression test asserts the backend filter and the limit clamp (:99) behave exactly as before, including limit above MAX_ANCHOR_LIST_LIMIT and below 1.
  • A route-level test that GET /v1/public/decision-ledger/anchors?before=<garbage> answers 200 with the first page.

All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example changing the SQL without the five-tied-rows pagination test that proves no row is lost — does not resolve this issue.

Test Coverage Requirements

src/review/** and src/api/** are both inside Codecov's src/** include; the 99% branch-counted patch gate applies. Every arm of the new cursor handling must be covered: absent before, a well-formed cursor, a malformed cursor, a page that ends exactly on a tie boundary, and a final page (results.length <= limit -> nextBefore === null). The five-tied-rows test is the named regression test for this fix.

Expected Outcome

Every recorded anchoring attempt — success or failure — is reachable by paging the public listing, including the four same-tick rows one scheduler run writes for the four backends. "Anchoring has been failing" stays publicly observable rather than being hidden by a page boundary.

Links & Resources

src/review/ledger-anchor-persistence.ts:44-141; src/api/routes.ts:1336-1355; src/utils/json.ts:14-16. Related closed work: #9271, epic #9267.

Metadata

Metadata

Assignees

No one assigned

    Labels

    gittensor:bugGittensor-scored bug fix — scores a 0.05x multiplier.help wantedExtra attention is needed

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions