Skip to content

Activity feed

The dashboard ships an Activity page at /activity (sidebar entry next to Home, shown when no project is selected). It is a cross-project timeline of audit-log rows, scoped to the projects whose audit trail the user may read, polling every five seconds.

Every row written to the audit log shows up: command issuance, task transitions, subscription edits, channel CRUD, user/role changes, schedule edits, MFA enrolments, login attempts, and so on. The feed is the same data the per-project audit page exposes, behind the same role bar; the difference is breadth (every project you may audit, in one view) and freshness (poll every 5s instead of every 30s). A project's rows appear for a caller exactly when that caller could open the project's audit page: the auditor or admin role on the project, decided by the same policy table (RBAC). A viewer or operator membership contributes nothing to the feed, so the feed is not a way around the audit tier.

Caller Sees
Instance admin Every audit row, including brain-wide rows that have no project_id (first-boot setup, system-wide config events)
User with auditor or admin on a project That project's rows, plus their own rows that carry no project_id (MFA, login, password events); other users' brain-wide rows are excluded
User with only viewer or operator on a project None of that project's rows; only their own user-scoped rows

A user with no audit-tier membership anywhere sees only their own user-scoped rows. The project_slug filter follows the same rule: a project the caller may not audit returns an empty page, not its rows.

The page exposes two filters in a sticky filter bar:

  • Project -- "All projects" (default) or any single project from the set the user may audit. Filtering to a project they cannot see, or hold only viewer or operator on, is a no-op (returns empty rather than 403, so an enumeration probe gives nothing away). The dashboard offers only the projects the caller may audit, and a caller with no auditor or admin membership sees a note that project activity needs the auditor or admin role while their own account activity is listed.
  • Action prefix -- free-form LIKE against the action column. Common prefixes: task., user., agent., auth., subscription..

Filters apply server-side and the polling cycle picks them up immediately.

The page is backed by GET /api/v1/activity:

GET /api/v1/activity?limit=50&project_slug=alpha&action_prefix=task. HTTP/1.1
Cookie: <session cookie>

The session cookie is resolved first; an Authorization: Bearer <api key> header is accepted instead.

Returns:

{
"items": [
{
"id": "0e6c1f3a-12ab-4cde-9876-543210fedcba",
"project_id": "...",
"project_slug": "alpha",
"user_id": "...",
"action": "task.failed",
"target_type": "task",
"target_id": "task-id",
"result": "success",
"outcome": "allow",
"metadata": {"task_name": "send_invoices"},
"source_ip": null,
"occurred_at": "2026-05-12T12:00:00.000000+00:00"
}
],
"next_before_cursor": "2026-05-12T11:30:00.123456+00:00|0e6c1f3a-12ab-4cde-9876-543210fedcba",
"newest_cursor": "2026-05-12T12:05:00.000000+00:00|aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
}

Cursor format is <iso_occurred_at>|<row_id> because the underlying audit_log.id column is uuid4 (random, not time-ordered); ordering by id alone would shuffle rows on every page. The dashboard's infinite-query refetches the head of the timeline on each 5-second poll and walks next_before_cursor for the Load older button.

source_ip returns null for non-admins regardless of project membership. The per-project audit page returns the field, and only project auditors and admins can open that page, so they are the only readers who see it.

Query parameters:

Parameter Notes
limit 1..200, default 50
since_cursor Return only rows newer than this <iso>|<uuid> cursor. Use for live polling.
before_cursor Return only rows older than this <iso>|<uuid> cursor. Use for backward pagination.
project_slug Constrain to one project (must be one the caller may audit; unknown slugs and projects outside that set return empty rather than 403 so enumeration probes give nothing away).
action_prefix LIKE-escaped literal prefix against the action column. % and _ are NOT wildcards.

The endpoint enforces a sliding window of 60 requests / minute per user.id per worker process. Exceeding the limit returns 429 Too Many Requests with a Retry-After: 60 header and a body of {"detail": "activity feed rate limit exceeded (60/min per worker)"}. With N uvicorn workers per brain (z4j serve --workers N, default min(4, cpu_count)) and M brain replicas, the cluster-wide effective ceiling is NM60/min per user; the per-worker enforcement is the correct shape for a polling dashboard but is NOT a cluster-wide throttle.

The audit log is the canonical source of truth. A WebSocket push from the brain would add a fan-out boundary (the dashboard hub already exists, but tying the activity feed to it would couple the two surfaces) and a stale-on-disconnect failure mode. Polling at 5 seconds is one extra GET per visible tab; the endpoint is two cheap queries (audit_log ordered by (occurred_at, id) DESC with the scope filter, plus a slug lookup for the page's projects), so the cost is negligible. There is no WebSocket-pushed mode.

The activity feed is a READ-ONLY view of the audit log. The endpoint never mutates state, and the data passing through is the same data the per-project audit page already exposes, to the same readers. What it adds is the cross-project aggregation: a user who audits two projects sees both projects' audit rows in one view. This is by design. It is a wider read than the per-project audit page in breadth, never in audience: a role the audit page refuses is refused the same rows here.

The endpoint relies on is_admin plus the caller's memberships filtered by the policy table's answer for the audit read action, the same answer the per-project audit page gets. The endpoint has its own unit test suite covering scope enforcement, filter handling, pagination, and the unauthenticated-rejection path, and the separation-of-duties suite holds the viewer and operator exclusions and the auditor and admin admissions against the real application.

The feed cannot be disabled per-tenant; it is always available to logged-in users, who see only what their roles admit (a user without an audit-tier membership sees their own user-scoped rows and nothing else). If you need to hide the page itself, gate the /activity route in your reverse proxy or remove the sidebar entry in a downstream fork of the dashboard build.