pycontextdb 0.4.3__tar.gz → 0.4.5__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.
Files changed (151) hide show
  1. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/CHANGELOG.md +21 -0
  2. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/PKG-INFO +2 -2
  3. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/README.md +1 -1
  4. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/__init__.py +1 -1
  5. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/client.py +5 -13
  6. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/dynamics/evolution.py +3 -2
  7. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/store/postgres_store.py +29 -1
  8. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/architecture.md +5 -2
  9. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/pyproject.toml +1 -1
  10. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/evals/test_trust_model.py +36 -0
  11. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_evolution.py +26 -1
  12. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_pg_sql.py +33 -1
  13. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/.github/CODEOWNERS +0 -0
  14. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/.github/copilot-instructions.md +0 -0
  15. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/.github/pull_request_template.md +0 -0
  16. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/.github/workflows/ci.yml +0 -0
  17. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/.github/workflows/publish.yml +0 -0
  18. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/.gitignore +0 -0
  19. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/.pre-commit-config.yaml +0 -0
  20. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/COMMERCIAL.md +0 -0
  21. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/CONTRIBUTING.md +0 -0
  22. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/LICENSE +0 -0
  23. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/NOTICE +0 -0
  24. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/OPEN_CORE.md +0 -0
  25. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/ai-tools/.cursorrules +0 -0
  26. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/ai-tools/CLAUDE.md +0 -0
  27. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/ai-tools/README.md +0 -0
  28. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/ai-tools/llms-full.txt +0 -0
  29. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/ai-tools/llms.txt +0 -0
  30. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/assets/README.md +0 -0
  31. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/assets/architecture.png +0 -0
  32. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/assets/benchmarks.png +0 -0
  33. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/assets/hero.png +0 -0
  34. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/assets/performance.png +0 -0
  35. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/assets/problem.png +0 -0
  36. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/assets/quickstart.png +0 -0
  37. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/benchmarks/__init__.py +0 -0
  38. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/benchmarks/run_benchmarks.py +0 -0
  39. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/benchmarks/trust_bakeoff.py +0 -0
  40. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/agents/__init__.py +0 -0
  41. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/agents/memory_bus.py +0 -0
  42. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/agents/rl_manager.py +0 -0
  43. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/cli.py +0 -0
  44. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/connectors/__init__.py +0 -0
  45. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/connectors/base.py +0 -0
  46. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/connectors/postgres.py +0 -0
  47. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/core/__init__.py +0 -0
  48. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/core/clock.py +0 -0
  49. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/core/config.py +0 -0
  50. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/core/exceptions.py +0 -0
  51. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/core/models.py +0 -0
  52. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/core/policy.py +0 -0
  53. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/core/slots.py +0 -0
  54. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/dynamics/__init__.py +0 -0
  55. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/dynamics/formation.py +0 -0
  56. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/dynamics/retrieval.py +0 -0
  57. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/dynamics/salience.py +0 -0
  58. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/dynamics/trust.py +0 -0
  59. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/graphs/__init__.py +0 -0
  60. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/graphs/base.py +0 -0
  61. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/graphs/causal.py +0 -0
  62. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/graphs/entity.py +0 -0
  63. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/graphs/semantic.py +0 -0
  64. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/graphs/temporal.py +0 -0
  65. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/integrations/__init__.py +0 -0
  66. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/integrations/act.py +0 -0
  67. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/integrations/autogen.py +0 -0
  68. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/integrations/crewai.py +0 -0
  69. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/integrations/langchain.py +0 -0
  70. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/integrations/livekit.py +0 -0
  71. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/integrations/openai_tools.py +0 -0
  72. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/integrations/pipecat.py +0 -0
  73. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/integrations/prompting.py +0 -0
  74. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/mcp.py +0 -0
  75. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/memory/__init__.py +0 -0
  76. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/memory/experiential.py +0 -0
  77. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/memory/factual.py +0 -0
  78. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/memory/working.py +0 -0
  79. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/pool.py +0 -0
  80. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/privacy/__init__.py +0 -0
  81. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/privacy/audit.py +0 -0
  82. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/privacy/injection.py +0 -0
  83. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/privacy/pii_detector.py +0 -0
  84. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/privacy/retention.py +0 -0
  85. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/py.typed +0 -0
  86. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/serve.py +0 -0
  87. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/store/__init__.py +0 -0
  88. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/store/base.py +0 -0
  89. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/store/factory.py +0 -0
  90. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/store/pg_sql.py +0 -0
  91. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/store/sqlite_store.py +0 -0
  92. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/store/vector_index.py +0 -0
  93. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/utils/__init__.py +0 -0
  94. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/utils/embeddings.py +0 -0
  95. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/utils/llm.py +0 -0
  96. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/contextdb/utils/migrations.py +0 -0
  97. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/api.md +0 -0
  98. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/connectors.md +0 -0
  99. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/index.md +0 -0
  100. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/memory_evolution.md +0 -0
  101. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/migrations.md +0 -0
  102. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/multi_tenant.md +0 -0
  103. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/postgres.md +0 -0
  104. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/prompts.md +0 -0
  105. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/quickstart.md +0 -0
  106. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/serve.md +0 -0
  107. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/docs/trust_policy.md +0 -0
  108. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/examples/customer_support.py +0 -0
  109. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/examples/multi_agent_team.py +0 -0
  110. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/examples/phone_agent.py +0 -0
  111. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/examples/research_assistant.py +0 -0
  112. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/legal/CLA-entity.md +0 -0
  113. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/legal/CLA-individual.md +0 -0
  114. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/legal/signed/README.md +0 -0
  115. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/mkdocs.yml +0 -0
  116. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/scripts/check_open_core_boundary.py +0 -0
  117. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/scripts/check_release.py +0 -0
  118. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/__init__.py +0 -0
  119. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/conftest.py +0 -0
  120. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/evals/__init__.py +0 -0
  121. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/integration/__init__.py +0 -0
  122. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/integration/test_client.py +0 -0
  123. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/integration/test_langchain_integration.py +0 -0
  124. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/pg_util.py +0 -0
  125. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/test_version.py +0 -0
  126. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/__init__.py +0 -0
  127. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_audit.py +0 -0
  128. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_config.py +0 -0
  129. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_connectors.py +0 -0
  130. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_decision_scope.py +0 -0
  131. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_embeddings.py +0 -0
  132. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_formation.py +0 -0
  133. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_graphs.py +0 -0
  134. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_host_api.py +0 -0
  135. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_init.py +0 -0
  136. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_memory_bus.py +0 -0
  137. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_memory_evolution.py +0 -0
  138. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_models.py +0 -0
  139. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_open_core_boundary.py +0 -0
  140. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_pii_detector.py +0 -0
  141. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_postgres_concurrency.py +0 -0
  142. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_postgres_connector.py +0 -0
  143. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_postgres_memory_evolution.py +0 -0
  144. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_retention.py +0 -0
  145. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_retrieval.py +0 -0
  146. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_scoped_id_access.py +0 -0
  147. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_serve.py +0 -0
  148. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_store.py +0 -0
  149. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_trust_policy.py +0 -0
  150. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_vector_index.py +0 -0
  151. {pycontextdb-0.4.3 → pycontextdb-0.4.5}/tests/unit/test_vector_scope.py +0 -0
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.5 — 2026-10-05
6
+
7
+ - `forget_user` no longer deletes other users' memories. It added every graph
8
+ neighbor of the erased memories to the deletion set, and entity edges join
9
+ any memories that mention the same name, so on a shared client with the
10
+ entity graph and an extraction model, erasing one user could hard-delete
11
+ another user's memories. It now deletes only the user's memories and the
12
+ summaries derived from them, and severs their graph edges.
13
+ - Consolidated summaries keep the weakest corroboration count of their inputs
14
+ instead of the sum. Repeated copies of one third-party claim could
15
+ consolidate into a summary that passed the default trust bar and was
16
+ returned by `recall_for_action`. The summary now stays pending confirmation.
17
+ - Unchanged limitation: entity rows are shared across users, and attributes
18
+ merged into an entity from a user's text are not removed by `forget_user`.
19
+
20
+ ## 0.4.4 — 2026-09-20
21
+
22
+ - PostgreSQL sibling composition now uses an ordered user-plus-entity cache
23
+ index, so a scoped recall does not scan other users that share the same
24
+ entity key.
25
+
5
26
  ## 0.4.3 — 2026-09-20
