amu-mem0 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python: ["3.10", "3.11", "3.12", "3.13"]
18
+ env:
19
+ MEM0_TELEMETRY: "False"
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: astral-sh/setup-uv@v6
23
+ - run: uv sync --python ${{ matrix.python }}
24
+ - run: uv run ruff check .
25
+ - run: uv run ruff format --check .
26
+ - run: uv run pytest -q
@@ -0,0 +1,41 @@
1
+ name: Publish
2
+
3
+ # Trusted publishing (OIDC): no API token is stored in this repo.
4
+ # One-time setup on each index: add a pending trusted publisher for
5
+ # repo sangaraju1988/amu-mem0, workflow publish.yml, and the environment
6
+ # named below (testpypi on test.pypi.org, pypi on pypi.org).
7
+ #
8
+ # TestPyPI: run manually (Actions > Publish > Run workflow).
9
+ # PyPI: runs only when a GitHub Release is published.
10
+
11
+ on:
12
+ workflow_dispatch:
13
+ release:
14
+ types: [published]
15
+
16
+ jobs:
17
+ testpypi:
18
+ if: github.event_name == 'workflow_dispatch'
19
+ runs-on: ubuntu-latest
20
+ environment: testpypi
21
+ permissions:
22
+ id-token: write
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: astral-sh/setup-uv@v6
26
+ - run: uv build
27
+ - uses: pypa/gh-action-pypi-publish@release/v1
28
+ with:
29
+ repository-url: https://test.pypi.org/legacy/
30
+
31
+ pypi:
32
+ if: github.event_name == 'release'
33
+ runs-on: ubuntu-latest
34
+ environment: pypi
35
+ permissions:
36
+ id-token: write
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - uses: astral-sh/setup-uv@v6
40
+ - run: uv build
41
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .DS_Store
@@ -0,0 +1,36 @@
1
+ # Evidence log
2
+
3
+ All times UTC. Each entry links to a public, timestamped record.
4
+
5
+ ## Timeline
6
+
7
+ | When (UTC) | What | Link |
8
+ |---|---|---|
9
+ | 2026-11-15T04:03:59Z | Package and benchmark pushed (commit `1a3dae0`) | https://github.com/sangaraju1988/amu-mem0/commit/1a3dae0 |
10
+ | 2026-11-15T04:04:45Z | CI green on Python 3.10–3.13 | https://github.com/sangaraju1988/amu-mem0/actions/runs/43675135907 |
11
+ | 2026-11-15T04:53:37Z | Publish workflow pushed (commit `1a2d9ed`), CI green | https://github.com/sangaraju1988/amu-mem0/commit/1a2d9ed |
12
+ | 2026-11-15T04:54:56Z | Feature request opened on mem0 | https://github.com/mem0ai/mem0/issues/7927 |
13
+ | pending | TestPyPI upload | |
14
+ | pending | v0.1.0 tag and GitHub Release | |
15
+ | pending | PyPI release | https://pypi.org/project/amu-mem0/ |
16
+
17
+ ## Benchmark
18
+
19
+ From `results/BENCH.md`, generated by `make bench` at commit `8783e3b` (synthetic multi-team setup, deterministic embedder, 420 memories; not production estimates):
20
+
21
+ | Condition | Leaked / returned | Queries with a leak | Recall@10 (entitled) |
22
+ |---|---|---|---|
23
+ | Plain mem0 | 857 / 2400 | 71.2% | 0.650 |
24
+ | Content-only filter (own labels) | 563 / 2400 (all derived) | 62.9% | 0.816 |
25
+ | amu-mem0 gate | 0 / 2164 | 0.0% | 0.999 |
26
+
27
+ Lineage coverage sweep (gate): queries with a leak at 100% / 90% / 75% / 50% of derivation edges recorded = 0.0% / 11.7% / 22.9% / 35.4%.
28
+
29
+ ## To capture as PDF later
30
+
31
+ - [ ] mem0ai/mem0#7927 issue page, including any maintainer comments and labels
32
+ - [ ] Any PR page that follows, with reviewers (only if a maintainer asks for one)
33
+ - [ ] The merge commit, if anything is merged upstream
34
+ - [ ] GitHub Release v0.1.0 notes
35
+ - [ ] PyPI project page for amu-mem0 0.1.0
36
+ - [ ] This repo's CI run for the release commit
amu_mem0-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Venkata Sangaraju
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ .PHONY: install test lint format bench
2
+
3
+ install:
4
+ uv sync
5
+
6
+ test:
7
+ MEM0_TELEMETRY=False uv run pytest -q
8
+
9
+ lint:
10
+ uv run ruff check .
11
+ uv run ruff format --check .
12
+
13
+ format:
14
+ uv run ruff check --fix .
15
+ uv run ruff format .
16
+
17
+ bench:
18
+ MEM0_TELEMETRY=False uv run python bench/run_bench.py
@@ -0,0 +1,260 @@
1
+ Metadata-Version: 2.5
2
+ Name: amu-mem0
3
+ Version: 0.1.0
4
+ Summary: Derivation-gated reads for mem0: a memory is returned only if the requester is entitled to every source it was derived from.
5
+ Project-URL: Paper, https://doi.org/10.1109/ACCESS.2026.3730363
6
+ Project-URL: amu-governance, https://github.com/sangaraju1988/amu-governance
7
+ Author: Venkata Sangaraju
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: access-control,agent-memory,data-lineage,mem0,privacy
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: amu-governance<0.2,>=0.1.2
22
+ Requires-Dist: mem0ai<3,>=2.2
23
+ Description-Content-Type: text/markdown
24
+
25
+ # amu-mem0
26
+
27
+ Derivation-gated reads for [mem0](https://github.com/mem0ai/mem0). A memory is
28
+ returned only if the requester is entitled to every sensitive label anywhere in
29
+ its lineage, including the sources of the memories it was derived from.
30
+
31
+ mem0 scopes memories by identity (`user_id`, `agent_id`, `run_id`). That
32
+ answers *whose* memory it is, not *what it was derived from*. When several
33
+ teams share one agent's memory, a summary written from restricted records looks
34
+ like any other memory and is returned to anyone who can search that scope.
35
+ `amu-mem0` records where each memory came from and checks it on every read.
36
+
37
+ The check is the rule from Sangaraju & Vissa, *Lineage-Aware Memory Governance*,
38
+ IEEE Access 14 (2026), [doi:10.1109/ACCESS.2026.3730363](https://doi.org/10.1109/ACCESS.2026.3730363):
39
+ serve a stored result only if `S(a) ⊆ P(d)`, where `S(a)` is the set of sensitive labels in
40
+ its derivation and `P(d)` is the set of labels the requester holds. The decision is made by
41
+ [`amu-governance`](https://github.com/sangaraju1988/amu-governance), not reimplemented here.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install amu-mem0 # not yet published; for now: pip install -e .
47
+ ```
48
+
49
+ Requires Python 3.10+, `mem0ai>=2.2,<3`, `amu-governance>=0.1.2,<0.2`. No other
50
+ dependencies.
51
+
52
+ ## Quickstart
53
+
54
+ ```python
55
+ from mem0 import Memory
56
+ from amu_mem0 import GatedMemory
57
+
58
+ memory = GatedMemory(Memory()) # wrap your existing instance
59
+
60
+ payroll = memory.add(
61
+ "Q3 payroll total is 4.2M",
62
+ agent_id="analyst",
63
+ sources={"hris": ["hr.salary"]}, # raw source -> its labels
64
+ )["results"][0]["id"]
65
+ memory.add("Payroll cost grew 8% QoQ", agent_id="analyst", derived_from=[payroll])
66
+
67
+ memory.search("payroll", filters={"agent_id": "analyst"}, entitlements={"mkt.campaign"})
68
+ # {'results': [], 'amu_denied': [{'id': ..., 'reason': 'unentitled', 'unentitled_labels': ['hr.salary']}, ...]}
69
+
70
+ memory.search("payroll", filters={"agent_id": "analyst"}, entitlements={"hr.salary"})
71
+ # both memories
72
+ ```
73
+
74
+ `examples/quickstart.py` is the same code running fully offline. It prints:
75
+
76
+ ```
77
+ marketing sees: []
78
+ marketing denied: [('unentitled', ['hr.salary']), ('unentitled', ['hr.salary'])]
79
+ finance sees: ['Payroll cost grew 8% QoQ', 'Q3 payroll total is 4.2M']
80
+ ```
81
+
82
+ ## How it works
83
+
84
+ - **Writes.** `add(..., sources=..., derived_from=...)` stores lineage under the
85
+ reserved metadata key `amu`. Here `sources` maps each raw source to its labels,
86
+ and `derived_from` lists the ids of mem0 memories the new one was written from.
87
+ The transitive closure (`closed`) is computed once, at write time, from the
88
+ parents' stored closures. A summary of a summary of a summary therefore carries
89
+ every label beneath it, and a memory with mixed sources carries the union.
90
+ - **Reads.** `search`, `get` and `get_all` require `entitlements=` and keep a
91
+ result only if every sensitive label in its closed lineage is in that set.
92
+ `search` and `get_all` return mem0's normal shape plus `amu_denied`, which gives
93
+ the id, a reason code and the unentitled labels for every removed candidate.
94
+ Denials are also logged on the `amu_mem0` logger, with ids and label names only,
95
+ never memory text.
96
+ - **Labels.** By default every label gates a read. Pass
97
+ `GatedMemory(memory, sensitive_labels={...})` to gate only on a registry
98
+ (the paper's semantics). Labels outside the registry are then ignored.
99
+ - **Entitlement changes** take effect on the next read. Verdicts are never
100
+ stored; only labels are.
101
+ - **Recall.** Filtering after retrieval would starve `top_k`, so the gate
102
+ over-fetches. It doubles the fetch until it has `top_k` allowed results, the
103
+ fetch reaches `max_fetch` (default 1000), or a larger fetch adds no new
104
+ candidates.
105
+
106
+ ### Fail-closed defaults
107
+
108
+ | Situation | Default | Reason code |
109
+ |---|---|---|
110
+ | Memory has no `amu` metadata (e.g. written with plain mem0) | denied | `missing_lineage` |
111
+ | `add()` with neither `sources` nor `derived_from` | stored, then denied | `incomplete_lineage` |
112
+ | A `derived_from` id is missing, or the parent has no or incomplete lineage | stored, then denied | `incomplete_lineage` |
113
+ | `amu` metadata is malformed | denied | `malformed_lineage` |
114
+ | `add(infer=True)` | raises `InferNotAllowedError` | n/a |
115
+
116
+ `on_missing_lineage="allow"` or `"allow_with_warning"` relaxes the first four
117
+ rows. A label already known to be forbidden still denies. `sources={}`
118
+ explicitly declares "no restricted inputs".
119
+
120
+ `infer=True` is rejected because mem0's extraction step shows the LLM up to 10
121
+ existing memories from the same scope. The facts it writes can draw on any of
122
+ them, and the caller cannot declare which. With `GatedMemory(allow_infer=True)`
123
+ such memories are stored but marked incomplete, so they stay hidden under the
124
+ default policy.
125
+
126
+ ## Benchmark
127
+
128
+ > **Synthetic multi-team setup, deterministic embedder, 420 memories.** These
129
+ > results describe this benchmark only. They are not estimates of leak rates in
130
+ > production.
131
+
132
+ `make bench` runs a deterministic benchmark: fixed seed, offline hashed
133
+ bag-of-words embedder, local Qdrant, no LLM. Two teams share one mem0 scope
134
+ containing 420 memories: 180 raw records and 240 agent-written summaries,
135
+ aggregates, and 2- and 3-hop summaries. It runs 240 queries at `top_k=10`.
136
+ Full results, definitions, versions and the commit hash are in
137
+ [`results/BENCH.md`](results/BENCH.md).
138
+
139
+ Run at commit `8783e3b`, 2026-11-15 (synthetic multi-team setup, deterministic
140
+ embedder, 420 memories):
141
+
142
+ | Condition | Leaked / returned | Leak rate | Queries with a leak | Recall@10 for entitled users | p50 / p95 search ms |
143
+ |---|---|---|---|---|---|
144
+ | Plain mem0 | 857 / 2400 | 35.7% | 71.2% | 0.650 | 2.05 / 2.18 |
145
+ | Content-only filter (each memory's own labels) | 563 / 2400 | 23.5% | 62.9% | 0.816 | 2.91 / 3.03 |
146
+ | amu-mem0 gate | 0 / 2164 | 0.0% | 0.0% | 0.999 | 3.07 / 12.57 |
147
+
148
+ - **Content-only filtering misses derived memories.** It removes raw restricted
149
+ records, but none of what was derived from them: all 563 of its leaks are
150
+ derived memories.
151
+ - **Recall@10, exactly.** For each query, mem0 is searched with `top_k=500` and
152
+ the results are kept, in mem0's order, only if the requester is truly entitled
153
+ to them. The first min(10, n) of those form the ideal set. A returned memory is
154
+ a hit if it is in that entitled list with a score, in the same `top_k=500`
155
+ call, at least that of the 10th ideal item, so ties at rank 10 are not
156
+ penalised. Recall@10 = min(hits, |ideal|) / |ideal|, averaged over all 240
157
+ queries (every query has at least one entitled memory).
158
+ - **Plain mem0's lower recall is displacement.** It always returns 10 results,
159
+ and restricted memories take slots entitled ones would otherwise fill. In 170
160
+ of the 171 queries where it misses entitled memories, every miss is matched by
161
+ a restricted memory in its top 10. The remainder reflects mem0's ranking
162
+ depending on `top_k`.
163
+ - **The denominators differ: 2,164 vs 2,400.** The gate returns fewer than 10
164
+ results in 34 queries (236 empty slots). In all 34 it returned every entitled
165
+ memory mem0 surfaces for that query at any depth, because fewer than 10
166
+ entitled memories pass mem0's own score threshold. Leak rates are per returned
167
+ item, so each uses its own denominator.
168
+ - **Latency is tail overhead from over-fetch, at small scale.** When most top
169
+ candidates are restricted, the gate issues several underlying searches. That
170
+ shows up in p95, not p50. These are in-process timings on 420 memories with
171
+ local Qdrant. Latency was not measured at production scale, with a remote
172
+ vector store, or with real embeddings.
173
+ - **Comparison with the paper.** The content-only leak rate falls inside the
174
+ paper's 18.8–25.5% range for naive memory. That is not a replication: the
175
+ workload, store and leak definition all differ.
176
+
177
+ **Lineage coverage sweep (gate only; synthetic multi-team setup, deterministic
178
+ embedder, 420 memories).** Each `derived_from` edge is recorded with
179
+ probability c, and dropped edges are silent.
180
+
181
+ | Derivation edges recorded | Queries with a leak | Leak rate | Recall@10 |
182
+ |---|---|---|---|
183
+ | 100% | 0.0% | 0.0% | 0.999 |
184
+ | 90.2% | 11.7% | 1.4% | 0.995 |
185
+ | 75.9% | 22.9% | 4.8% | 0.983 |
186
+ | 49.9% | 35.4% | 11.2% | 0.949 |
187
+
188
+ The paper recommends at least 90% lineage completeness, based on 0% measured
189
+ leakage at 75–90% on its workloads. This workload is different: it drops whole
190
+ derivation edges in a multi-hop graph, where the paper dropped columns within a
191
+ flat result. Here some leakage remains at every coverage level below 100%.
192
+
193
+ ## Threat model
194
+
195
+ **In scope.** A requester using the same mem0 scope as other teams receives a
196
+ memory derived, directly or through other memories, from a source whose label
197
+ they do not hold. Callers are trusted to pass the requester's real
198
+ entitlements and to declare lineage truthfully.
199
+
200
+ **Out of scope.**
201
+ - Calls made on the wrapped `Memory` directly (`gated.memory.search(...)`,
202
+ `history()`, `update()`). These bypass the gate.
203
+ - Anyone who can write mem0 metadata or the vector store directly. They can
204
+ forge or strip `amu` lineage. The gate is an application-level filter, not
205
+ storage-level enforcement. (For storage-level enforcement on Postgres, see
206
+ [amu-pgvector](https://github.com/sangaraju1988/amu-pgvector).)
207
+ - Content that encodes restricted information without naming its source
208
+ (inference through undeclared derived features, threat T4 in the paper), and
209
+ aggregation across many permitted reads (T6).
210
+ - mem0's SQLite history and message log, entity store and graph memory.
211
+
212
+ ## Limitations
213
+
214
+ - **Zero leakage depends on lineage coverage.** The guarantee holds only if
215
+ every derivation is declared. An undeclared edge is invisible to the gate.
216
+ In the synthetic benchmark, with 90% of derivation edges recorded, 11.7% of
217
+ queries still returned at least one leaked memory. The paper recommends at
218
+ least 90% lineage completeness, and notes that deeper derivations raise the
219
+ requirement. Multi-hop agent memory is exactly that case. See the coverage
220
+ sweep in `results/BENCH.md`.
221
+ - **Derived-feature inference remains open.** A memory whose text paraphrases
222
+ restricted data without a declared source is not caught.
223
+ - **`infer=True` is unsupported** for gated data (see above).
224
+ - **Only the sync OSS `Memory` is supported.** `AsyncMemory` and the hosted
225
+ `MemoryClient` are not wrapped or tested.
226
+ - **`update()` is not gated or re-closed.** A child's closure is computed when
227
+ the child is written. Changing a parent's lineage later does not update its
228
+ children. Deleting a parent leaves children with their recorded closure.
229
+ - **Search cost grows with filtering.** When most top candidates are denied,
230
+ the gate issues several underlying searches. This was measured only as
231
+ small-scale tail overhead (p95 in the benchmark), not at production scale.
232
+ - Tested against mem0ai 2.2.1 with local Qdrant. Other vector stores are
233
+ expected to work because only metadata round-tripping is required, but they
234
+ are untested.
235
+
236
+ ## Upstream discussion
237
+
238
+ The behaviour this package addresses is described for mem0's maintainers in
239
+ [mem0ai/mem0#7927](https://github.com/mem0ai/mem0/issues/7927). The issue
240
+ proposes no change to mem0's core; it asks whether a docs link would be welcome.
241
+
242
+ ## Development
243
+
244
+ ```bash
245
+ make install # uv sync
246
+ make test # pytest, against a real offline mem0 Memory (nothing mocked)
247
+ make lint
248
+ make bench
249
+ ```
250
+
251
+ ## Citation
252
+
253
+ If you use this, please cite the paper:
254
+
255
+ > V. Sangaraju and S. Vissa, "Lineage-Aware Memory Governance: A Derivation-Gated
256
+ > Framework for Privacy-Preserving Column-Level Access Control in Enterprise AI
257
+ > Agents," *IEEE Access*, vol. 14, pp. 139683–139693, 2026,
258
+ > doi:10.1109/ACCESS.2026.3730363.
259
+
260
+ MIT licensed. Not affiliated with or endorsed by Mem0.
@@ -0,0 +1,236 @@
1
+ # amu-mem0
2
+
3
+ Derivation-gated reads for [mem0](https://github.com/mem0ai/mem0). A memory is
4
+ returned only if the requester is entitled to every sensitive label anywhere in
5
+ its lineage, including the sources of the memories it was derived from.
6
+
7
+ mem0 scopes memories by identity (`user_id`, `agent_id`, `run_id`). That
8
+ answers *whose* memory it is, not *what it was derived from*. When several
9
+ teams share one agent's memory, a summary written from restricted records looks
10
+ like any other memory and is returned to anyone who can search that scope.
11
+ `amu-mem0` records where each memory came from and checks it on every read.
12
+
13
+ The check is the rule from Sangaraju & Vissa, *Lineage-Aware Memory Governance*,
14
+ IEEE Access 14 (2026), [doi:10.1109/ACCESS.2026.3730363](https://doi.org/10.1109/ACCESS.2026.3730363):
15
+ serve a stored result only if `S(a) ⊆ P(d)`, where `S(a)` is the set of sensitive labels in
16
+ its derivation and `P(d)` is the set of labels the requester holds. The decision is made by
17
+ [`amu-governance`](https://github.com/sangaraju1988/amu-governance), not reimplemented here.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install amu-mem0 # not yet published; for now: pip install -e .
23
+ ```
24
+
25
+ Requires Python 3.10+, `mem0ai>=2.2,<3`, `amu-governance>=0.1.2,<0.2`. No other
26
+ dependencies.
27
+
28
+ ## Quickstart
29
+
30
+ ```python
31
+ from mem0 import Memory
32
+ from amu_mem0 import GatedMemory
33
+
34
+ memory = GatedMemory(Memory()) # wrap your existing instance
35
+
36
+ payroll = memory.add(
37
+ "Q3 payroll total is 4.2M",
38
+ agent_id="analyst",
39
+ sources={"hris": ["hr.salary"]}, # raw source -> its labels
40
+ )["results"][0]["id"]
41
+ memory.add("Payroll cost grew 8% QoQ", agent_id="analyst", derived_from=[payroll])
42
+
43
+ memory.search("payroll", filters={"agent_id": "analyst"}, entitlements={"mkt.campaign"})
44
+ # {'results': [], 'amu_denied': [{'id': ..., 'reason': 'unentitled', 'unentitled_labels': ['hr.salary']}, ...]}
45
+
46
+ memory.search("payroll", filters={"agent_id": "analyst"}, entitlements={"hr.salary"})
47
+ # both memories
48
+ ```
49
+
50
+ `examples/quickstart.py` is the same code running fully offline. It prints:
51
+
52
+ ```
53
+ marketing sees: []
54
+ marketing denied: [('unentitled', ['hr.salary']), ('unentitled', ['hr.salary'])]
55
+ finance sees: ['Payroll cost grew 8% QoQ', 'Q3 payroll total is 4.2M']
56
+ ```
57
+
58
+ ## How it works
59
+
60
+ - **Writes.** `add(..., sources=..., derived_from=...)` stores lineage under the
61
+ reserved metadata key `amu`. Here `sources` maps each raw source to its labels,
62
+ and `derived_from` lists the ids of mem0 memories the new one was written from.
63
+ The transitive closure (`closed`) is computed once, at write time, from the
64
+ parents' stored closures. A summary of a summary of a summary therefore carries
65
+ every label beneath it, and a memory with mixed sources carries the union.
66
+ - **Reads.** `search`, `get` and `get_all` require `entitlements=` and keep a
67
+ result only if every sensitive label in its closed lineage is in that set.
68
+ `search` and `get_all` return mem0's normal shape plus `amu_denied`, which gives
69
+ the id, a reason code and the unentitled labels for every removed candidate.
70
+ Denials are also logged on the `amu_mem0` logger, with ids and label names only,
71
+ never memory text.
72
+ - **Labels.** By default every label gates a read. Pass
73
+ `GatedMemory(memory, sensitive_labels={...})` to gate only on a registry
74
+ (the paper's semantics). Labels outside the registry are then ignored.
75
+ - **Entitlement changes** take effect on the next read. Verdicts are never
76
+ stored; only labels are.
77
+ - **Recall.** Filtering after retrieval would starve `top_k`, so the gate
78
+ over-fetches. It doubles the fetch until it has `top_k` allowed results, the
79
+ fetch reaches `max_fetch` (default 1000), or a larger fetch adds no new
80
+ candidates.
81
+
82
+ ### Fail-closed defaults
83
+
84
+ | Situation | Default | Reason code |
85
+ |---|---|---|
86
+ | Memory has no `amu` metadata (e.g. written with plain mem0) | denied | `missing_lineage` |
87
+ | `add()` with neither `sources` nor `derived_from` | stored, then denied | `incomplete_lineage` |
88
+ | A `derived_from` id is missing, or the parent has no or incomplete lineage | stored, then denied | `incomplete_lineage` |
89
+ | `amu` metadata is malformed | denied | `malformed_lineage` |
90
+ | `add(infer=True)` | raises `InferNotAllowedError` | n/a |
91
+
92
+ `on_missing_lineage="allow"` or `"allow_with_warning"` relaxes the first four
93
+ rows. A label already known to be forbidden still denies. `sources={}`
94
+ explicitly declares "no restricted inputs".
95
+
96
+ `infer=True` is rejected because mem0's extraction step shows the LLM up to 10
97
+ existing memories from the same scope. The facts it writes can draw on any of
98
+ them, and the caller cannot declare which. With `GatedMemory(allow_infer=True)`
99
+ such memories are stored but marked incomplete, so they stay hidden under the
100
+ default policy.
101
+
102
+ ## Benchmark
103
+
104
+ > **Synthetic multi-team setup, deterministic embedder, 420 memories.** These
105
+ > results describe this benchmark only. They are not estimates of leak rates in
106
+ > production.
107
+
108
+ `make bench` runs a deterministic benchmark: fixed seed, offline hashed
109
+ bag-of-words embedder, local Qdrant, no LLM. Two teams share one mem0 scope
110
+ containing 420 memories: 180 raw records and 240 agent-written summaries,
111
+ aggregates, and 2- and 3-hop summaries. It runs 240 queries at `top_k=10`.
112
+ Full results, definitions, versions and the commit hash are in
113
+ [`results/BENCH.md`](results/BENCH.md).
114
+
115
+ Run at commit `8783e3b`, 2026-11-15 (synthetic multi-team setup, deterministic
116
+ embedder, 420 memories):
117
+
118
+ | Condition | Leaked / returned | Leak rate | Queries with a leak | Recall@10 for entitled users | p50 / p95 search ms |
119
+ |---|---|---|---|---|---|
120
+ | Plain mem0 | 857 / 2400 | 35.7% | 71.2% | 0.650 | 2.05 / 2.18 |
121
+ | Content-only filter (each memory's own labels) | 563 / 2400 | 23.5% | 62.9% | 0.816 | 2.91 / 3.03 |
122
+ | amu-mem0 gate | 0 / 2164 | 0.0% | 0.0% | 0.999 | 3.07 / 12.57 |
123
+
124
+ - **Content-only filtering misses derived memories.** It removes raw restricted
125
+ records, but none of what was derived from them: all 563 of its leaks are
126
+ derived memories.
127
+ - **Recall@10, exactly.** For each query, mem0 is searched with `top_k=500` and
128
+ the results are kept, in mem0's order, only if the requester is truly entitled
129
+ to them. The first min(10, n) of those form the ideal set. A returned memory is
130
+ a hit if it is in that entitled list with a score, in the same `top_k=500`
131
+ call, at least that of the 10th ideal item, so ties at rank 10 are not
132
+ penalised. Recall@10 = min(hits, |ideal|) / |ideal|, averaged over all 240
133
+ queries (every query has at least one entitled memory).
134
+ - **Plain mem0's lower recall is displacement.** It always returns 10 results,
135
+ and restricted memories take slots entitled ones would otherwise fill. In 170
136
+ of the 171 queries where it misses entitled memories, every miss is matched by
137
+ a restricted memory in its top 10. The remainder reflects mem0's ranking
138
+ depending on `top_k`.
139
+ - **The denominators differ: 2,164 vs 2,400.** The gate returns fewer than 10
140
+ results in 34 queries (236 empty slots). In all 34 it returned every entitled
141
+ memory mem0 surfaces for that query at any depth, because fewer than 10
142
+ entitled memories pass mem0's own score threshold. Leak rates are per returned
143
+ item, so each uses its own denominator.
144
+ - **Latency is tail overhead from over-fetch, at small scale.** When most top
145
+ candidates are restricted, the gate issues several underlying searches. That
146
+ shows up in p95, not p50. These are in-process timings on 420 memories with
147
+ local Qdrant. Latency was not measured at production scale, with a remote
148
+ vector store, or with real embeddings.
149
+ - **Comparison with the paper.** The content-only leak rate falls inside the
150
+ paper's 18.8–25.5% range for naive memory. That is not a replication: the
151
+ workload, store and leak definition all differ.
152
+
153
+ **Lineage coverage sweep (gate only; synthetic multi-team setup, deterministic
154
+ embedder, 420 memories).** Each `derived_from` edge is recorded with
155
+ probability c, and dropped edges are silent.
156
+
157
+ | Derivation edges recorded | Queries with a leak | Leak rate | Recall@10 |
158
+ |---|---|---|---|
159
+ | 100% | 0.0% | 0.0% | 0.999 |
160
+ | 90.2% | 11.7% | 1.4% | 0.995 |
161
+ | 75.9% | 22.9% | 4.8% | 0.983 |
162
+ | 49.9% | 35.4% | 11.2% | 0.949 |
163
+
164
+ The paper recommends at least 90% lineage completeness, based on 0% measured
165
+ leakage at 75–90% on its workloads. This workload is different: it drops whole
166
+ derivation edges in a multi-hop graph, where the paper dropped columns within a
167
+ flat result. Here some leakage remains at every coverage level below 100%.
168
+
169
+ ## Threat model
170
+
171
+ **In scope.** A requester using the same mem0 scope as other teams receives a
172
+ memory derived, directly or through other memories, from a source whose label
173
+ they do not hold. Callers are trusted to pass the requester's real
174
+ entitlements and to declare lineage truthfully.
175
+
176
+ **Out of scope.**
177
+ - Calls made on the wrapped `Memory` directly (`gated.memory.search(...)`,
178
+ `history()`, `update()`). These bypass the gate.
179
+ - Anyone who can write mem0 metadata or the vector store directly. They can
180
+ forge or strip `amu` lineage. The gate is an application-level filter, not
181
+ storage-level enforcement. (For storage-level enforcement on Postgres, see
182
+ [amu-pgvector](https://github.com/sangaraju1988/amu-pgvector).)
183
+ - Content that encodes restricted information without naming its source
184
+ (inference through undeclared derived features, threat T4 in the paper), and
185
+ aggregation across many permitted reads (T6).
186
+ - mem0's SQLite history and message log, entity store and graph memory.
187
+
188
+ ## Limitations
189
+
190
+ - **Zero leakage depends on lineage coverage.** The guarantee holds only if
191
+ every derivation is declared. An undeclared edge is invisible to the gate.
192
+ In the synthetic benchmark, with 90% of derivation edges recorded, 11.7% of
193
+ queries still returned at least one leaked memory. The paper recommends at
194
+ least 90% lineage completeness, and notes that deeper derivations raise the
195
+ requirement. Multi-hop agent memory is exactly that case. See the coverage
196
+ sweep in `results/BENCH.md`.
197
+ - **Derived-feature inference remains open.** A memory whose text paraphrases
198
+ restricted data without a declared source is not caught.
199
+ - **`infer=True` is unsupported** for gated data (see above).
200
+ - **Only the sync OSS `Memory` is supported.** `AsyncMemory` and the hosted
201
+ `MemoryClient` are not wrapped or tested.
202
+ - **`update()` is not gated or re-closed.** A child's closure is computed when
203
+ the child is written. Changing a parent's lineage later does not update its
204
+ children. Deleting a parent leaves children with their recorded closure.
205
+ - **Search cost grows with filtering.** When most top candidates are denied,
206
+ the gate issues several underlying searches. This was measured only as
207
+ small-scale tail overhead (p95 in the benchmark), not at production scale.
208
+ - Tested against mem0ai 2.2.1 with local Qdrant. Other vector stores are
209
+ expected to work because only metadata round-tripping is required, but they
210
+ are untested.
211
+
212
+ ## Upstream discussion
213
+
214
+ The behaviour this package addresses is described for mem0's maintainers in
215
+ [mem0ai/mem0#7927](https://github.com/mem0ai/mem0/issues/7927). The issue
216
+ proposes no change to mem0's core; it asks whether a docs link would be welcome.
217
+
218
+ ## Development
219
+
220
+ ```bash
221
+ make install # uv sync
222
+ make test # pytest, against a real offline mem0 Memory (nothing mocked)
223
+ make lint
224
+ make bench
225
+ ```
226
+
227
+ ## Citation
228
+
229
+ If you use this, please cite the paper:
230
+
231
+ > V. Sangaraju and S. Vissa, "Lineage-Aware Memory Governance: A Derivation-Gated
232
+ > Framework for Privacy-Preserving Column-Level Access Control in Enterprise AI
233
+ > Agents," *IEEE Access*, vol. 14, pp. 139683–139693, 2026,
234
+ > doi:10.1109/ACCESS.2026.3730363.
235
+
236
+ MIT licensed. Not affiliated with or endorsed by Mem0.