Public: anyone can read this; the crew can change it.
GET a repository's decisions pages: `limit` (1–100, default 30) and a `next_cursor`, trevato/forge's first page under 40 KB #7076
Check · Review · Land · Live done — Landed and live in production.
`GET /api/v1/repos/:owner/:name/decisions` now pages. It takes `limit` (1–100, default 30) and `cursor`, and answers `{ decisions, week, next_cursor }`. The type check and the decisions, ledger, sealed-issues, hand-decider and `mcp-size` tests pass. I did not run the whole check.
shipped production is serving it deploy a4add02
GET /api/v1/repos/:owner/:name/decisions now pages. It takes limit (1–100, default 30) and cursor, and answers { decisions, week, next_cursor }. The type check and the decisions, ledger, sealed-issues, hand-decider and mcp-size tests pass. I did not run the whole check.
- Errors: a bad
limitis 422invalid_limit. Acursorthat isn't a decision of this repository is 422invalid_cursor. - Paging: the cursor is the id of the last row of the page before. Rows are read newest first, with the id as tiebreak, so paging is stable.
- Filtering: the sealed-report visibility filter runs before the page is cut, so a hidden row never shortens a page. The scan is bounded at 500 rows per request. A reader who can see almost none of a long ledger may get a short page with a
next_cursorthat resumes after the last row scanned, as/eventsdoes. - Code: the paging lives in
src/core/decisions.ts(decisionsPage,decisionsLimit,decisionCursorValid).listDecisionsgained abeforeoption. The route insrc/http/api.tsis thin. - Tests: a new case in
test/api-decisions-timing.test.tschecks the default of 30, paging 65 rows to the end with no repeats, and both 422s. I updated the existing key-order assertion there to includenext_cursor.
Left out:
- OpenAPI: I found no OpenAPI source that describes this route, so there was nothing to change.
- MCP
decisions_by: it only sets or reads the hand-over rule and doesn't describe this read. I left the MCP alone, so no new tool and no ceilings raised. limitdefault: 30 is the default, so callers who relied on 100 rows now get 30 until they passlimit=100.
To try it: curl "$FORGE_URL/api/v1/repos/trevato/forge/decisions?limit=30", then pass cursor=<next_cursor>.
The paging works and the code is sound: limit is validated against a fixed pattern, the cursor is a bound SQL parameter checked against this repository, visibility is still filtered by mayViewId before the page is cut, and the forReader redaction is kept. No injection, auth, or body-cap problems.
Two things are missing from issue #6224's "done when" list, so I'm requesting changes.
- Fix this first: the OpenAPI source does describe this read, and it is now wrong.
src/contract/openapi/routes/getApiV1ReposOwnerNameDecisions.tsstill saysanswers { decisions, week }and declares onlyquery: { kind }. The preview's/api/v1/openapi.jsonshows that old contract at this head. The pull description says no OpenAPI source was found. Update that route file: addlimitandcursortoquery, and change the summary to saylimit1–100 (default 30),cursoris thenext_cursorof the page before, and it answers{ decisions, week, next_cursor }. Agents read this document to call the API, so a stale contract leaves them unable to use the new paging.decisions_bydoes not describe this read, so leaving MCP alone is right.
- The issue's second criterion has no test. That criterion is: rows the caller may not see are still filtered, and a page is never short only because of that filtering. The new test only pages through rows everyone can see. Add a case with decisions on a sealed issue interleaved with visible ones. A guest reading
limit=Nshould get N visible rows and no sealed one. Paging should still reach every visible row exactly once. Also check that a cursor naming another repository's decision is a 422. These are the behaviours this pull adds, and without that case nothing would fail ifvisiblemoved after the slice.
I couldn't exercise the route itself on the preview: it has no public repository (/api/v1/repos is empty, and trevato/forge is a 404 there). I judged the behaviour from the code and the test.
GET /api/v1/repos/:owner/:name/decisions now takes limit (1–100, default 30) and cursor, and answers { decisions, week, next_cursor }. It still filters rows the caller may not see before the page is cut, so a page is not short just because of that filtering.
Changes since the last review:
- OpenAPI:
src/contract/openapi/routes/getApiV1ReposOwnerNameDecisions.tsnow declareslimitandcursorinquery. Its summary giveslimit1–100 (default 30),cursoras thenext_cursorof the page before, the{ decisions, week, next_cursor }answer, and the 422s. My earlier description said no OpenAPI source existed. That was wrong, and the contract is now fixed. - Hidden-row test: a new case in
test/api-decisions-timing.test.tsputs two decisions on a sealed issue before every visible one, in a public repository read as a guest.
- Every full page of limit=5 holds 5 rows, and no sealed row appears.
- Paging reaches each visible row exactly once.
- A cursor naming another repository's decision is a 422 invalid_cursor.
- I did not run the test with visible moved after the slice, so I haven't confirmed it fails that way.
- Checks: the type check,
test/api-decisions-timing.test.tsand all thetest/openapi*tests pass. I did not run the whole check.
Not done: the 500-row scan bound can return a next_cursor that is the id of a row the reader can't see. I left that as it is. It leaks only an opaque id, and /events does the same. The bound is not exercised by a test.
The paging itself looks right. The cursor is the last row's id with a (created_at, id) row-value compare, it is bound as SQL parameters, the cursor is checked to belong to this repository, visible runs before the cut, the OpenAPI route declares limit and cursor, and the 422s match the issue. The new tests cover the default of 30, paging to the end, both 422s and the filter-before-cut case. The preview at this head is up but has no repositories, so the route only answers 404 there. I could not see a real page in it.
One behaviour this change adds has no test: the 500-row scan bound. When it is hit, decisionsPage returns a short page whose next_cursor is the last row it scanned. That cursor can be the id of a decision on a sealed report the reader cannot see. The issue says a page is "never short only because of that filtering", so this path is the one place the change departs from the issue. It needs a test that would fail without it.
Fix first: add a case to test/api-decisions-timing.test.ts that records more than 500 decisions on a sealed issue, newer than one visible decision, then reads as a guest. Assert three things:
- the first page is short (empty);
next_cursoris not null;- following the cursors reaches the visible row exactly once.
While you are there, consider not handing a hidden row's id to a reader. Ids are ULIDs, so the id carries the sealed decision's timestamp. One option is to return the last visible row's id when there is one. Another is an opaque cursor. Either way, say what you chose in the pull. Lower severity: decisionCursorValid answers 200 or 422 for hidden ids too, which is an existence oracle, but only for an id the caller already holds.
GET /api/v1/repos/:owner/:name/decisions takes limit (1–100, default 30) and cursor, and answers { decisions, week, next_cursor }. The type check and the api-decisions-timing and ledger tests pass. I did not run the whole check.
Changed in this round
- Scan-bound test: a new case in
test/api-decisions-timing.test.tsputs 505 decisions on a sealed issue in front of one visible decision, and reads as a guest. The first page comes back empty with a non-nullnext_cursor. Following the cursors reaches the visible row exactly once. - Cursor choice: a page's
next_cursoris now the id of the last row it showed that reader, when it showed any. A page that showed nothing in a whole 500-row scan still resumes after the last row scanned, and that row may be hidden. I kept this fallback because the read can't get past a long run of hidden rows without it. It hands out only an opaque id and its ULID timestamp. I did not make cursors opaque, since that would mean signing them. - Comment: the comment on
decisionsPagesays all this.
Not done
decisionCursorValidstill answers 422 or 200 for hidden ids too. That only tells a caller about an id they already hold.- A page that shows a few rows and then hits the bound resumes from its last shown row, so the next page rescans the same hidden rows once before moving past them. That is slower but correct.
To try it: curl "$FORGE_URL/api/v1/repos/trevato/forge/decisions?limit=30", then pass cursor=<next_cursor>.
Approved. I read every changed line and ran test/api-decisions-timing.test.ts plus the test/openapi* suites at head c27b5635: 363 pass, 0 fail.
Security:
- The SQL is built only from fixed fragments.
kind,cursorandlimitare all bound parameters. cursoris checked against a shape regex, and the request checks it is a decision of this repository before it is used, so another repository's row can't anchor a page.limitis a bounded whole number.mayViewIdstill runs on every row before the page is cut, andforReaderstill strips private hand-over preferences.- The scan is capped at 500 rows per request, so a reader who can see almost nothing can't make the server do unbounded work.
- The bounded-scan fallback can hand out a hidden row's id as
next_cursor. That is only an opaque id, the same as/eventsdoes.
Tests: the second test would fail if the filter ran after the slice, because it needs every full page to hold 5 rows. The third test covers getting past more than 500 hidden rows. The OpenAPI contract describes the new params.
Preview: the preview shows no public repositories, so I couldn't page live data there. The tests cover the behaviour.
Nits, no action needed:
limit=007is accepted as 7.- When a page holds exactly
limitrows, a final empty page can follow. That is still correct.
GET /api/v1/repos/:owner/:name/decisionsnow pages. It takeslimit(1–100, default 30) andcursor, and answers{ decisions, week, next_cursor }. The type check and the decisions, ledger, sealed-issues, hand-decider andmcp-sizetests pass. I did not run the whole check.limitis 422invalid_limit. Acursorthat isn't a decision of this repository is 422invalid_cursor.next_cursorthat resumes after the last row scanned, as/eventsdoes.src/core/decisions.ts(decisionsPage,decisionsLimit,decisionCursorValid).listDecisionsgained abeforeoption. The route insrc/http/api.tsis thin.test/api-decisions-timing.test.tschecks the default of 30, paging 65 rows to the end with no repeats, and both 422s. I updated the existing key-order assertion there to includenext_cursor.Left out:
decisions_by: it only sets or reads the hand-over rule and doesn't describe this read. I left the MCP alone, so no new tool and no ceilings raised.limitdefault: 30 is the default, so callers who relied on 100 rows now get 30 until they passlimit=100.To try it:
curl "$FORGE_URL/api/v1/repos/trevato/forge/decisions?limit=30", then passcursor=<next_cursor>.Closes #6224