Telling the caller what it withheld was the leak

Hungry Hippa

A local memory runtime whose retrieval reports each excluded item with a reason — and whose own test suite records the finding that, for an unauthorised caller, that report answered whether a memory about a topic exists at all.

Carries 2 of 7 rubric mechanisms. Most systems here carry none or one (41%), and a dash means the mechanism was not found at this commit — not that the system needed it. Each mark is one LLM reviewer's reading of the code at this commit rather than a run of it — known limits.

  • Tombstone
  • Trust state
  • Bi-temporal
  • Scope enforced
  • Mutation audit
  • Human review
  • Negative evals

1. Executive Summary

Hungry Hippa is "[o]ne memory. Any AI. Your machine." — MIT, Python, 15,028 lines, a local SQLite store with an MCP surface so several AI clients read and write the same memory, and an explicit promise that the database is not uploaded anywhere. The schema calls itself a "Living Cortex": episodes, evidence, a graph, beliefs, procedures, vectors, consolidation, forgetting and retrieval, under reversible migrations that each carry an up and a down.

For a project this size, the file list under tests/ is the surprise: test_existence_oracle.py, test_confused_deputy.py, test_injection_framing.py, test_trust_boundary.py, test_trust_token.py, test_quarantine_cli.py, test_supersession.py, test_provenance.py, test_file_permissions.py, test_resource_limits.py. Attack classes named as filenames.

And one of them records a finding the project made against itself, which is the most useful thing in this report. Recall returns, alongside its results, a list of what it excluded and why — an item key and a reason such as other-actor. That is exactly the disclosure this atlas praised in mnemonic a few reports ago: a retrieval that tells you what it held back. Here is what it also is, in the project's own words:

"Finding: recall as an untrusted caller returned excluded=[{"item": "belief:B-0002", "reason": "other-actor"}] for a topic that matched a protected memory, and [] for a topic that matched nothing. That difference answers 'does the operator hold a memory about X', and the row id leaks sequential identifiers. Graph entity names were returned unfiltered too."

The same feature is a courtesy to an authorised caller and an existence oracle to an unauthorised one. The difference is who is asking — which means the disclosure has to be conditioned on the answer to that question, and a system that returns one list to everybody has decided the question does not matter. Three separate leaks in one sentence: the presence, the sequential id, and the unfiltered entity names.

The read policy that followed states its rule rather than implying one.

"The operator and the runtime's own background work read everything; an untrusted caller reads only its own rows. Identity decides, never a label."

may_read(item, actor_id, *, include_quarantined, identity) returns (allowed, reason) for every row, ownership is is_owner(actor, identity) or is_system_identity(identity), and the actor_id a caller passes cannot by itself grant anything. That last clause is the producer test this atlas looks for and rarely finds: several systems read this week accept an actor string and record it as though it were an identity.

The status partition runs before ranking. episodes.status is active | archived | compressed | purged, and step six of the pipeline — described in the module docstring as the "actor/status policy partition (quarantine + sensitivity)" — sends anything not active to the excluded list with its status as the reason, before the ranker sees it. Quarantined rows return only when a caller passes include_quarantined. Relationship reads carry the same predicate, and the context compiler prefers active over superseded on top. Partitioning before ranking is the placement theurian argues for: a withheld row that reaches the ranker moves every number computed from the ranking.

And the explanation is built not to be a side channel. Scores are decomposable — recall(..., explain=True) returns the parts behind each item's score — and the docstring states the constraint: the explanation "never contains memory content, so it cannot leak quarantined or otherwise unauthorized text". A system that has just been bitten by an oracle in its exclusion list has gone and checked its explain path for the same shape.

What to weigh. The mutation log is enforced in code rather than by database triggers — the schema says so and gives the reason, richer context — so a new write path can omit it and the database will not notice; thirty-two call sites cover what exists today. Evidence kinds distinguish user_explicit from agent_inference and derived_pattern, but nothing found withholds on that distinction, so provenance labels rather than gates. importance and confidence both default to 0.5, so an unset value looks like a considered middle. And the tests are run_all()-style modules returning result lists rather than pytest functions, so a plain pytest run exercises less than the filenames promise.

2. Mental Model

A status decides before a score does.

An identity decides who may read; a label decides nothing.

An exclusion list is a kindness to the authorised and an oracle to everyone else.

An explanation must not carry what the policy withheld.