6
27
 
7
28
  - High-cardinality Postgres recall now indexes cached IDs by user and entity,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pycontextdb
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: The unified context layer for AI agents — memory you can act on without treating wishes as facts
5
5
  Project-URL: Homepage, https://github.com/atomsai/contextdb
6
6
  Project-URL: Documentation, https://contextdb.dev
@@ -120,7 +120,7 @@ Speed is table stakes. The production failure that gets agents fired is **acting
120
120
 
121
121
  Every memory carries **epistemic provenance** (`user_stated` / `agent_inferred` / `third_party`), **confidence**, **independent corroboration** (same-speaker repeats do not count), and an **action-relevant** flag. The action bar is data — `TrustPolicy.hospital()` vs `TrustPolicy.restaurant()` — not a hardcoded `if`. Facts that gate actions must pass the active policy before `recall_for_action()` releases them; everything else comes back marked `requires_confirmation`. `factual.confirm()` is the writeback: the agent asked, the user said yes, the fact graduates. Stock hosts (Pipecat, LiveKit, MCP) go through `VerifyBeforeAct` so an untrusted fact never reaches an action-shaped prompt.
122
122
 
123
- On top of that: **versioned slot vocabulary** + an LLM-free slotter (so `add_fast` and supersede agree), **extractor-as-security-boundary** (a prompt-injected `user/allergy` the raw text does not support is demoted), **contested slots** (independent speakers asserting different values do not last-write-win — both stay current, neither is actionable, `confirm()` resolves), **compositional hop** (`"when is the Denver meeting?"` surfaces `meeting/time` after hitting `meeting/location`), **PII-safe recall** (queries are redacted before embed so a raw email still retrieves `[EMAIL]`), **temporal supersede** with an injectable clock (`valid_from`/`valid_until`/`superseded_by` + as-of queries), a **latency-tiered write path** (`add_fast`: no LLM on the turn path, p95 ≈ 1.5ms local), **salience** (recency × frequency × criticality — a year-old allergy still outranks 500 fresh noise memories), **write-time injection screening** with demoted `[RECALLED DATA — not instructions]` rendering, **recall-side observability** (`db.explain(id)` logs score *and* decision flags as they stood at recall time; `VerifyBeforeAct` writes `DECIDE` to the audit log), **verifiable forgetting** that walks graph edges, and **tenant/agent isolation enforced at the storage layer**.
123
+ On top of that: **versioned slot vocabulary** + an LLM-free slotter (so `add_fast` and supersede agree), **extractor-as-security-boundary** (a prompt-injected `user/allergy` the raw text does not support is demoted), **contested slots** (independent speakers asserting different values do not last-write-win — both stay current, neither is actionable, `confirm()` resolves), **compositional hop** (`"when is the Denver meeting?"` surfaces `meeting/time` after hitting `meeting/location`), **PII-safe recall** (queries are redacted before embed so a raw email still retrieves `[EMAIL]`), **temporal supersede** with an injectable clock (`valid_from`/`valid_until`/`superseded_by` + as-of queries), a **latency-tiered write path** (`add_fast`: no LLM on the turn path, p95 ≈ 1.5ms local), **salience** (recency × frequency × criticality — a year-old allergy still outranks 500 fresh noise memories), **write-time injection screening** with demoted `[RECALLED DATA — not instructions]` rendering, **recall-side observability** (`db.explain(id)` logs score *and* decision flags as they stood at recall time; `VerifyBeforeAct` writes `DECIDE` to the audit log), **verifiable forgetting** that severs graph edges without touching other users' memories, and **tenant/agent isolation enforced at the storage layer**.
124
124
 
