Memory: source-adapter provenance and the cross-adapter read filter¶
SQLiteMemoryStore (src/bernstein/core/memory/sqlite_store.py) is the persistent, tag-indexed store that backs Bernstein's cross-session memory. Every row records the content, type, tags, importance, and a timestamp; each row can also record the CLI adapter that produced it.
The provenance field and the low-level read filters (query, get_relevant, CrossTaskKB.subscribe) are opt-in: existing direct callers see no behaviour change unless they pass the new keywords. The spawned-agent prompt path is the one exception - see "Enforcement on the spawned-agent prompt path" below.
What changed¶
| Surface | Old | New |
|---|---|---|
| Schema | memory(... source_agent, source_model) | adds source_adapter TEXT |
SQLiteMemoryStore.add | unchanged | accepts source_adapter: str \| None = None |
SQLiteMemoryStore.add_many | n/a | bulk insert with per-row source_adapter |
SQLiteMemoryStore.query | n/a | yields rows; supports read_only_from_adapters allow-list |
SQLiteMemoryStore.get_relevant | unchanged | accepts read_only_from_adapters allow-list (same contract as query) |
MemoryEntry | source_agent, source_model | adds source_adapter: str \| None |
CrossTaskKB.publish | unchanged | forwards source_adapter to the store |
CrossTaskKB.subscribe | unchanged | accepts read_only_from_adapters |
bernstein.core.memory.trust_policy | n/a | MemoryTrustPolicy + active_trust_policy() - default-enforced provenance policy for spawned prompts |
spawner_core._load_persistent_memory | replayed every row unfiltered | applies MemoryTrustPolicy before injecting content into a prompt |
The migration is additive: _migrate_columns adds source_adapter only if absent. Existing rows backfill with NULL and continue to surface from list(), query(), and get_relevant() by default (no adapter filter passed = legacy behaviour, unchanged).
Threat model¶
Bernstein routes tasks across a heterogeneous set of adapters (claude-code, codex, gemini-cli, ...). A row written by adapter A is, by default, indistinguishable from a row written by adapter B once it lands in the shared SQLite store. A subscriber operating under adapter B will replay adapter A's payload verbatim.
That is the shape of a documented cross-adapter memory-poisoning attack class: a payload written by one adapter steers a later, unrelated adapter into following injected instructions or exfiltrating data. The orchestrator sits at the boundary where this isolation has to live; no single adapter can enforce it for the others.
Recording the producing adapter and offering an allow-list on read is the primitive that draws that boundary. On the low-level store API (query, get_relevant, CrossTaskKB.subscribe) that allow-list stays opt-in, matching the rest of the store's API surface. On the one call site that actually reaches a live, injectable prompt - spawner_core._load_persistent_memory - the boundary is enforced by default; see the next section.
Enforcement on the spawned-agent prompt path¶
_load_persistent_memory builds the "Persistent Memory" section of every spawned agent's prompt from SQLiteMemoryStore.get_relevant(), injecting MemoryEntry.content verbatim. Because that is a live, model-facing surface, it does not rely on a caller remembering to pass read_only_from_adapters=. Instead it runs every candidate row through bernstein.core.memory.trust_policy.MemoryTrustPolicy:
- Untagged rows (
source_adapter IS NULL) stay trusted by default. This covers pre-migration rows and today's operator/CLI writes (bernstein memory add,bernstein memory share), which do not yet tag provenance, so existing manual workflows are unaffected. - Rows tagged with an explicit
source_adapterare untrusted by default unless that adapter has been opted into the policy's allow-list. This is what stops adapter A's payload from steering adapter B's spawned prompt.
Configuration (process environment, read once per _load_persistent_memory call via active_trust_policy()):
| Env var | Effect |
|---|---|
BERNSTEIN_MEMORY_TRUST_POLICY | Set to 0/false/no/off to disable enforcement entirely (legacy replay-everything). Enabled by default. |
BERNSTEIN_MEMORY_TRUST_ADAPTERS | Comma-separated allow-list of extra source_adapter values to trust alongside untagged rows, e.g. claude-code,codex. |
Callers that build their own policy object (tests, or future orchestration code that knows the current adapter identity) can pass trust_policy=MemoryTrustPolicy(...) directly to _load_persistent_memory, bypassing the environment lookup.
Known residual gap¶
bernstein memory add and bernstein memory share do not currently accept or auto-derive a source_adapter value, so both a genuine operator and a spawned agent with shell access invoking that CLI produce an identical, untagged (trusted) row. Tagging those write paths with the invoking adapter's identity (where available) is tracked as follow-up work; until then, an adversarial agent with a shell tool can still add an untagged row through the CLI escape hatch rather than through SQLiteMemoryStore.add()/ CrossTaskKB.publish() with an explicit foreign source_adapter.
Usage¶
Write with provenance¶
from pathlib import Path
from bernstein.core.memory.sqlite_store import SQLiteMemoryStore
store = SQLiteMemoryStore(Path(".sdd/memory/memory.db"))
store.add(
type="learning",
content="HTTP 429 from the upstream API means back off, not retry-now.",
tags=["http", "retries"],
source_adapter="claude-code",
)
add_many accepts the same per-row keyword set and writes the batch in a single transaction:
store.add_many(
[
{"type": "learning", "content": "...", "source_adapter": "claude-code"},
{"type": "learning", "content": "...", "source_adapter": "codex"},
]
)
Read with an adapter allow-list¶
# Default: every row, including pre-migration NULL-provenance rows.
for entry in store.query():
...
# Strict allow-list: rows whose source_adapter is in the list, no NULLs.
for entry in store.query(read_only_from_adapters=["claude-code"]):
...
Passing an empty list (read_only_from_adapters=[]) is treated as "no adapter is allowed" and returns nothing. This is intentional: an explicit empty allow-list is a safe default for callers that want a fail-closed read.
Forwarding through CrossTaskKB¶
Adapters that already use the publish/subscribe facade pick up the feature without extra wiring:
from bernstein.core.memory.cross_task_kb import CrossTaskKB
kb = CrossTaskKB(store, run_id="r-1", producer_task_id="t-7")
kb.publish(
tag="api-schema",
key="users",
value="...",
scope="run",
source_adapter="claude-code",
)
for fact in kb.subscribe(
tag="api-schema",
scope="run",
read_only_from_adapters=["claude-code"],
):
...
Migration notes¶
- New databases pick up
source_adapteron first open. - Existing databases gain the column the next time
SQLiteMemoryStore.__init__runs. The migration is idempotent: re-opening the same database does not raise on the duplicateADD COLUMN. - Pre-migration rows persist with
source_adapter = NULLand are returned by defaultlist(),query(), andget_relevant()calls so existing tooling sees no read regression. - Rows with
NULLprovenance are excluded fromquery(read_only_from_adapters=...)andget_relevant(read_only_from_adapters=...)results because the filter is a strict allow-list, not a default-deny.MemoryTrustPolicy(used by_load_persistent_memory) is the layer that keeps untagged rows trusted by default instead - see "Enforcement on the spawned-agent prompt path" above.
Out of scope¶
- Tagging
bernstein memory add/bernstein memory sharewith the invoking adapter's identity - see "Known residual gap" above. - Lineage-style content-hash chains across rows.
cross_task_kb_metaalready carriescontent_hashper published fact. - Provenance on the JSONL log under
memory/jsonl_log.py. Add it separately if the SQLite primitive proves out.