Activity feed
The dashboard ships a Live Activity Feed at /activity (sidebar entry under the Workspace group, next to Home). It is a cross-project timeline of audit-log rows, scoped to the user’s accessible projects, polling every five seconds.
What appears in the feed
Section titled “What appears in the feed”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; the difference is breadth (all your projects in one view) and freshness (poll every 5s instead of every 30s).
| Caller | Sees |
|---|---|
| Admin | Every audit row, including brain-wide rows that have no project_id (first-boot setup, system-wide config events) |
| Non-admin user | Only rows whose project_id is one the user holds a membership in; brain-wide rows are excluded |
A non-admin with zero memberships sees an empty feed.
Filters
Section titled “Filters”The page exposes two filters in a sticky filter bar:
- Project — “All projects” (default) or any single project from the user’s accessible set. Filtering to a project they cannot see is a no-op (returns empty rather than 403, so an enumeration probe gives nothing away).
- Action prefix — free-form
LIKEagainst the action column. Common prefixes:task.,user.,agent.,auth.,subscription..
Filters apply server-side and the polling cycle picks them up immediately.
Wire endpoint
Section titled “Wire endpoint”The page is backed by GET /api/v1/activity:
GET /api/v1/activity?limit=50&project_slug=alpha&action_prefix=task. HTTP/1.1Authorization: Bearer <session cookie>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 is the only surface that returns the field to non-admin callers.
Query parameters:
| Parameter | Notes |
|---|---|
limit | 1..200, default 50 |
since_cursor | Return only rows newer than this ` |
before_cursor | Return only rows older than this ` |
project_slug | Constrain to one project (must be in caller’s accessible set; unknown slugs 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. |
Rate limit
Section titled “Rate limit”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 Z4J_WORKERS=N 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.
Why polling and not WebSocket
Section titled “Why polling and not WebSocket”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 a single indexed query (audit_log ordered by id DESC with a project_id filter), so the cost is negligible. A WebSocket-pushed mode is a candidate for a later minor if operators ask for it.
Threat model
Section titled “Threat model”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. The new surface area is the cross-project aggregation: a non-admin user with memberships in two projects can now see both project’s audit rows in one view. This is by design and matches the existing per-project audit page’s RBAC (member sees rows; non-member 403s).
The endpoint relies on is_admin + MembershipRepository.list_for_user for scope enforcement; both surfaces are already exercised by the unit tests for the existing audit / projects APIs. The new endpoint adds its own 14-case test suite covering scope enforcement, filter handling, pagination, and the unauthenticated-rejection path.
Disabling
Section titled “Disabling”The feed cannot be disabled per-tenant; it is always available to logged-in users. If you need to hide it from non-admin users entirely, gate the /activity route in your reverse proxy or remove the sidebar entry in a downstream fork of the dashboard build.