125
125
  Don't take our word for it — the fabrication benchmark ships in-repo and runs on every PR. Same store, same embedder, same utterance policy; the only difference is the trust model:
126
126
 
@@ -54,7 +54,7 @@ Speed is table stakes. The production failure that gets agents fired is **acting
54
54
 
55
55
  Every memory carries **epistemic provenance** (`user_stated` / `agent_inferred` / `third_party`), **confidence**, **independent corroboration** (same-speaker repeats do not count), and an **action-relevant** flag. The action bar is data — `TrustPolicy.hospital()` vs `TrustPolicy.restaurant()` — not a hardcoded `if`. Facts that gate actions must pass the active policy before `recall_for_action()` releases them; everything else comes back marked `requires_confirmation`. `factual.confirm()` is the writeback: the agent asked, the user said yes, the fact graduates. Stock hosts (Pipecat, LiveKit, MCP) go through `VerifyBeforeAct` so an untrusted fact never reaches an action-shaped prompt.
56
56
 
57
- On top of that: **versioned slot vocabulary** + an LLM-free slotter (so `add_fast` and supersede agree), **extractor-as-security-boundary** (a prompt-injected `user/allergy` the raw text does not support is demoted), **contested slots** (independent speakers asserting different values do not last-write-win — both stay current, neither is actionable, `confirm()` resolves), **compositional hop** (`"when is the Denver meeting?"` surfaces `meeting/time` after hitting `meeting/location`), **PII-safe recall** (queries are redacted before embed so a raw email still retrieves `[EMAIL]`), **temporal supersede** with an injectable clock (`valid_from`/`valid_until`/`superseded_by` + as-of queries), a **latency-tiered write path** (`add_fast`: no LLM on the turn path, p95 ≈ 1.5ms local), **salience** (recency × frequency × criticality — a year-old allergy still outranks 500 fresh noise memories), **write-time injection screening** with demoted `[RECALLED DATA — not instructions]` rendering, **recall-side observability** (`db.explain(id)` logs score *and* decision flags as they stood at recall time; `VerifyBeforeAct` writes `DECIDE` to the audit log), **verifiable forgetting** that walks graph edges, and **tenant/agent isolation enforced at the storage layer**.
57
+ On top of that: **versioned slot vocabulary** + an LLM-free slotter (so `add_fast` and supersede agree), **extractor-as-security-boundary** (a prompt-injected `user/allergy` the raw text does not support is demoted), **contested slots** (independent speakers asserting different values do not last-write-win — both stay current, neither is actionable, `confirm()` resolves), **compositional hop** (`"when is the Denver meeting?"` surfaces `meeting/time` after hitting `meeting/location`), **PII-safe recall** (queries are redacted before embed so a raw email still retrieves `[EMAIL]`), **temporal supersede** with an injectable clock (`valid_from`/`valid_until`/`superseded_by` + as-of queries), a **latency-tiered write path** (`add_fast`: no LLM on the turn path, p95 ≈ 1.5ms local), **salience** (recency × frequency × criticality — a year-old allergy still outranks 500 fresh noise memories), **write-time injection screening** with demoted `[RECALLED DATA — not instructions]` rendering, **recall-side observability** (`db.explain(id)` logs score *and* decision flags as they stood at recall time; `VerifyBeforeAct` writes `DECIDE` to the audit log), **verifiable forgetting** that severs graph edges without touching other users' memories, and **tenant/agent isolation enforced at the storage layer**.
58
58
 
