> For the complete documentation index, see [llms.txt](https://docs.opendatadiscovery.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.opendatadiscovery.org/developer-guides/architecture-decision-log/adr-0021-activity-stream-cursor-pagination.md).

# ADR-0021: Activity streams use cursor pagination

## Status

**Accepted.** Reconstructed from the codebase on 2026-05-30; the decision is live in the source today.

## Context

The activity feed is an append-only audit trail that can grow very large. Two pagination shapes are available: offset/limit (`page` + `size`), which the platform uses for aggregate listings like alerts, and cursor pagination, which carries a pointer to the last item seen. Offset paging degrades as the offset grows — the database must scan and discard every skipped row — which is exactly the wrong cost profile for deep scrolling through a long audit log.

## Decision

**Activity-stream endpoints paginate by a `(lastEventId, lastEventDateTime)` cursor, not offset/limit.** The caller passes the id and timestamp of the last event it saw, plus a `size`; the next page is the rows immediately after that cursor. The same cursor shape is reused across the platform-wide activity feed (`getActivity`) and the per-data-entity activity feed (`getDataEntityActivityList`), establishing cursor pagination as the convention for activity streams specifically.

This is **not** a global pagination convention: aggregate/mutable listings (alerts, the data-entity list) continue to use offset/limit (`page` + `size`). The pagination shape follows the data semantics — append-only audit streams get cursors; bounded mutable listings get offsets.

## Consequences

* Deep scrolling through the activity feed stays cheap: each page is an indexed seek past the cursor rather than an offset scan, so cost does not grow with how far back the reader has paged.
* The cursor is a compound key `(lastEventId, lastEventDateTime)` because event timestamps are not unique — the id breaks ties so the page boundary is deterministic.
* A reader cannot jump to "page N" of the activity feed (there are no page numbers) — only forward/continued paging from a cursor. That is the accepted trade-off of the shape, and it matches how an audit feed is actually read.
* There is a known naming wart: the controller-level cursor parameter is spelled `lasEventId` (missing a "t"), so the generated client exposes that misspelling on the public contract even though the service-layer parameter is the correct `lastEventId`. The decision is the cursor *shape*; the misspelled parameter name is a contract blemish that renaming would be a breaking change to fix.

## Evidence

* `odd-platform-api/.../controller/ActivityController.java:34-35` — `getActivity` takes `final Long lasEventId, final OffsetDateTime lastEventDateTime` (the cursor pair; note the `lasEventId` misspelling at the controller/contract layer) and passes them to the service.
* `odd-platform-api/.../service/activity/ActivityServiceImpl.java:96-97` — `getActivityList(... final Long lastEventId, final OffsetDateTime lastEventDateTime)`; `:179-180` — `fetchAllActivities` forwards them to `activityRepository.findAllActivities(... lastEventId, lastEventDateTime)`.
* `odd-platform-api/.../service/activity/ActivityServiceImpl.java:119-127` — `getDataEntityActivityList` takes the same `(lastEventId, lastEventDateTime)` cursor pair, confirming the shape is reused across activity-stream endpoints.

## See also

* [Activity Feed](/features/active-platform-features/activity-feed.md) — the user-facing activity stream this paginates.
* [ADR-0022 — Activity view-modes are a single enum parameter](/developer-guides/architecture-decision-log/adr-0022-activity-view-modes-single-parameter.md) — the companion API-shape decision for the same endpoint.
