Surfaced by
#317 / PR #366. REST /api/memory/add was fixed to resolve private:<agent> from X-hal0-Agent + X-hal0-Private headers and pass that dataset to the wrapper. The route is correct; the wrapper layer still collapses.
Repro (post #366)
# REST writes resolve to private:hermes-agent.
curl -X POST http://127.0.0.1:8080/api/memory/add \
-H 'X-hal0-Agent: hermes-agent' -H 'X-hal0-Private: 1' \
-H 'Content-Type: application/json' \
-d '{"text":"probe"}'
# But the actual Cognee dataset is still `shared` — `CogneeWrapper._effective_write_dataset`
# (src/hal0/memory/cognee_wrapper.py:869) collapses `private:*` to `shared` on a
# non-private wrapper. The singleton wrapper at app.state.memory_wrapper is created
# with client_id="anonymous", private_mode=False.
Root cause
CogneeWrapper was designed for one-instance-per-(client_id, private_mode) — see tests/memory/test_cognee_wrapper.py::test_private_writes_invisible_to_other_clients which constructs alice + bob wrappers. The wrapper guards against a non-private instance writing to private:<other> so a wrong-client wrapper can't smuggle data.
But production runs ONE wrapper (app.state.memory_wrapper, anonymous, non-private). The REST and MCP layers are the identity gate. The wrapper's collapse guard is now defending against itself.
Options (order: most-proper → blocker fix → spike)
a) Reshape CogneeWrapper so a single instance can serve multiple clients: take client_id + private per-call (audit log + scope filter both read from the per-call args, not the constructor). Touches every callsite + the audit log shape but is the truthful model.
b) Keep one wrapper but pass a trusted_caller=True kwarg to add / search / list_items from REST + MCP, signaling that the caller has already enforced identity. Wrapper-level collapse is skipped for trusted callers. Smaller diff, preserves the per-client wrapper code path for tests.
c) Stand up a per-request wrapper from a pool keyed by client_id. Simplest mental model but multiplies the Cognee-init cost.
Acceptance
- Smoke
curl POST /api/memory/add with private headers + curl POST /api/memory/search with same headers returns the just-written item.
hal0 agent bootstrap hermes memory_roundtrip smoke passes.
- Existing
tests/memory/ private-isolation contract (Alice can't see Bob's private) holds.
Tracked because
Closing the bootstrap smoke loop from #317 needs both halves. PR #366 ships the REST half so the route is no longer the bug, but the LXC smoke acceptance from #317 cannot succeed until this wrapper-side change lands too.
Surfaced by
#317 / PR #366. REST
/api/memory/addwas fixed to resolveprivate:<agent>fromX-hal0-Agent+X-hal0-Privateheaders and pass that dataset to the wrapper. The route is correct; the wrapper layer still collapses.Repro (post #366)
Root cause
CogneeWrapperwas designed for one-instance-per-(client_id, private_mode) — seetests/memory/test_cognee_wrapper.py::test_private_writes_invisible_to_other_clientswhich constructsalice+bobwrappers. The wrapper guards against a non-private instance writing toprivate:<other>so a wrong-client wrapper can't smuggle data.But production runs ONE wrapper (
app.state.memory_wrapper, anonymous, non-private). The REST and MCP layers are the identity gate. The wrapper's collapse guard is now defending against itself.Options (order: most-proper → blocker fix → spike)
a) Reshape
CogneeWrapperso a single instance can serve multiple clients: takeclient_id+privateper-call (audit log + scope filter both read from the per-call args, not the constructor). Touches every callsite + the audit log shape but is the truthful model.b) Keep one wrapper but pass a
trusted_caller=Truekwarg toadd/search/list_itemsfrom REST + MCP, signaling that the caller has already enforced identity. Wrapper-level collapse is skipped for trusted callers. Smaller diff, preserves the per-client wrapper code path for tests.c) Stand up a per-request wrapper from a pool keyed by
client_id. Simplest mental model but multiplies the Cognee-init cost.Acceptance
curl POST /api/memory/addwith private headers +curl POST /api/memory/searchwith same headers returns the just-written item.hal0 agent bootstrap hermesmemory_roundtripsmoke passes.tests/memory/private-isolation contract (Alice can't see Bob's private) holds.Tracked because
Closing the bootstrap smoke loop from #317 needs both halves. PR #366 ships the REST half so the route is no longer the bug, but the LXC smoke acceptance from #317 cannot succeed until this wrapper-side change lands too.