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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

`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

opened this

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 limit is 422 invalid_limit. A cursor that isn't a decision of this repository is 422 invalid_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_cursor that resumes after the last row scanned, as /events does.
  • Code: the paging lives in src/core/decisions.ts (decisionsPage, decisionsLimit, decisionCursorValid). listDecisions gained a before option. The route in src/http/api.ts is thin.
  • Tests: a new case in test/api-decisions-timing.test.ts checks 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 include next_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.
  • limit default: 30 is the default, so callers who relied on 100 rows now get 30 until they pass limit=100.

To try it: curl "$FORGE_URL/api/v1/repos/trevato/forge/decisions?limit=30", then pass cursor=<next_cursor>.

Closes #6224

commented

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 limit is 422 invalid_limit. A cursor that isn't a decision of this repository is 422 invalid_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_cursor that resumes after the last row scanned, as /events does.
  • Code: the paging lives in src/core/decisions.ts (decisionsPage, decisionsLimit, decisionCursorValid). listDecisions gained a before option. The route in src/http/api.ts is thin.
  • Tests: a new case in test/api-decisions-timing.test.ts checks 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 include next_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.
  • limit default: 30 is the default, so callers who relied on 100 rows now get 30 until they pass limit=100.

To try it: curl "$FORGE_URL/api/v1/repos/trevato/forge/decisions?limit=30", then pass cursor=<next_cursor>.

Changes requested — before the latest push

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.

  1. Fix this first: the OpenAPI source does describe this read, and it is now wrong. src/contract/openapi/routes/getApiV1ReposOwnerNameDecisions.ts still says answers { decisions, week } and declares only query: { kind }. The preview's /api/v1/openapi.json shows that old contract at this head. The pull description says no OpenAPI source was found. Update that route file: add limit and cursor to query, and change the summary to say limit 1–100 (default 30), cursor is the next_cursor of 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_by does not describe this read, so leaving MCP alone is right.
  1. 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=N should 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 if visible moved 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.

commented

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.ts now declares limit and cursor in query. Its summary gives limit 1–100 (default 30), cursor as the next_cursor of 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.ts puts 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.ts and all the test/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.

Changes requested — before the latest push

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_cursor is 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.

commented

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.ts puts 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-null next_cursor. Following the cursors reaches the visible row exactly once.
  • Cursor choice: a page's next_cursor is 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 decisionsPage says all this.

Not done

  • decisionCursorValid still 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

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, cursor and limit are all bound parameters.
  • cursor is 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.
  • limit is a bounded whole number.
  • mayViewId still runs on every row before the page is cut, and forReader still 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 /events does.

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=007 is accepted as 7.
  • When a page holds exactly limit rows, a final empty page can follow. That is still correct.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs