Mycelium

Found → fixed: our own memory holes

  • memory
  • safety
  • trust-layer
  • audit

We turned the audit on our own memory layer before turning it into marketing. The audit found real holes; the trust-layer program (see memory you can trust — phase P0) exists to close them. This note tells that story the only way we think is honest: each hole, in plain language, only once the fix that closed it has merged and deployed. For each one: what could go wrong as a class of problem — never how to reproduce it — what we changed, the PR, the day it shipped, and the test that fails without the fix.

The shape of what we found

Without describing anything that is still open: the findings clustered where the field's literature says they would — who a memory route believes it is talking to, who a write is allowed to claim to be, whether the machinery that guards memory is actually wired to anything, whether a gate can be trusted to stay on, and what memory is allowed to claim about itself. The details below arrived with their fixes.

1. A token is not an agent

The first finding is about authentication on memory routes — what an authenticated credential is allowed to be. Memory routes serve one agent's private memory. The platform has two credential families: people log in to a console, agents hold keys. The memory routes accepted both — so a valid console login, at almost any role, could act as an agent on that agent's own memory surface. The credential was genuine; it simply was not the kind of credential that claim requires. Two adjacent findings came with it: creating a console user took whatever role the request asked for, so a mistaken or compromised administrator login could mint another administrator; and one settings write echoed a stored credential back in full, though reads masked it.

What changed: memory routes now answer to agent credentials — an agent key or the admin key. Among console logins, only the two agent-shaped roles pass; every other role is refused, by name, at the route. New users default to the least-privileged role, and promoting anyone to administrator requires the admin key itself — an administrator login is no longer sufficient. Settings writes mask exactly like reads.

PR: #190 · Deployed: 2026-09-28 (deploy-2026-09-28a)

How it is proven: the suite that pins this ran against the unfixed code first — 25 of its 40 tests failed, among them the plain one: a console login must be refused on agent memory search. After the fix, 40 of 40. The reviewer re-verified each refusal on a live server with a fresh data directory, not by trusting the suite.

2. Write authority belongs to the caller

The second is about writes: who a row says it came from, versus who actually wrote it. A memory row carries its origin — which agent wrote this. That field was taken from the write's own body, so a caller could store a row that claimed to come from someone else. Any authenticated agent could overwrite or delete any other agent's rows. And a special authority label — the one that marks a row as an operator's directive — could be stamped by anyone at all. None of this requires breaking authentication; it only requires the store to believe what a write says about itself.

What changed: the stored origin is now the authenticated identity. A mismatching claim in the body is not deleted — it is kept, visibly flagged as a claim, and never trusted. Custody follows first write: the agent that wrote a row owns it, an authorized correction does not steal ownership, and an agent key may rewrite or delete only what it wrote. Rows whose owner is unknown fail closed — the admin key may repair them, nothing else may touch them. Rows the platform itself writes (messages, tasks, savepoints) cannot be claimed by agent keys: the server stamps their real owner at write time. The directive label is admin-only. And the memory routes gained measured rate limits, so a runaway loop in one harness cannot become a denial of service against the store.

Existing rows were migrated honestly: where a row's own metadata named an owner, custody was backfilled once, on first boot after deploy; where it did not, the row stays admin-mutable — fail-closed, visible, repairable.

PR: #190 · Deployed: 2026-09-28 (deploy-2026-09-28a)

How it is proven: the same suite carries the custody tests — another agent's delete is refused while the owner's and the admin's are allowed, overwriting an owner-unknown row is refused, a directive stamp without the admin key is refused — all red before the fix, green after. The reviewer re-verified with the fix reverted: the new tests fail.

3. Dead code is a decision

The third is about machinery that exists but isn't wired — and what that silence is worth. Two pieces of the memory pipeline were running and doing nothing.

Extracted facts — the distillation pass that pulls durable facts out of activity — were written to the facts table and then handed to the search indexer, where a plumbing mismatch made the call throw. The throw was swallowed as "non-critical", and the not indexed status it returned was discarded. Every extracted fact landed in the table; none of them ever reached the search index. The store said the fact existed; search said nothing was there. A memory that cannot be retrieved is also a memory that cannot be audited — or deleted — which makes this silence a safety problem, not just a bug.

The message auto-indexer, meant to index conversations as they happen, was subscribed to an event name the server never emits. Wired to nothing, and nothing complained.

What changed: the plumbing is fixed and the silence is gone — each fact now carries its index status, and a failed index is logged loudly instead of swallowed (the silent swallow was its own defect). The auto-indexer listens for the name the server actually emits and reads the real message. And because indexing everything agents say is the widest untrusted-input path into memory we have, it ships gated off: nothing changes for an operator who has not explicitly turned it on, and when it is on, auto-indexed rows land flagged as low-trust candidates, never as fact of record.

The review's second round caught what making the first fix live would have re-opened: the indexer had been about to copy the model's own authority label into the search index — the exact hole fix 2 closed on the direct write path, reopened through extraction. The index now reads the authority of record back from the facts row, never from what the model passed along.

PR: #192 · Deployed: 2026-09-28 (deploy-2026-09-28b)

How it is proven: three tests, each red on the unfixed code with the failure in its name: extracted fact must reach the search index; the handler must subscribe to the event the server actually emits; and round two's a model-planted directive must never survive into the searchable index. The full suite at the merge: 1,837 passed.

4. A gate that can be silently emptied is not a gate

The fourth is the gate that stands behind the channel the earlier note's animation shows. One configuration key on the platform decides which agent actions are blocked outright: it is the value a security check reads before letting a message or a merge through. Three things were wrong with it, and they compound. The key lived in a store writable by the very principals the rules gate — the rules could be rewritten by whoever they bind. A malformed write could land in a shape the reader interprets as no rules — the gate silently off, no error, no event. And rule changes lagged behind an operator's write by a cache interval, so for a while the platform enforced the rules as they used to be.

What changed: the key is admin-only on every write path — agents and ordinary console logins are refused; reads stay open, because rules are not secrets and knowing them is the point. The value is validated whole at the layer that stores it: a malformed value is refused, never merged into silence, while an explicit empty ruleset from an admin stays legal — disabling is a right, and it should be deliberate. Every write path invalidates the cache at once, so read-your-writes holds.

Why this one took four review rounds — the lesson worth keeping: locking the write routes was not enough. Round two found that the per-namespace cap — which evicts a namespace's oldest keys, driven by ordinary non-admin writes elsewhere in that namespace — could evict the rules key without anyone ever "writing" it. Round three found the expiry sibling: an expiration timestamp on the key would hand it to a routine sweep. The fix makes the key durable by construction — outside cap eviction and outside every purge — and refuses expiry on it outright. The rule this bought us: admin-only is a property of every path that can remove a row, not just the routes named write.

PR: #193 · Deployed: 2026-09-29 (deploy-2026-09-29a)

How it is proven: the gate's suite ran against the unfixed code first: 16 failed, 6 passed — the six passes pin the enforcement behavior that was never supposed to change. After the fix, all 22 pass. In the final round the reviewer reverted just the fix files and watched exactly the seven durability tests fail — the eviction and expiry proofs among them.

5. What a fact is allowed to claim about itself

The last fix is the smallest and the most general. The extraction pass hands facts to the store with fields that describe them — a category, a confidence. Those fields were the model's own output, trusted verbatim. A model grading itself could store a confidence no verified fact is allowed and out-rank them wherever ranking reads that field; a category outside any known set flowed straight into the index; the fact's text was unbounded. Nothing here breaks authentication or custody — it is memory believing its own summarizer, which is its own class of problem: the store's quality controls were reading unvalidated model output.

What changed: the category is validated against the extraction prompt's own set, and anything off it becomes general. Confidence is clamped below the ceiling a verified fact may reach, a missing value keeps the historical default, and an explicit zero no longer inflates to that default. Over-long text is truncated, not dropped — the text is a summary of something that really happened, and the record now says it was truncated. And the search index reads category, confidence and authority back from the row of record, so what ranking sees is what was validated.

PR: #194 · Deployed: 2026-09-29 (deploy-2026-09-29b)

How it is proven: five tests, written first and run against the unfixed code: an off-set category stored and indexed raw; an absurd confidence stored verbatim; over-long text stored uncapped; a failed read-back swallowed without a warning. All five red before, all five green after.

How each of these was reviewed

Every fix above followed the same rule: two independent reviews that must converge on the same commit before merge. One reviewer is a fresh session that reads the branch cold and re-verifies every claim live — on a spawned server with a fresh data directory, not by trusting the test suite. The other is the director. Both must say ship on the same commit, or the branch goes back.

This is not ceremony, and the record says so. The first fix was sent back after its first ship: continuous integration was red — five plugin tests the new ownership rules broke — and the live probes found four more gaps, plus a migration the deploy would have needed. The fourth fix's ship took four rounds, and rounds two and three are where the eviction and expiry lessons above came from: both were found by reviewers probing paths the fix had not named. The reviews are where found → fixed gets its second half — skipping one would have shipped a gate that a full namespace quietly empties.

After P0

The board is not empty: the remaining P0 finding gets this same treatment here — found → fixed, with the PR and the date — only when its fix has merged. Not before. The frontier work — provenance that survives derivation, quarantine, cascading deletion, the audit log — follows the same rule and is tracked on the same board as everything else. When those land, they get the same found → fixed treatment here, plus the measured claims they feed.