59
59
  Don't take our word for it — the fabrication benchmark ships in-repo and runs on every PR. Same store, same embedder, same utterance policy; the only difference is the trust model:
60
60
 
@@ -41,7 +41,7 @@ from contextdb.core.policy import TrustPolicy
41
41
  from contextdb.pool import ContextDBPool
42
42
  from contextdb.utils.embeddings import EmbeddingProvider
43
43
 
44
- __version__ = "0.4.3"
44
+ __version__ = "0.4.5"
45
45
 
46
46
  __all__ = [
47
47
  "Clock",
@@ -1685,21 +1685,13 @@ class ContextDB:
1685
1685
  if added == 0:
1686
1686
  break
1687
1687
 
1688
- # Walk graph edges: a forgotten fact that still has neighbors is
1689
- # how "forgotten" memories regenerate — not from the row, from
1690
- # the graph.
1688
+ # Sever graph edges so a forgotten memory cannot be reached or
1689
+ # rebuilt through the graph. Neighbors are never added to the
1690
+ # deletion set: entity edges join every memory that mentions the
1691
+ # same name, including other users' memories, and this user's own
1692
+ # and derived memories are already in ``targets``.
1691
1693
  for graph in self._graphs.values():
1692
1694
  for memory_id in list(targets):
1693
- try:
1694
- neighbors = await graph.get_neighbors(memory_id, max_results=50)
1695
- except Exception: # noqa: BLE001
1696
- continue
1697
- for nid, _weight in neighbors:
1698
- if nid in targets:
1699
- continue
1700
- neighbor = await store.get_raw(nid)
1701
- if neighbor is not None:
1702
- targets[nid] = neighbor
1703
1695
  try:
1704
1696
  await graph.remove_node(memory_id)
1705
1697
  except Exception: # noqa: BLE001
@@ -98,7 +98,8 @@ class Consolidator:
98
98
  # Trust-conservative merge: a summary must never be MORE trusted
99
99
  # than its least trusted input. Otherwise consolidation launders
100
100
  # epistemic status — five third-party hearsay fragments would
101
- # emerge as a confident first-party fact.
101
+ # emerge as a confident first-party fact. Corroboration is not
102
+ # summed: copies of one claim are not independent support.
102
103
  new_item = MemoryItem(
103
104
  content=summary,
104
105
  embedding=cluster_items[0].embedding,
@@ -110,7 +111,7 @@ class Consolidator:
110
111
  key=lambda s: _SOURCE_RANK[s],
111
112
  ),
112
113
  confidence=min(m.confidence for m in cluster_items),
113
- corroboration_count=sum(m.corroboration_count for m in cluster_items),
114
+ corroboration_count=min(m.independent_corroboration for m in cluster_items),
114
115
  action_relevant=any(m.action_relevant for m in cluster_items),
115
116
  injection_suspect=any(m.injection_suspect for m in cluster_items),
116
117
  )
