# A console leaked one CSS class and hid 11 days of receipts

> Two ways an agent console lied during a redesign: a view's opt-out class nothing removed, and a receipt table no page read. With a five-minute check for each.

- Author: Chad Priest
- Published: 2026-09-02
- Canonical URL: https://blog.vodou.ai/agent-console-shared-host-leak-unread-receipts/
- Tags: ai-agents, observability, architecture, memory, frontend

---

Every agent system I have seen writes more about a turn than it shows. The model ran. The memory lane ran, or did not. The tools returned. Somewhere a row landed with all of that in it. Then a person opens the console and sees whichever subset the last engineer to touch that view found interesting. The console is the one place a human checks whether the system did what it claims, and it is built by the people who are already sure it did.

I spent four days inside the console for Vodou: 13 commits, 138 files, and a staging copy of the whole UI served one URL apart from the original. Most of that was tokens, spacing, and a rail. It does not belong in a post. Two of the bugs do, because in both the console told a story the data did not support, and both have a check you can run on your own stack this afternoon.

## A byte-for-byte copy at /next/, and a fifth tab that reads a table nobody read

The redesign does not replace the console. It lives at `MCP-servers/Vodou-Console/public/next/` as a copy of the original, served by the same gateway at `/next/index.html`, with asset paths rewritten and no service worker. The old UI at `/` stays untouched for the whole release. I wanted the two comparable one URL apart, in the same session, against the same data, because a redesign judged from screenshots is judged by the person who made it.

Navigation became data. `js/shell/nav-manifest.js` lists five destinations, Settings, and a reach list for the command palette, and the rail renders from it. One status dot folds the websocket, kernel, scheduler and channel signals, reusing the polling that already existed instead of adding more.

The part that matters to you is the Receipts tab. Every turn already produced a receipt: which memories were injected, which lanes ran, how long each took, whether the turn was degraded. Memory now has a fifth tab that shows every receipt from the last 3, 7 or 14 days, grouped by local day, across all conversations, with a per-lane coverage strip (with memories, found nothing, never ran) and a "problems only" toggle.

**Diagram: Where a receipt could be seen, before and after**

A turn writes a receipt to turn_receipts; before, only the chat view read it inside one conversation; now a read-only endpoint feeds a Receipts tab and both views share one renderer

```text
  [Turn] --> [buildReceipt] --> [turn_receipts table]
  [turn_receipts table] --> [▶ Context N line in chat.js (problem)]
  [turn_receipts table] --> [GET /api/receipts]
  [GET /api/receipts] --> [Memory › Receipts tab (fixed)]
  [▶ Context N line in chat.js (problem)] --> [turn-receipt-view.js]
  [Memory › Receipts tab (fixed)] --> [turn-receipt-view.js]

  notes:
    buildReceipt: memories, lanes, ms, degraded
    ▶ Context N line in chat.js: only inside a conversation you open
    GET /api/receipts: read-only
    turn-receipt-view.js: one renderer, both surfaces

  The table existed the whole time. The tab is what made it a surface.
```

## Four records of one turn, and the fullest one was displayed by nothing

Here is the unflattering part. In August the memory pipeline went dark for 11 days. Every turn still wrote a receipt. The receipts said, per lane, "never ran, 0 ms." Nobody saw them, because the only renderer was the small `▶ Context N` line under a reply, inside a conversation. You had to open the right conversation and expand the right line to learn the lane had not run. Nobody opens old conversations to audit them. I did not.

When I went looking, I counted four records of every turn, each showing a different subset. The panel and the page injector showed memories, tools and skills. The second console showed memories, model and tokens. The stored `turn_receipts` row held memories, ids and the `degraded` flag, and no page read it. The most complete record had zero readers. It was a write with no reader, which for a human is the same as no write.

The fix was less code than the diagnosis. `GET /api/receipts` is a read-only query over `turn_receipts`. Classification lives in `browseReceipts` in `turn-receipt.ts`, which is the fixture-gated twin of the grader that already scored this flow in CI, so the tab and the grader cannot disagree about what "never ran" means. The receipt DOM moved verbatim out of `chat.js` into `components/turn-receipt-view.js`, so chat and the tab render one receipt UI instead of two that drift. Moving it exposed a second bug: `parseReceiptLanes` had been dropping the `items` count the row carried, so a lane that injected two memories rendered without the number. The morning of the blackout, with "problems only" on, now reduces to five red rows.

## Visit /board once and /memory renders at padding 0 with overflow hidden

The second bug was smaller and, per minute of user time, worse. The console hands one element, `#main-content`, to every view. The Kanban board opts out of the standard padded document chrome by adding a class: `.board-view` sets padding to zero and overflow to hidden, because the board scrolls its own columns. Nothing removed the class. Visit the board once and Memory, Apps, Messaging, Skills, Scripts, Servers, Channels and Projects all rendered flush to the edges with their internal scrolling clipped until a reload.

I measured before touching anything:

```text
/memory  cls=""            pad=24px/32px
/board   cls="board-view"  pad=0px/0px
/memory  cls="board-view"  pad=0px/0px   <- leaked
```

The router already normalized `display` on that element, for exactly this reason. Someone had hit this class of bug before and fixed the half they saw. The class list was the other half. The `overflow: hidden` half did the damage: any long page opened after the board could not scroll, and the user's report would be "Memory stopped scrolling," which points at Memory.

I audited all 21 routes on a clean load first. Every one was already at 24/32. Nothing needed per-view spacing work. They were correct and were being stripped after the fact, which is why a review of any single view found nothing wrong.

**Diagram (beforeafter)**

Before, the board added a class the router never removed and 20 pages inherited padding 0; after, the router resets the class list along with display

```text
  BEFORE: Before the fix
    - board adds .board-view to the shared host
    - router resets display only
    - every page after the board: pad 0, overflow hidden
    - until reload

  AFTER: After the fix
    - router resets className and display on every navigation
    - board keeps its opt-out
    - all 21 routes measure 24/32 in any order
```

## The reset set must cover the mutation set, and every table needs a reader

Both bugs are the same shape, and the shape is checkable rather than advisory.

For the shared host: the set of properties a router resets on navigation must be a superset of the set of properties any view is permitted to mutate on the shared element. This is true or false of a codebase. Grep every view for writes to the host (`classList.add`, `style.`, `dataset.`, attribute sets) and compare that list to the router's reset. Any property in the first list and not the second is a leak waiting for the right visit order.

For the receipts: every table the turn path writes must have at least one read path that reaches a page a person can open without already knowing a conversation id. A record reachable only from inside the thing it describes is not an audit surface. It is a footnote.

## Hash-walk your routes twice in DevTools, then count readers per table

Run the first check in the browser console of your own single-page app. Replace the selector and the route list.

```js
const host = document.querySelector('#app-main');
const routes = ['#/chat', '#/board', '#/memory', '#/settings'];
const snap = (r) => {
  const cs = getComputedStyle(host);
  return { r, cls: host.className, pad: cs.padding, ov: cs.overflow };
};
const rows = [];
for (const r of [...routes, ...routes.slice().reverse()]) {
  location.hash = r;
  await new Promise(res => setTimeout(res, 400));
  rows.push(snap(r));
}
console.table(rows);
```

Passing output: each route's row is identical on both passes. Failing output looks like mine above. A route whose `cls` or `pad` differs depending on what came before it has a view that mutates the host without a symmetric teardown, and the router is not covering for it.

The second check is a grep against your own tree. List the tables your turn path writes, then count readers.

```bash
sqlite3 app.db "SELECT name FROM sqlite_master WHERE type='table';" |
while read t; do
  w=$(grep -rlE "INSERT INTO $t|UPDATE $t" src | wc -l)
  r=$(grep -rlE "FROM $t" src | wc -l)
  echo "$t writers=$w readers=$r"
done
```

Passing: every table with writers has readers, and for each reader you can name a page that shows the rows without a conversation id. Failing: a line like `turn_receipts writers=2 readers=0`. That line existed in my tree for months.

## The advice is about signing the receipt, not about reaching it

The current guidance on agent receipts is about integrity. The [agent-receipts](https://github.com/webaesbyamin/agent-receipts) project signs every memory observation with Ed25519 and hashes inputs and outputs, so a fact can be traced to the conversation that created it. The [kriya console](https://github.com/governex/kriya-console) turns everything an agent did into signed receipts you can re-verify offline. Both are good work, and to their credit the [agent-receipts dashboard](https://agent-receipts-web.vercel.app/) puts a fourteen-day receipt volume chart and a recent receipts list on its front page from day one, which is the surface I was missing.

What the integrity framing skips is reachability. A signed receipt inside a conversation nobody opens is exactly as invisible as an unsigned one. My receipts were correct for 11 days. The failure was not in the record. It was that the only reader of the record was the chat view, and the chat view is the last place a person goes to audit.

The broader agent guides have the same gap. Anthropic's [Building Effective AI Agents](https://www.anthropic.com/engineering/building-effective-agents) argues for simple composable patterns and for measuring performance, and OpenAI's [practical guide to building agents](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf) covers guardrails and human intervention. Neither says where the human looks. A console is the thing the human looks at, and it is built with the least rigor of anything in the stack, because the people who build it already believe the system.

**Diagram (timeline)**

Four days: receipts tab, the board leak fix, the staging copy, an 860px reading column added and removed the same day, then six more views

```text
  Aug 30    ->  Receipts tab reads turn_receipts for the first time (fixed)
  Aug 31    ->  board-view class found leaking onto 20 routes (problem)
  Sep 2 am  ->  staging copy at /next/ with the rail shell
  Sep 2     ->  860px reading column added to chat (problem)
  Sep 2     ->  860px column and 1120px page cap both removed
  Sep 2 pm  ->  Memory, Activity, Skills, Connect, Settings views done (fixed)
```

The column deserves one sentence. I added an 860px reading column to chat in the morning because every design reference has one, and removed it the same afternoon because I use the console on a wide screen and the conversation should take the width it has. The token stays at `none` so it is one line to bring back.

## Still live on September 2: three receipt renderers became two, not one

The receipt DOM is shared between chat and the Receipts tab now. The second console has its own renderer and I did not touch it, so there are still two ways a receipt is drawn, and the panel and page injector still show their own subsets. The invariant I stated above holds for `turn_receipts` today. It does not yet hold for the other three records of a turn, and I have not run my own grep across all of them.

---

Source: [A console leaked one CSS class and hid 11 days of receipts](https://blog.vodou.ai/agent-console-shared-host-leak-unread-receipts/) by Chad Priest, from Building Vodou in Public.