Diagram — the status and identity partition runs before ranking, and the exclusion list it produces was itself the leak the project found and wrote into a test
Diagram source
%% caption: the status and identity partition runs before ranking, and the exclusion list it produces was itself the leak the project found and wrote into a test
flowchart TB
    Q["recall(query, actor_id, identity)"] --> P1["intent analysis: entity lookup +<br/>keyword and vector search in parallel"]
    P1 --> P2["bounded graph traversal around<br/>matched entities"]
    P2 --> PART{"step 6: partition BEFORE ranking"}
    PART --> ST{"status == 'active'?"}
    ST -->|"no"| EXC["excluded: item + reason = the status"]
    ST -->|"yes"| POL{"policy.may_read(item, actor_id,<br/>include_quarantined, identity)"}
    POL -.->|"'The operator and the runtime's own<br/>background work read everything; an<br/>untrusted caller reads only its own rows.<br/>Identity decides, never a label.'"| RULE["is_owner(actor, identity)<br/>or is_system_identity(identity)"]
    POL -->|"denied"| EXC
    POL -->|"allowed"| RANK["rank: salience × recency ×<br/>relevance × confidence"]
    RANK --> COMP["context compiler: budget, dedupe,<br/>active-over-superseded"]
    COMP --> OUT["a minimal context block"]
    EXC -.->|"THE FINDING, in the project's own test:<br/>an untrusted caller got a reason for a topic<br/>that matched a protected memory and an empty<br/>list for one that matched nothing — 'that<br/>difference answers does the operator hold a<br/>memory about X', plus a sequential row id"| ORACLE["the disclosure was the leak"]
    EXPL["recall(explain=True) —<br/>decomposable scores"] -.->|"'never contains memory content, so it cannot<br/>leak quarantined or otherwise unauthorized text'"| SAFE["the same side channel, closed one layer up"]
    W["any mutation"] --> QUOTA["write_quota"] --> LOG[("mutation_log: ts · action ·<br/>target_kind · target_id · detail ·<br/>session_id — enforced in code, not by<br/>triggers, 'so the log carries rich context'")]

3. Architecture

File Role
schema.py The Living Cortex schema and reversible migrations
retrieval.py The pipeline, the partition, the explanation constraint
policy.py may_read, and identity over label
db.py log_mutation, and the write path
forgetting.py, semantic.py, procedural.py, graph.py Sweeps, beliefs, procedures, relationships
tests/ Attack classes as filenames

4. Essential Implementation Paths

tests/test_existence_oracle.py:1-10 — read this first; it is the report.

src/hungry_hippa/policy.py:126-138 — the rule, stated.

src/hungry_hippa/retrieval.py:1-16, :323-338 — partition before ranking, and an explanation that carries no content.

5. Memory Data Model

Episodes with outcome, importance, confidence, project, status and a reinforcement count; evidence with a kind spanning user_explicit, document, tool_result, visual_observation, audio_observation, external_source, agent_inference and derived_pattern; beliefs, procedures, relationships and vectors. Raw evidence rows are immutable, which is what makes supersession meaningful rather than cosmetic.

6. Retrieval Mechanics

Covered above. The one addition: the ranking is "transparent and heuristic … so a learned policy can replace it later through the same interface" — a stated intention to keep the seam, which is how a heuristic stays replaceable instead of becoming load-bearing by accident.

7. Write Mechanics

A quota table gates writes, and a quota breach is itself logged (write_quota_exceeded) rather than silently dropped. Every mutation writes a log row with an action, a target and a session.

8. Agent Integration

An MCP server over one local store, so several clients share a memory. That sharing is what makes the identity check load-bearing: the design's whole premise is several callers against one database.

9. Reliability, Safety, and Trust

The security work is the substance, and its best property is that the project found its own leak in a feature it had built for good reasons and wrote the finding down where the next reader will meet it.

10. Tests, Evals, and Benchmarks

Seventeen test modules and a 901-line evaluation harness. The modules use a run_all() convention returning result records rather than pytest functions, so the harness rather than pytest is the entry point. Nothing was installed or run for this reading.

11. For Your Own Build

Condition your exclusion disclosure on who is asking. Telling a caller what you withheld is right for an authorised one and an existence oracle for everyone else, and one list for both decides that the distinction does not matter.

Let identity decide, never a label. An actor_id a caller supplies is a claim; treating it as an identity is how the confused-deputy tests in this repository got their names.

Partition before you rank. A withheld row that reaches the ranker changes every number computed from the ranking, even when its content never appears.

Check your explain path for the leak you just fixed. A score decomposition that quotes content re-opens the channel the policy closed.

And write the finding into the test. The docstring here explains what went wrong, with the actual payload, where whoever touches that code next will read it.

12. Open Questions

Whether evidence kind ever gates. It distinguishes what a person said from what a model inferred and no read path was found that acts on it.

How much of the suite a plain pytest run covers. The modules expose run_all() rather than test functions.

Whether the entity names are filtered now. The finding names them as leaking; the fix path was not traced.

Appendix: File Index

Path What to read it for
tests/test_existence_oracle.py:1-10 A disclosure that was a leak, with the payload
src/hungry_hippa/policy.py:126-138 Identity decides, never a label
src/hungry_hippa/retrieval.py:1-16 A pipeline, and an explanation that carries no content
src/hungry_hippa/schema.py:1-32 Reversible migrations, immutable evidence, a mutation log

History

2026-09-16e9254e89… — first reading, at a commit dated 15 September 2026. Screened before opening, from a shallow clone: two files scanned, no auto-run surfaces, no build-time execution points, one unpinned surface and one dependency file inside the seven-day cooldown. Nothing was installed, built or run.