@@ -282,6 +282,10 @@ class PostgresStore(BaseStore):
282
282
  self._index_items: dict[str, MemoryItem] = {}
283
283
  self._index_ids_by_user: dict[str | None, set[str]] = {}
284
284
  self._index_ids_by_entity: dict[str, dict[str, None]] = {}
285
+ self._index_ids_by_user_entity: dict[
286
+ tuple[str | None, str],
287
+ dict[str, None],
288
+ ] = {}
285
289
  self._embedding_dim = embedding_dim
286
290
  self._embedding_model_id = embedding_model_id
287
291
  self._index_loaded = False
@@ -389,6 +393,10 @@ class PostgresStore(BaseStore):
389
393
  self._index_ids_by_user.setdefault(item.user_id, set()).add(item.id)
390
394
  if item.entity_key is not None:
391
395
  self._index_ids_by_entity.setdefault(item.entity_key, {})[item.id] = None
396
+ self._index_ids_by_user_entity.setdefault(
397
+ (item.user_id, item.entity_key),
398
+ {},
399
+ )[item.id] = None
392
400
 
393
401
  def _discard_index_item(self, memory_id: str) -> None:
394
402
  item = self._index_items.pop(memory_id, None)
@@ -405,11 +413,22 @@ class PostgresStore(BaseStore):
405
413
  entity_ids.pop(memory_id, None)
