Summary
attribution_pagination_payload computes each attribution kind's has_more by OR-ing the per-kind row count with the request-level truncated flag. Whenever the action scan truncates (a group with more human-action events than GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST), every kind is advertised as has_more=True even when all of its rows have already been returned, and pagination_payload then hands back a next_offset that does not advance — an infinite pagination loop for API consumers.
Code
forensics/views.py:2175-2190:
def attribution_pagination_payload(action_groups: list[dict], filters: dict) -> dict:
limit = filters["limit"]
offset = filters["offset"]
payload = {}
for kind, page_key in ACTION_ATTRIBUTION_PAGE_KEYS.items():
total = len([row for row in action_groups if row["attribution_kind"] == kind])
returned = min(max(total - offset, 0), limit)
payload[page_key] = pagination_payload(
limit,
offset,
returned,
total > offset + limit or getattr(action_groups, "truncated", False), # has_more
)
forensics/views.py:1686-1693:
def pagination_payload(limit: int, offset: int, returned: int, has_more: bool) -> dict:
return {
...
"has_more": has_more,
"next_offset": offset + returned if has_more else None,
}
truncated is set per request in action_groups_for_api (forensics/views.py:2122) purely from the total event count vs max_events; it is independent of offset, so it is True on every page once the group's action history is large enough.
Concrete failure scenario
- A group has more human-action events than
GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST, so action_groups_for_api sets truncated=True.
GET /api/v1/groups/<slug>/actions/ (default offset=0, limit=100). Take an attribution kind with, say, 3 fully-returned groups: returned=3, has_more = (3 > 100) or True = True, next_offset = 0 + 3 = 3.
- The client follows
next_offset=3. Now returned = min(max(3-3,0),100) = 0, but has_more is still True (truncated is still True), so next_offset = 3 + 0 = 3.
- The client is pinned at
offset=3, has_more=True, zero new rows — forever. For a kind with zero rows the loop pins at offset=0.
Each iteration also re-runs the full bounded action scan (GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST events materialized per request), so an automated consumer following the documented pagination contract spins in place and repeatedly re-scans.
Why not a duplicate
#164 is about api_group_actions materializing all events and paginating in Python (the scan strategy). #147 is group_overview_context setting audit_files_limited via == LIMIT. This is a distinct output-contract bug in the pagination metadata: has_more/next_offset conflate the per-kind "more rows of this kind" signal with the request-level scan_truncated signal, so a fully-returned kind never terminates. scan_truncated is already surfaced as its own top-level key (views.py:2188); it should not also drive per-kind has_more.
Suggested fix
Compute per-kind has_more from that kind's own total only (total > offset + limit), and let scan_truncated remain the separate, already-present signal that the underlying scan was capped. When has_more is derived per-kind, next_offset advances correctly and terminates.
Severity: MEDIUM — breaks the pagination contract for automated API consumers (non-terminating loop) and amplifies work via repeated full scans.
Summary
attribution_pagination_payloadcomputes each attribution kind'shas_moreby OR-ing the per-kind row count with the request-leveltruncatedflag. Whenever the action scan truncates (a group with more human-action events thanGOGGLES_MAX_ACTION_EVENTS_PER_REQUEST), every kind is advertised ashas_more=Trueeven when all of its rows have already been returned, andpagination_payloadthen hands back anext_offsetthat does not advance — an infinite pagination loop for API consumers.Code
forensics/views.py:2175-2190:forensics/views.py:1686-1693:truncatedis set per request inaction_groups_for_api(forensics/views.py:2122) purely from the total event count vsmax_events; it is independent ofoffset, so it isTrueon every page once the group's action history is large enough.Concrete failure scenario
GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST, soaction_groups_for_apisetstruncated=True.GET /api/v1/groups/<slug>/actions/(defaultoffset=0, limit=100). Take an attribution kind with, say, 3 fully-returned groups:returned=3,has_more = (3 > 100) or True = True,next_offset = 0 + 3 = 3.next_offset=3. Nowreturned = min(max(3-3,0),100) = 0, buthas_moreis stillTrue(truncatedis stillTrue), sonext_offset = 3 + 0 = 3.offset=3,has_more=True, zero new rows — forever. For a kind with zero rows the loop pins atoffset=0.Each iteration also re-runs the full bounded action scan (
GOGGLES_MAX_ACTION_EVENTS_PER_REQUESTevents materialized per request), so an automated consumer following the documented pagination contract spins in place and repeatedly re-scans.Why not a duplicate
#164 is about
api_group_actionsmaterializing all events and paginating in Python (the scan strategy). #147 isgroup_overview_contextsettingaudit_files_limitedvia== LIMIT. This is a distinct output-contract bug in the pagination metadata:has_more/next_offsetconflate the per-kind "more rows of this kind" signal with the request-levelscan_truncatedsignal, so a fully-returned kind never terminates.scan_truncatedis already surfaced as its own top-level key (views.py:2188); it should not also drive per-kindhas_more.Suggested fix
Compute per-kind
has_morefrom that kind's own total only (total > offset + limit), and letscan_truncatedremain the separate, already-present signal that the underlying scan was capped. Whenhas_moreis derived per-kind,next_offsetadvances correctly and terminates.Severity: MEDIUM — breaks the pagination contract for automated API consumers (non-terminating loop) and amplifies work via repeated full scans.