A memory system that only answers from its own store is a filing cabinet,
not a partner. The transactive rule the project is named for — not knowing
is fine when you know who does — needed a holder registry, a directory
query, and a referral that never leaks the text it may not read.
- schema v5 adds `holders` (person/agent/service, declared topics, contact).
A holder with memories exists whether or not anybody registered them; the
registry adds reachability, it does not grant existence.
- `who_knows` ranks holders by their own memories and is deliberately NOT
filtered by visibility: "she knows" is the answer. The answer is filtered.
- `ask` searches the asker's visible scope first (hop 0), then walks the
directory with a visited set and a hop budget. Three terminal states:
answered, referred, no_holder_knows. Nobody knowing is a result, not an error.
- Confidence is ordinal, not calibrated: relative strength, coverage, and
declared topics. Every hit carries the raw numbers and the basis lines that
produced the score, so a ranking can be argued with.
- Faces: HTTP (/api/holders, /api/who_knows, /api/ask), CLI (holders,
holder-set, holder-rm, who-knows, ask), MCP (who_knows, ask, list_holders).
Four bugs found by running the thing, not by reading it:
- FTS5 refuses bm25() inside MAX(); the aggregate uses FTS5's own `rank` column.
- A CJK question is longer than the sentence that answers it, so the whole-run
phrase matched nothing. The query now ORs adjacent bigram phrases; adjacency
is kept, equal length is not. Latin path untouched, baseline unaffected.
- The hop budget counted the asker as a hop, so max_hops=1 asked nobody.
- A second, stricter readability test in _answered() disagreed with the SQL
visibility clause and hid a memory the asker was allowed to read. The SQL
clause is the only authority now.
A method named `list` shadowed the builtin inside its own class and made
mypy reject the return annotation; renamed rather than cast around it.
Proof: 155 pytest, 12 Go tests, ruff and mypy clean on touched files, pyright
ledger unchanged at 62, and scripts/mcp_write_smoke.sh drives two principals
on one SQLite file — the husband asks through MCP, gets `referred` with his
wife's contact channel, and her private sentence appears nowhere in the reply.
A cartridge used to be an export: materialize it, hash it, and never let it
change behaviour again. The transactive-memory promise needs the opposite —
mounting the home cartridge must make the work cartridge's memories
unfindable, and appending to a cartridge must make a new memory findable
through it.
Cartridge scope (schema v2 -> v3):
- cartridge_atoms (which atoms are in which cartridge) and cartridge_mounts
(which cartridges this machine has mounted now); both rebuilt on reopen,
because a mount is a machine-local state, not a fact about the data.
- mount / switch / unmount / append refuse a cartridge that was built for a
different runtime, naming every differing field instead of a boolean.
- append rewrites atoms.parquet and sparse_index.bm25.zst, recomputes DNA and
the KV fingerprint, and keeps the cartridges row in one SQLite transaction;
the files are written first so a failed export cannot leave a half-cartridge
in the database.
- RetrievalService.search() now ranks inside the mounted scope by default, and
an explicit cartridge_ids filter overrides the mount. Nothing mounted means
the whole library, so an empty store behaves as it did before.
- forget() removes the forgotten atoms from every cartridge's membership:
forgetting must not leave a memory retrievable through a cartridge.
KV fingerprint (kv_fingerprint.py, stored in the manifest):
- model / tokenizer / chat-template hashes, the RoPE block (factor,
original_max_position_embeddings, mrope_interleaved, mrope_section,
partial_rotary_factor), the adapter hash, the context length, the engine
version, and the fingerprint format version.
- A snapshot we cannot read is recorded as unverifiable and listed in
unverifiable_fields; a blind machine refuses to mount rather than pretending
the runtime matches, because a wrong RoPE factor silently corrupts retrieval.
Two defects found by running the new tests, not by reading:
- atoms.parquet could not export a memory at all: pyarrow refuses a struct with
no child field, so metadata={} failed the whole table. The six JSON columns
are now written as JSON text and decoded by the cartridge reader.
- materialize() inserted cartridge membership before the cartridges row existed,
so the foreign key rejected every cartridge.
api.py crossed 500 lines, so the cartridge and mount routes moved to
api_cartridges.py. That module must not use `from __future__ import
annotations`: FastAPI then resolves Depends(settings_dep) against module
globals, where the injected dependency is only a parameter of register(), and
every route starts demanding settings as a query parameter.
Verification: 138 pytest passed (26 new: 10 fingerprint, 16 scope), Go
build/vet/gofmt/test clean, and scripts/mcp_write_smoke.sh now proves the whole
loop across two processes - mount narrows search, a memory written after the
upload is invisible until append_to_cartridge, unmount restores the library,
and rewriting the RoPE factor makes mount_cartridge refuse with "rope: factor:
cartridge 4.0 vs local 2.0". ruff clean on every touched file (the 20 remaining
package errors are in files this change did not touch; HEAD had 25). mypy: one
error, the pre-existing np.savez stub artifact at cartridge_assets.py:329
(HEAD:320). Repo pyright: 62 errors, unchanged from the p0-3 ledger.
remember / supersede / forget now reach the one Python writer from a
terminal, from MCP, and from HTTP. The server, not the caller, decides
who owns a memory.
- cli: remember, supersede, forget. --holder falls back to
settings.default_holder until the holder registry lands. Timestamps are
parsed once with one clear message.
- ingestion: import pymupdf as fitz. The deprecated fitz import printed a
warning to stdout, so every CLI command's JSON answer was unparseable.
- mcp: remember, supersede_memory and forget_memory forward to the Python
writer over HTTP (ELY_EYE_API, default http://127.0.0.1:8765). SQLite
stays read-only to the Go side, so the write rules live in one place.
- mcp reads follow schema v2: atoms.time became ingested_at, and reads go
through v_atoms_current, so a superseded memory stops being findable.
holder, origin, scenario and event_time now show up in fetch_atom and
search_atoms.
- scripts/mcp_write_smoke.sh proves the loop end to end: remember, search,
supersede, forget, then not found.
Evidence: 92 pytest passed; go build, go vet and go test clean; mypy clean
on the 10 touched source files with a fresh cache; ruff clean except a
pre-existing B008 at cli.py:292, identical at HEAD.
Local read-only Model Context Protocol server (Go) exposing the .ely_eye memory
store to coding agents such as Codex and Claude Code: nine tools over status,
cartridges, Evidence Atoms, compiled evidence packs, proof suites, and HashHop
proofs. One-line cross-platform install (no Docker), with a prebuilt-binary
release workflow.