406
414
  if not entity_ids:
407
415
  self._index_ids_by_entity.pop(item.entity_key, None)
416
+ scoped_entity_ids = self._index_ids_by_user_entity.get(
417
+ (item.user_id, item.entity_key)
418
+ )
419
+ if scoped_entity_ids is not None:
420
+ scoped_entity_ids.pop(memory_id, None)
421
+ if not scoped_entity_ids:
422
+ self._index_ids_by_user_entity.pop(
423
+ (item.user_id, item.entity_key),
424
+ None,
425
+ )
408
426
 
409
427
  def _clear_index_items(self) -> None:
410
428
  self._index_items.clear()
411
429
  self._index_ids_by_user.clear()
412
430
  self._index_ids_by_entity.clear()
431
+ self._index_ids_by_user_entity.clear()
413
432
 
414
433
  def _scope_sql(self, user_id: str | None = None) -> tuple[str, list[Any]]:
415
434
  clauses: list[str] = []
@@ -1101,8 +1120,17 @@ class PostgresStore(BaseStore):
1101
1120
  await self._ensure_index()
1102
1121
  excluded = exclude_ids or set()
1103
1122
  items: list[MemoryItem] = []
1123
+ scope = self._resolve_scope(user_id)
1104
1124
  for entity_key in dict.fromkeys(entity_keys):
1105
- for memory_id in self._index_ids_by_entity.get(entity_key, {}):
1125
+ entity_ids = (
1126
+ self._index_ids_by_entity.get(entity_key, {})
1127
+ if scope is None
1128
+ else self._index_ids_by_user_entity.get(
1129
+ (scope, entity_key),
1130
+ {},
1131
+ )
1132
+ )
1133
+ for memory_id in entity_ids:
1106
1134
  item = self._index_items.get(memory_id)
1107
1135
  if item is None:
1108
1136
  continue
@@ -41,7 +41,8 @@ Three pipelines operate over the storage layer:
41
41
  NOOP operations with lineage and consistency results. Separately, the
42
42
  auto-linker mirrors each new write into graph indices; the consolidator
43
43
  merges dense semantic clusters into summaries that inherit *worst-case*
44
- trust (no laundering); the pruner drops stale / redundant memories by
44
+ trust, including the weakest corroboration count (no laundering); the
45
+ pruner drops stale / redundant memories by
45
46
  policy.
46
47
  3. **Retrieval** — a query becomes an answer. The query is PII-redacted
47
48
  before embed. Query classifier picks graph weights; each graph produces
@@ -78,7 +79,9 @@ search, `DECIDE` (act/ask/abstain), and deletion — and because the chain
78
79
  is append-only, only the PII-processed form of a query is ever logged.
79
80
  `pii_action="encrypt"` fails closed: without a key the client refuses to
80
81
  initialize rather than store plaintext annotation originals.
81
- `forget_user` walks graph edges and signs the deletion set. The retention
82
+ `forget_user` deletes the user's memories and the summaries derived from them,
83
+ severs their graph edges, and signs the deletion set. It never deletes another
84
+ user's memory through a shared edge. The retention
82
85
  manager applies typed TTLs and honors right-to-erasure requests. Isolation
83
86
  is a store predicate (`user_id` / `tenant_id` / `agent_id`), not a
84
87
  convention.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycontextdb"
7
- version = "0.4.3"
7
+ version = "0.4.5"
8
8
  description = "The unified context layer for AI agents — memory you can act on without treating wishes as facts"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -813,6 +813,42 @@ async def test_eval_7_1_forget_user_leaves_zero_residue(tmp_path: Path) -> None:
813
813
  await db.close()
814
814
 
815
815
 
