Skip to content
Merged
27 changes: 25 additions & 2 deletions docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,13 @@ directly). Agents call the MCP `report` tool, which POSTs to `POST /api/agents/{
(`offset`/`limit`, true `total`); the UI fetches tabular reports through it, so expanding
a 1.2 MB report transfers ~8 KB. Storage stays a single TEXT blob — no migration — and
the slice is Python-side, so it bounds the response, not the read; off-row storage waits
on a payload distribution that justifies it.
on a payload distribution that justifies it. The **portal** detail route carries the same
window as two optional query params (`rows_offset`/`rows_limit`, #2162) rather than a second
route: `/rows` is `Depends(get_current_user)`, which a portal principal cannot satisfy, and
a clone on a client-facing prefix would need its own copy of the uniform-404 contract. There
the *server* decides tabularity from the real payload (a non-tabular one returns whole with
no `row_meta`, never a 400), and because paging re-reads the blob per request it is
rate-limited per (client, agent).
- **Export** (#1536): `GET /api/reports/{id}/export?format=xlsx|pdf` →
`services/report_export.py` (pure builders, lazily-imported `openpyxl`/`reportlab`, both
pure-Python wheels). Shape mismatch degrades to a sensible sheet or JSON rather than
Expand All @@ -661,7 +667,24 @@ directly). Agents call the MCP `report` tool, which POSTs to `POST /api/agents/{
`GET /api/reports/{id}` returns the full payload, lazy-loaded when a card expands.
- **Fleet access**: `GET /api/reports` + `GET /api/reports/stats` filter via
`accessible_agent_names` + `_narrow_to_agent` (admin = all). Renderers (`components/reports/`)
pick by `display_hint` → `report_type` prefix → JSON, with shape-validation fallback to JSON.
pick by `display_hint` → `report_type` prefix → fallback, with a shape check per hint.
- **Three renderer surfaces, a per-surface fallback** (#2162): Agent Detail, the Operations fleet tab,
and the **Workspace agent page** all mount the same `ReportRenderer`. The third was added
after it shipped `JSON.stringify(payload)` to external clients — a disclosure defect
(`payload` is free-form agent JSON of the class `client_portal/agent_page.py` refuses to
expose for an ask's `context`, canary G-04), which a typed renderer narrows because it
reads only the keys its hint declares. This matters to the CI pin above: `test_1535`
regexes `payload.X` out of `ReportRenderer.vue`, so a third consumer widens that drift
guard's blast radius and **`shapeOk` must stay in that file** — extracting it is the
natural refactor and it empties the pinned set. The fallback is **per-surface**: the default
stays `ReportJson`, so both operator surfaces render exactly what they always did, and only the
Workspace passes `:fallback-component="ReportSummary"` (bounded, humanised, credential-shaped
tokens redacted at value level, no raw payload reachable behind it). AC #2 asks for a client
fallback "deliberately stricter than the operator side", so the split IS the design — a global
summary would erase it, and a raw dump is a FEATURE when you are debugging an agent's own
output. The override deliberately catches an agent-chosen `display_hint: "json"` as well as a
shape mismatch, since `json` is a valid enum value and replacing only the mismatch path would
leave an agent able to request a dump in front of a client.
- **Agent read-back** (#1538): `list_reports` / `get_report` MCP tools over the existing
access-controlled REST endpoints — no new endpoint, no new tenant-boundary logic. The
MCP layer adds the narrowing the backend cannot do (agent key → owner scope → `{self} ∪
Expand Down
43 changes: 36 additions & 7 deletions docs/memory/feature-flows/agent-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,19 @@ calling agent.
`setActive` gate so a WS trigger only refetches while the panel is mounted). Wired into
`utils/websocket.js` `agent_report` dispatch.
- **Renderers** `components/reports/` — `ReportRenderer.vue` picks by `display_hint` →
`report_type` prefix → JSON, validating payload shape and falling back to `ReportJson` on
mismatch (Codex #10). Typed renderers: `ReportTable`, `ReportKpiTiles`, `ReportMarkdown`
(DOMPurify via `utils/markdown.js`), `ReportTimeline`, `ReportJson`.
- **Panels** — `ReportsPanel.vue` (Agent Detail "Reports" tab) and `ReportsPanelFleet.vue`
(Operations → Reports tab; agent/type/time/search filters + KPI tiles from
`GET /api/reports/stats`). Lists show metadata; full payload lazy-loads on expand.
`report_type` prefix → fallback, validating payload shape and falling back on mismatch
(Codex #10). Typed renderers: `ReportTable`, `ReportKpiTiles`, `ReportMarkdown`
(DOMPurify via `utils/markdown.js`), `ReportTimeline`. The fallback is a **prop**, not a
fixture (#2162): `fallbackComponent` defaults to `ReportJson`, which is what both operator
panels still render, and only the client-facing Workspace passes `ReportSummary` — there is
no disclosure behind it, because a raw payload must be unreachable on that surface.
- **Panels — THREE consumers, not two** (#2162) — `ReportsPanel.vue` (Agent Detail "Reports"
tab), `ReportsPanelFleet.vue` (Operations → Reports tab; agent/type/time/search filters +
KPI tiles from `GET /api/reports/stats`), and the **Workspace agent page's Reports tab**
(`components/portal/PortalAgentPage.vue`, client-facing — see
[workspace-agent-page.md](workspace-agent-page.md)). Lists show metadata; full payload
lazy-loads on expand. Enumerating them is load-bearing: this list said "two" while the
third shipped a raw JSON dump to external clients.

### Renderer payload contracts
| hint | expected payload shape |
Expand All @@ -101,7 +108,18 @@ calling agent.
| `kpi` | `{ tiles: Array<{label, value, unit?}> }` |
| `markdown` | `{ markdown: string }` |
| `timeline` | `{ events: Array<{ts?, label, detail?}> }` |
| `json` (or anything malformed) | rendered as a pretty-printed JSON viewer |
| `json` (or anything malformed) | `ReportJson` by default; the overriding surface's `fallbackComponent` (#2162) |

**The fallback is per-surface, since #2162.** `ReportJson` — a pretty-printed dump — stays the
default, and both operator panels keep it: it is the useful answer when you are debugging an
agent's own output. It is unacceptable for an external client, so `ReportRenderer` takes a
`fallbackComponent` prop and the Workspace passes `ReportSummary`, a bounded summary (≤40 entries
with a counted remainder, ~200-char values, depth 1 — a nested value is described as "12 items",
never serialised) with credential-shaped tokens redacted at **value** level, mirroring canary
G-04's prefix set, and no raw payload behind it. AC #2 asks for a client fallback "deliberately
stricter than the operator side", so the split is the design. The override covers an
agent-chosen `json` hint as well as a shape mismatch — replacing only the mismatch path would
leave an agent able to request a dump in front of a client.

## Retention
`cleanup_service._sweep_retention_772` prunes `agent_reports` older than
Expand Down Expand Up @@ -203,6 +221,17 @@ is needed to know whether to page. Non-tabular payloads answer 400 on the rows r
than being given an invented row axis, and no-access answers 404 exactly like
`GET /reports/{id}`, so the sibling route cannot be used to probe an id.

**The client-facing surface windows differently, on purpose (#2162).** This route is
`Depends(get_current_user)`, which a portal principal — a verified email with no `users` row —
structurally cannot satisfy, so the Workspace could not reuse it and a clone on a
client-facing prefix would have needed its own copy of the uniform-404 contract. Its detail
route took two optional query params instead (`rows_offset`/`rows_limit`), and the **server**
decides tabularity from the real payload rather than trusting `display_hint`: a non-tabular
payload comes back whole with no `row_meta` instead of a 400, which removes the
predict-then-recover round trip the operator client needs. Same honest residual as below, plus
one more — paging re-reads the blob per request, so on a prefix a client can loop it is
rate-limited per (client, agent).

**Honest residual.** The slice happens in Python after the whole blob is read from the
column, so it bounds the RESPONSE, not the read. Moving the slice into SQL needs the rows
off-row; the trigger for that work should be a measured payload distribution approaching the
Expand Down
132 changes: 131 additions & 1 deletion docs/memory/feature-flows/workspace-agent-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,97 @@ mechanism anywhere in Trinity — no table, no column, no endpoint. It has no da
source, so it was omitted rather than invented. A number a user reads as "how
well is this agent doing" has to come from something real.

## The Reports tab (#2162)

The tab shipped `<pre>{{ JSON.stringify(payload, null, 2) }}</pre>`. Read beside
this document's own thesis, that is not a cosmetic gap: the section above refuses
to expose an ask's `context` at all because free-form agent JSON has been a
credential-leak surface (canary G-04) — and `agent_reports.payload` is the *same
category*, filed by the same agents, and was being dumped key-for-key to an
external client. Routing it through the shared `components/reports/` renderer set
**narrows** what crosses, because a typed renderer reads only the keys its hint
declares (`tiles`, `columns`+`rows`, `markdown`, `events`) and never the rest of
the payload.

**Rendering is presentation, and this does not move the exclusion boundary.**
Everything the page must not show is still dropped in `client_portal/agent_page.py`
before the payload exists; nothing is filtered in the Vue component. What changed
is how the payload that legitimately crosses is *presented*. The payload itself
remains agent-authored untrusted content of the same class as `asks.title` and
the schedule name — bounded and escaped, never trusted.

**The fallback is the one place this surface deliberately differs from the
operator ones.** The shared set's fallback is the raw JSON viewer, so reuse alone
could not satisfy "never a raw dump to a client" — and AC #2 asks for a fallback
*"deliberately stricter than the operator side, because the audience is an
external client"*, i.e. it asks for a SPLIT, not for a stricter default
everywhere. So `ReportRenderer` gained a `fallbackComponent` override defaulting
to `ReportJson` — every operator call site passes nothing and renders exactly what
it always did, because a raw payload is the useful answer when you are debugging
an agent's own output — and this page passes `ReportSummary`: a bounded key-value
view (≤40 entries with a counted remainder, ~200-char values, depth 1, so a
nested value is described as "12 items" and never serialised) with
credential-shaped tokens redacted at **value** level, and no raw payload
reachable behind it at all. A key-name allow-list was rejected twice over: the
fallback fires precisely on payloads nobody has seen, so an allow-list blanks
nearly all of them, and an allowed key's value carries the secret anyway
(`{"status": "failed: sk-…"}`).

The override deliberately catches an agent-chosen `display_hint: "json"`
(`src/mcp-server/src/tools/reports.ts`) as well as a shape mismatch — replacing
only the *mismatch* path would leave an agent able to put a raw dump in front of
a client by asking for one.

**Honest residual.** A key-value summary still names every top-level key. It
bounds and humanises; it does not eliminate the class, and a well-shaped
`markdown` or `table` report still renders its values as authored. The general
fix is a G-04-style scrub at the portal read boundary — a security change to a
shipping read path, which deserves its own review rather than riding a UI fix.

**Row windowing without a second route.** AC #3 wants #1537's windowed-rows
pattern, and the operator reader `GET /api/reports/{id}/rows` is
`Depends(get_current_user)` — which a portal principal (a verified email with no
`users` row) structurally cannot satisfy, the same fact #2128 hit with
feature-flags. Rather than clone it onto a client-facing prefix, the **existing**
detail route took two optional params:

```
GET .../agents/{name}/reports/{id}?rows_offset=&rows_limit=
tabular payload -> payload {columns, rows: window} + row_meta {total, offset, limit}
anything else -> payload whole, no row_meta (rows_limit ignored)
```

The **server** decides tabularity from the real payload, so the client never
predicts a shape from an agent-authored `display_hint` that can disagree with what
was filed — which deletes the 400-and-recover branch a client-side prediction
would need. `rows_limit` absent is byte-identical to before. No new route means no
second gate and no second copy of the 404-uniformity contract: a foreign report id
and a missing one stay indistinguishable through the windowed path too.

**Read amplification is real and mitigated, not hidden.** The slice happens in
Python after the whole (≤5 MiB) blob is read out of the column, so paging
*multiplies* reads — the route that exists to cut transfer raises them. Acceptable
behind an operator JWT; on a prefix a client can loop it is an amplification
primitive, so the route is rate-limited per (client, agent) after the roster gate.
A report whose total fits one page costs exactly **one** request and shows no
footer, so only genuinely large tables page at all.

**Bounded by rows, not by a nested scroll region.** The page has one scroll axis
(#2101, and the asks list above made the same call); a 100-row window plus
`ReportTable`'s stated total and an explicit "Load more" satisfies "contained with
a stated total" without a second scroll axis.

**The store owns the state, and every await is generation-guarded.** A reset
cannot cancel a promise already in flight, so the `reportsLoaded` flag that
contract #15 requires (an empty state must gate on a *succeeded* fetch, never on
list length) would otherwise have turned a transient wrong-render into a permanent
one: switch agents mid-fetch, the old agent's list lands in the cleared state, and
the new agent is marked loaded-with-the-wrong-data for the life of the mount.
Every report request captures a generation counter before its first await and
discards its result if a reset bumped it. The component additionally gates each
read on the state belonging to the agent on screen — the store is a singleton that
outlives it, and a fresh **mount** for a different agent fires no props watcher.

## The UX repairs (#2161)

The page shipped with four defects, and fixing them forced two of ent#360's own
Expand Down Expand Up @@ -252,7 +343,15 @@ destination" is finally true.
| UI | `components/StackedBarChart.vue` | optional `labels` prop (#2161) |
| UI | `utils/executionBuckets.js` | **new** — `BUCKET_COLORS` + chart helpers, shared with `OverviewPanel.vue` (#2161) |
| UI | `views/Portal.vue`, `router/index.js` | `/workspace/a/:agentName`; shared stage escape (#2161) |
| Store | `stores/clientPortal.js` | `fetchAgentPage`, `fetchAgentReports`, `fetchAgentReport` |
| Store | `stores/clientPortal.js` | `fetchAgentPage`, `fetchAgentReports`, `fetchAgentReport`; the whole Reports orchestration + generation guard (#2162) |
| Service | `client_portal/agent_page.py` | `_window_rows` + `report_detail(rows_offset, rows_limit)` (#2162) |
| Router | `client_portal/router.py` | `rows_offset`/`rows_limit` on the existing detail route, rate-limited (#2162) |
| UI | `components/reports/ReportSummary.vue` | **new** — the CLIENT-FACING human-readable fallback; no raw escape hatch (#2162) |
| UI | `components/reports/reportSummary.js` | **new** — the bounded, redacting summariser (pure) (#2162) |
| UI | `components/reports/ReportRenderer.vue` | `fallbackComponent` override, default `ReportJson` (operator unchanged); `shapeOk` untouched (#2162) |
| UI | `components/reports/{ReportTable,ReportKpiTiles}.vue` | dark ink pair on meta text — AC #4 (#2162) |
| UI | `components/reports/ReportTimeline.vue` | `bg-blue-500` → `bg-status-info-500` (#2162) |
| UI | `utils/reportPaging.js` | **new** — the one frontend page-size constant, shared with `stores/reports.js` (#2162) |

## Tests

Expand Down Expand Up @@ -283,6 +382,37 @@ param-enumerating guard cannot have and the reason this bug shipped twice. Also
pins that the chart is stacked by untranslated buckets while the labels ride the
separate prop — the mistake that renders a blank chart.

`tests/unit/test_2162_portal_report_window.py` — the row window: `rows_limit`
absent returns today's payload unchanged, a tabular payload windows with a TRUE
total, a non-tabular one comes back whole with no `row_meta` (never a 400), the
offset/limit clamps, and the inherited gate — foreign and missing ids stay
indistinguishable 404s through the windowed path. Plus the route wiring: both
params optional, bounded by the shared `REPORT_ROWS_PAGE_MAX`, rate-limited
*after* the roster gate, and resolvable by FastAPI under postponed annotations.

`src/frontend/tests/unit/reportSummary.spec.js` — the fallback summariser. The
two that define it are negatives: no output path ever serialises the payload
(behaviourally, and by scanning the module source), and a credential-shaped token
is redacted **at value level** — as a whole value, embedded mid-string under an
innocuous key, and past the truncation point. Redaction runs before both
truncation and key humanisation; the humanisation ordering was caught by its own
test, since rewriting `_` to a space destroys the very shape every pattern keys
on.

`src/frontend/tests/unit/portalReportsStore.spec.js` — the store contract against
a mocked axios (`fleetGridFailuresFetch.spec.js` shape). The agent-switch race is
the one that matters: agent A's list, failure, payload and load-more page each
resolve *after* a switch to B and must all be discarded, with B left NOT marked
loaded. Plus the load-more terminal guard, windowed-vs-whole (`row_meta` present
is the only paging signal), and that a failed fetch never lands in the payload map.

`src/frontend/tests/unit/portalReportsRendering.spec.js` — the wiring no unit test
can reach: the tab holds no `<pre>` and no serialiser, mounts the shared
`ReportRenderer`, passes `:fallback-component`, and adds no second scroll axis.
Guards the **mechanism** rather than the spelling — a prop declared and never used
would pass a call-site scan — and re-asserts the five CI-pinned `payload.X` keys
are still inside `ReportRenderer.vue`, the file `test_1535` regexes them out of.

`src/frontend/tests/unit/workspaceRoomsGate.spec.js` — F24 was **rewritten**, not
deleted. It used to require each exit function to contain `route.params.roomId`;
after #2161 that would mandate the enumeration that *was* the defect, so it now
Expand Down
Loading
Loading