816
+ async def test_eval_7_2_forget_user_spares_other_users_sharing_an_entity(
817
+ tmp_path: Path,
818
+ ) -> None:
819
+ """EVAL-7.2: on a shared client, erasing one user must not delete another
820
+ user's memory because both mention the same entity."""
821
+ db = contextdb.init(
822
+ tenant_id="org-shared",
823
+ config=make_config(tmp_path, enable_entity_graph=True),
824
+ )
825
+ db._llm = MockLLM(
826
+ responses={"Extract": '{"entities": [{"name": "Acme", "type": "ORGANIZATION"}]}'}
827
+ )
828
+ try:
829
+ mine = await db.factual.add(
830
+ "Prefers Friday calls about the Acme account.",
831
+ source="user_stated",
832
+ user_id="alice",
833
+ )
834
+ theirs = await db.factual.add(
835
+ "Escalation contact for the Acme renewal.",
836
+ source="user_stated",
837
+ user_id="bob",
838
+ )
839
+ graph = db._graphs["entity"]
840
+ assert [nid for nid, _ in await graph.get_neighbors(mine.id)] == [theirs.id]
841
+
842
+ deleted = await db.forget_user("alice")
843
+
844
+ assert deleted == 1
845
+ assert await db.verify_forgotten("alice") is True
846
+ assert [m.id for m in await db.factual.list_facts(user_id="bob")] == [theirs.id]
847
+ assert await graph.get_neighbors(theirs.id) == []
848
+ finally:
849
+ await db.close()
850
+
851
+
816
852
  # ---------------------------------------------------------------------------
817
853
  # Epic 8 — Realtime + agent-host integrations
818
854
  # ---------------------------------------------------------------------------
@@ -22,9 +22,34 @@ async def test_consolidation_merges_with_worst_case_trust(client: ContextDB) ->
22
22
  assert summary.source == "consolidator"
23
23
  assert summary.epistemic_source == "third_party"
24
24
  assert summary.confidence == 0.4
25
- assert summary.corroboration_count == 6
25
+ assert summary.corroboration_count == 1
26
26
  assert summary.action_relevant is True
27
27
  assert summary.metadata["consolidated_from"]
28
+ assert client.trust_policy.is_trusted(summary) is False
29
+
30
+
31
+ async def test_consolidated_copies_of_one_rumor_still_require_confirmation(
32
+ client: ContextDB,
33
+ ) -> None:
34
+ """Five copies of one third-party rumor are one source, not five. The
35
+ summary must stay pending confirmation instead of passing the trust bar."""
36
+ copies = [
37
+ await client.factual.add(
38
+ "Forum post says the refund for the annual plan was approved.",
39
+ source="third_party",
40
+ action_relevant=True,
41
+ )
42
+ for _ in range(5)
43
+ ]
44
+ assert all(copy.requires_confirmation_under(client.trust_policy) for copy in copies)
45
+
46
+ summaries = await client.consolidate(min_cluster_size=5)
47
+
48
+ assert summaries, "identical copies should have merged"
49
+ summary = summaries[0]
50
+ assert summary.independent_corroboration == 1
51
+ assert summary.requires_confirmation_under(client.trust_policy)
52
+ assert await client.factual.recall_for_action("issue the annual plan refund") == []
28
53
 
29
54
 
30
55
  async def test_consolidation_screens_summary_for_injection(client: ContextDB) -> None:
@@ -98,6 +98,16 @@ async def test_list_by_entities_filters_and_copies_only_the_requested_limit(
98
98
  )
99
99
  for index in range(20)
100
100
  ]
101
+ foreign = [
102
+ MemoryItem(
103
+ content=f"foreign-{index}",
104
+ entity_key="entity-a",
105
+ user_id="user-2",
106
+ tenant_id="tenant-1",
107
+ agent_id="agent-1",
108
+ )
109
+ for index in range(1_000)
110
+ ]
101
111
  future = MemoryItem(
102
112
  content="future",
103
113
  entity_key="entity-a",
@@ -106,13 +116,15 @@ async def test_list_by_entities_filters_and_copies_only_the_requested_limit(
106
116
  agent_id="agent-1",
107
117
  valid_from=now + timedelta(days=1),
108
118
  )
109
- indexed = [*entity_b, future, *entity_a]
119
+ indexed = [*foreign, *entity_b, future, *entity_a]
110
120
  for item in indexed:
111
121
  store._cache_index_item(item)
112
122
  store._index_loaded = True
113
123
 
114
124
  original_model_copy = MemoryItem.model_copy
125
+ original_scope_allows = store._item_scope_allows
115
126
  copies = 0
127
+ scope_checks = 0
116
128
 
117
129
  def counted_model_copy(
118
130
  self: MemoryItem,
@@ -125,6 +137,13 @@ async def test_list_by_entities_filters_and_copies_only_the_requested_limit(
125
137
  return original_model_copy(self, update=update, deep=deep)
126
138
 
127
139
  monkeypatch.setattr(MemoryItem, "model_copy", counted_model_copy)
140
+
141
+ def counted_scope_allows(item: MemoryItem, user_id: str | None) -> bool:
142
+ nonlocal scope_checks
143
+ scope_checks += 1
144
+ return original_scope_allows(item, user_id)
145
+
146
+ monkeypatch.setattr(store, "_item_scope_allows", counted_scope_allows)
128
147
  results = await store.list_by_entities(
129
148
  ["entity-a", "entity-b"],
130
149
  user_id="user-1",
@@ -135,6 +154,7 @@ async def test_list_by_entities_filters_and_copies_only_the_requested_limit(
135
154
 
136
155
  assert len(results) == 5
137
156
  assert copies == 5
157
+ assert scope_checks == 7
138
158
  assert all(item.entity_key == "entity-a" for item in results)
139
159
  assert entity_a[0].id not in {item.id for item in results}
140
160
  results[0].metadata["nested"].append(99)
@@ -238,6 +258,12 @@ def test_postgres_secondary_indexes_follow_cache_lifecycle() -> None:
238
258
  }
239
259
  assert list(store._index_ids_by_entity["entity-a"]) == [first.id]
240
260
  assert list(store._index_ids_by_entity["entity-b"]) == [second.id]
261
+ assert list(store._index_ids_by_user_entity["user-1", "entity-a"]) == [
262
+ first.id
263
+ ]
264
+ assert list(store._index_ids_by_user_entity["user-2", "entity-b"]) == [
265
+ second.id
266
+ ]
241
267
 
242
268
  replacement = first.model_copy(
243
269
  update={"user_id": "user-3", "entity_key": "entity-c"}
@@ -245,18 +271,24 @@ def test_postgres_secondary_indexes_follow_cache_lifecycle() -> None:
245
271
  store._cache_index_item(replacement)
246
272
  assert "user-1" not in store._index_ids_by_user
247
273
  assert "entity-a" not in store._index_ids_by_entity
274
+ assert ("user-1", "entity-a") not in store._index_ids_by_user_entity
248
275
  assert store._index_ids_by_user["user-3"] == {first.id}
249
276
  assert list(store._index_ids_by_entity["entity-c"]) == [first.id]
277
+ assert list(store._index_ids_by_user_entity["user-3", "entity-c"]) == [
278
+ first.id
279
+ ]
250
280
 
251
281
  store._discard_index_item(first.id)
252
282
  assert "user-3" not in store._index_ids_by_user
253
283
  assert "entity-c" not in store._index_ids_by_entity
284
+ assert ("user-3", "entity-c") not in store._index_ids_by_user_entity
254
285
  assert first.id not in store._index_items
255
286
 
256
287
  store._clear_index_items()
257
288
  assert store._index_items == {}
258
289
  assert store._index_ids_by_user == {}
259
290
  assert store._index_ids_by_entity == {}
291
+ assert store._index_ids_by_user_entity == {}
260
292
 
261
293
 
262
294
  @pytest.mark.asyncio
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes