pycontextdb 0.4.4__tar.gz → 0.4.6__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.4 → pycontextdb-0.4.6}/CHANGELOG.md +35 -0
  2. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/PKG-INFO +3 -3
  3. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/README.md +2 -2
  4. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/__init__.py +1 -1
  5. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/client.py +62 -16
  6. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/core/slots.py +20 -1
  7. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/dynamics/evolution.py +3 -2
  8. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/dynamics/trust.py +74 -28
  9. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/memory/factual.py +20 -5
  10. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/architecture.md +5 -2
  11. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/pyproject.toml +1 -1
  12. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/evals/test_trust_model.py +241 -1
  13. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_evolution.py +26 -1
  14. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/.github/CODEOWNERS +0 -0
  15. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/.github/copilot-instructions.md +0 -0
  16. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/.github/pull_request_template.md +0 -0
  17. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/.github/workflows/ci.yml +0 -0
  18. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/.github/workflows/publish.yml +0 -0
  19. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/.gitignore +0 -0
  20. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/.pre-commit-config.yaml +0 -0
  21. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/COMMERCIAL.md +0 -0
  22. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/CONTRIBUTING.md +0 -0
  23. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/LICENSE +0 -0
  24. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/NOTICE +0 -0
  25. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/OPEN_CORE.md +0 -0
  26. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/ai-tools/.cursorrules +0 -0
  27. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/ai-tools/CLAUDE.md +0 -0
  28. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/ai-tools/README.md +0 -0
  29. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/ai-tools/llms-full.txt +0 -0
  30. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/ai-tools/llms.txt +0 -0
  31. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/assets/README.md +0 -0
  32. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/assets/architecture.png +0 -0
  33. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/assets/benchmarks.png +0 -0
  34. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/assets/hero.png +0 -0
  35. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/assets/performance.png +0 -0
  36. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/assets/problem.png +0 -0
  37. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/assets/quickstart.png +0 -0
  38. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/benchmarks/__init__.py +0 -0
  39. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/benchmarks/run_benchmarks.py +0 -0
  40. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/benchmarks/trust_bakeoff.py +0 -0
  41. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/agents/__init__.py +0 -0
  42. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/agents/memory_bus.py +0 -0
  43. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/agents/rl_manager.py +0 -0
  44. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/cli.py +0 -0
  45. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/connectors/__init__.py +0 -0
  46. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/connectors/base.py +0 -0
  47. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/connectors/postgres.py +0 -0
  48. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/core/__init__.py +0 -0
  49. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/core/clock.py +0 -0
  50. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/core/config.py +0 -0
  51. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/core/exceptions.py +0 -0
  52. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/core/models.py +0 -0
  53. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/core/policy.py +0 -0
  54. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/dynamics/__init__.py +0 -0
  55. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/dynamics/formation.py +0 -0
  56. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/dynamics/retrieval.py +0 -0
  57. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/dynamics/salience.py +0 -0
  58. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/graphs/__init__.py +0 -0
  59. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/graphs/base.py +0 -0
  60. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/graphs/causal.py +0 -0
  61. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/graphs/entity.py +0 -0
  62. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/graphs/semantic.py +0 -0
  63. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/graphs/temporal.py +0 -0
  64. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/integrations/__init__.py +0 -0
  65. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/integrations/act.py +0 -0
  66. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/integrations/autogen.py +0 -0
  67. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/integrations/crewai.py +0 -0
  68. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/integrations/langchain.py +0 -0
  69. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/integrations/livekit.py +0 -0
  70. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/integrations/openai_tools.py +0 -0
  71. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/integrations/pipecat.py +0 -0
  72. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/integrations/prompting.py +0 -0
  73. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/mcp.py +0 -0
  74. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/memory/__init__.py +0 -0
  75. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/memory/experiential.py +0 -0
  76. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/memory/working.py +0 -0
  77. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/pool.py +0 -0
  78. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/privacy/__init__.py +0 -0
  79. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/privacy/audit.py +0 -0
  80. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/privacy/injection.py +0 -0
  81. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/privacy/pii_detector.py +0 -0
  82. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/privacy/retention.py +0 -0
  83. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/py.typed +0 -0
  84. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/serve.py +0 -0
  85. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/store/__init__.py +0 -0
  86. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/store/base.py +0 -0
  87. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/store/factory.py +0 -0
  88. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/store/pg_sql.py +0 -0
  89. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/store/postgres_store.py +0 -0
  90. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/store/sqlite_store.py +0 -0
  91. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/store/vector_index.py +0 -0
  92. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/utils/__init__.py +0 -0
  93. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/utils/embeddings.py +0 -0
  94. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/utils/llm.py +0 -0
  95. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/contextdb/utils/migrations.py +0 -0
  96. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/api.md +0 -0
  97. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/connectors.md +0 -0
  98. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/index.md +0 -0
  99. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/memory_evolution.md +0 -0
  100. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/migrations.md +0 -0
  101. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/multi_tenant.md +0 -0
  102. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/postgres.md +0 -0
  103. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/prompts.md +0 -0
  104. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/quickstart.md +0 -0
  105. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/serve.md +0 -0
  106. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/docs/trust_policy.md +0 -0
  107. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/examples/customer_support.py +0 -0
  108. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/examples/multi_agent_team.py +0 -0
  109. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/examples/phone_agent.py +0 -0
  110. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/examples/research_assistant.py +0 -0
  111. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/legal/CLA-entity.md +0 -0
  112. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/legal/CLA-individual.md +0 -0
  113. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/legal/signed/README.md +0 -0
  114. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/mkdocs.yml +0 -0
  115. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/scripts/check_open_core_boundary.py +0 -0
  116. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/scripts/check_release.py +0 -0
  117. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/__init__.py +0 -0
  118. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/conftest.py +0 -0
  119. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/evals/__init__.py +0 -0
  120. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/integration/__init__.py +0 -0
  121. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/integration/test_client.py +0 -0
  122. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/integration/test_langchain_integration.py +0 -0
  123. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/pg_util.py +0 -0
  124. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/test_version.py +0 -0
  125. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/__init__.py +0 -0
  126. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_audit.py +0 -0
  127. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_config.py +0 -0
  128. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_connectors.py +0 -0
  129. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_decision_scope.py +0 -0
  130. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_embeddings.py +0 -0
  131. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_formation.py +0 -0
  132. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_graphs.py +0 -0
  133. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_host_api.py +0 -0
  134. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_init.py +0 -0
  135. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_memory_bus.py +0 -0
  136. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_memory_evolution.py +0 -0
  137. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_models.py +0 -0
  138. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_open_core_boundary.py +0 -0
  139. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_pg_sql.py +0 -0
  140. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_pii_detector.py +0 -0
  141. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_postgres_concurrency.py +0 -0
  142. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_postgres_connector.py +0 -0
  143. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_postgres_memory_evolution.py +0 -0
  144. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_retention.py +0 -0
  145. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_retrieval.py +0 -0
  146. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_scoped_id_access.py +0 -0
  147. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_serve.py +0 -0
  148. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_store.py +0 -0
  149. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_trust_policy.py +0 -0
  150. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_vector_index.py +0 -0
  151. {pycontextdb-0.4.4 → pycontextdb-0.4.6}/tests/unit/test_vector_scope.py +0 -0
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.6 — 2026-10-08
6
+
7
+ - A lower-trust write no longer replaces a first-party fact in its slot.
8
+ Writes made under one user id share a speaker, so a third-party document or
9
+ an agent inference stored for that user superseded what the user had said,
10
+ and the user would then be asked to confirm the document's value. A
11
+ user-stated or confirmed fact now stays current and the slot is contested
12
+ until `confirm()`. Corrections of equal or higher trust still supersede.
13
+ Advisory: GHSA-vpf9-5xgr-3x8m.
14
+ - `factual.recall_for_action` and `factual.pending_confirmations` accept the
15
+ `entity` and `attribute` an action needs. With them, only current facts in
16
+ that slot count, read exactly rather than by similarity, and the read is
17
+ audited like a recall. Without them nothing changes: any trusted memory the
18
+ query retrieves counts, whether or not it answers the request.
19
+ - The slot vocabulary (version 2) adds `user/billing_email`, `user/work_phone`,
20
+ and `user/work_address`. A user's billing email, work phone, or work address
21
+ no longer replaces their personal one. An email filed as a billing email
22
+ stays there instead of being moved to `user/email` and stored as
23
+ `agent_inferred`.
24
+
25
+ ## 0.4.5 — 2026-10-05
26
+
27
+ - `forget_user` no longer deletes other users' memories. It added every graph
28
+ neighbor of the erased memories to the deletion set, and entity edges join
29
+ any memories that mention the same name, so on a shared client with the
30
+ entity graph and an extraction model, erasing one user could hard-delete
31
+ another user's memories. It now deletes only the user's memories and the
32
+ summaries derived from them, and severs their graph edges.
33
+ - Consolidated summaries keep the weakest corroboration count of their inputs
34
+ instead of the sum. Repeated copies of one third-party claim could
35
+ consolidate into a summary that passed the default trust bar and was
36
+ returned by `recall_for_action`. The summary now stays pending confirmation.
37
+ - Unchanged limitation: entity rows are shared across users, and attributes
38
+ merged into an entity from a user's text are not removed by `forget_user`.
39
+
5
40
  ## 0.4.4 — 2026-09-20
6
41
 
7
42
  - PostgreSQL sibling composition now uses an ordered user-plus-entity cache
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pycontextdb
3
- Version: 0.4.4
3
+ Version: 0.4.6
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
@@ -118,9 +118,9 @@ Hermetic, reproducible — run the suite yourself: `python benchmarks/run_benchm
118
118
 
119
119
  Speed is table stakes. The production failure that gets agents fired is **acting on a wish as if it were a fact**: a caller says *"I'd like to come in Thursday"* and the agent answers *"we penciled you in."* A wish and a confirmed fact are the same object in every memory store on the market — so ContextDB types them.
120
120
 
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.
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`. Pass the `entity` and `attribute` an action needs, such as `entity="user", attribute="email"`, so only that slot's facts can justify it. `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
 
@@ -52,9 +52,9 @@ Hermetic, reproducible — run the suite yourself: `python benchmarks/run_benchm
52
52
 
53
53
  Speed is table stakes. The production failure that gets agents fired is **acting on a wish as if it were a fact**: a caller says *"I'd like to come in Thursday"* and the agent answers *"we penciled you in."* A wish and a confirmed fact are the same object in every memory store on the market — so ContextDB types them.
54
54
 
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.
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`. Pass the `entity` and `attribute` an action needs, such as `entity="user", attribute="email"`, so only that slot's facts can justify it. `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.4"
44
+ __version__ = "0.4.6"
45
45
 
46
46
  __all__ = [
47
47
  "Clock",
@@ -48,7 +48,12 @@ from contextdb.core.models import (
48
48
  MemoryType,
49
49
  )
50
50
  from contextdb.core.policy import TrustPolicy
51
- from contextdb.core.slots import canonicalize_slot, infer_negation, infer_slot
51
+ from contextdb.core.slots import (
52
+ canonicalize_slot,
53
+ infer_negation,
54
+ infer_slot,
55
+ slot_supported_by,
56
+ )
52
57
  from contextdb.dynamics.trust import TrustEngine, infer_action_relevant, speaker_id
53
58
  from contextdb.privacy.injection import screen_injection
54
59
  from contextdb.privacy.pii_detector import PIIDetector
@@ -389,7 +394,7 @@ class ContextDB:
389
394
  inferred = infer_slot(source_text)
390
395
  chosen = explicit or inferred
391
396
  if explicit is not None and inferred is not None:
392
- if (explicit.entity, explicit.attribute) != (inferred.entity, inferred.attribute):
397
+ if not slot_supported_by(explicit, inferred):
393
398
  chosen = inferred
394
399
  item.epistemic_source = "agent_inferred"
395
400
  item.confidence = min(item.confidence, 0.4)
@@ -1685,21 +1690,13 @@ class ContextDB:
1685
1690
  if added == 0:
1686
1691
  break
1687
1692
 
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.
1693
+ # Sever graph edges so a forgotten memory cannot be reached or
1694
+ # rebuilt through the graph. Neighbors are never added to the
1695
+ # deletion set: entity edges join every memory that mentions the
1696
+ # same name, including other users' memories, and this user's own
1697
+ # and derived memories are already in ``targets``.
1691
1698
  for graph in self._graphs.values():
1692
1699
  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
1700
  try:
1704
1701
  await graph.remove_node(memory_id)
1705
1702
  except Exception: # noqa: BLE001
@@ -1999,8 +1996,18 @@ class ContextDB:
1999
1996
  self,
2000
1997
  user_id: str | None = None,
2001
1998
  limit: int = 100,
1999
+ *,
2000
+ entity: str | None = None,
2001
+ attribute: str | None = None,
2002
2002
  ) -> list[MemoryItem]:
2003
- """Facts that are action-relevant and do not yet pass the trust policy."""
2003
+ """Facts that are action-relevant and do not yet pass the trust policy.
2004
+
2005
+ With ``entity`` and ``attribute``, only the current facts in that slot.
2006
+ """
2007
+ if entity is not None or attribute is not None:
2008
+ rows = await self.slot_facts(entity, attribute, user_id=user_id)
2009
+ pending = [item for item in rows if self.trust_policy.requires_confirmation(item)]
2010
+ return pending[:limit]
2004
2011
  uid = self._resolve_user(user_id)
2005
2012
  await self._ensure_init()
2006
2013
  store = self._require_store()
@@ -2009,6 +2016,45 @@ class ContextDB:
2009
2016
  )
2010
2017
  return [item for item in rows if self.trust_policy.requires_confirmation(item)]
2011
2018
 
2019
+ async def slot_facts(
2020
+ self,
2021
+ entity: str | None,
2022
+ attribute: str | None,
2023
+ *,
2024
+ user_id: str | None = None,
2025
+ as_of: datetime | None = None,
2026
+ ) -> list[MemoryItem]:
2027
+ """Current facts in one slot, read exactly rather than by similarity.
2028
+
2029
+ A similarity search returns the nearest memories whether or not they
2030
+ answer the request. When the caller knows which slot an action needs,
2031
+ this is the evidence that action may rely on.
2032
+ """
2033
+ slot = canonicalize_slot(entity, attribute)
2034
+ if slot is None:
2035
+ raise ValueError("entity and attribute are both required to name a slot")
2036
+ uid = self._resolve_user(user_id)
2037
+ await self._ensure_init()
2038
+ store = self._require_store()
2039
+ moment = as_of or self.clock()
2040
+ rows = await store.list_by_slot(slot.entity, slot.attribute, user_id=uid)
2041
+ items = sorted(
2042
+ (m for m in rows if m.memory_type == MemoryType.FACTUAL and m.is_valid_at(moment)),
2043
+ key=lambda m: m.write_generation,
2044
+ reverse=True,
2045
+ )
2046
+ audit_details = {
2047
+ "slot": [slot.entity, slot.attribute],
2048
+ "hits": len(items),
2049
+ "returned_ids": [m.id for m in items],
2050
+ "as_of": moment.isoformat(),
2051
+ "abstained": not items,
2052
+ }
2053
+ if self._audit is not None and self.config.enable_read_audit:
2054
+ await self._audit.log(operation="SLOT_READ", user_id=uid, details=audit_details)
2055
+ await self._emit("read_audit", user_id=uid, audit_details=audit_details)
2056
+ return items
2057
+
2012
2058
  # ------------------------------------------------------------------ #
2013
2059
  # Typed memory surfaces
2014
2060
  # ------------------------------------------------------------------ #
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import re
15
15
  from dataclasses import dataclass
16
16
 
17
- SLOT_VOCAB_VERSION = "1"
17
+ SLOT_VOCAB_VERSION = "2"
18
18
 
19
19
  # Canonical (entity, attribute) → class. The class drives the per-class
20
20
  # corroboration bar in :class:`TrustPolicy`.
@@ -31,8 +31,11 @@ SLOT_VOCAB: dict[tuple[str, str], str] = {
31
31
  ("reservation", "time"): "booking",
32
32
  ("reservation", "day"): "booking",
33
33
  ("user", "email"): "contact",
34
+ ("user", "billing_email"): "contact",
34
35
  ("user", "phone"): "contact",
36
+ ("user", "work_phone"): "contact",
35
37
  ("user", "address"): "contact",
38
+ ("user", "work_address"): "contact",
36
39
  ("user", "name"): "identity",
37
40
  ("account", "number"): "identity",
38
41
  ("account", "pin"): "identity",
@@ -77,6 +80,15 @@ _ATTRIBUTE_ALIASES: dict[str, str] = {
77
80
  "mobile": "phone",
78
81
  "allergies": "allergy",
79
82
  "allergic": "allergy",
83
+ "invoice_email": "billing_email",
84
+ "office_phone": "work_phone",
85
+ "office_address": "work_address",
86
+ }
87
+
88
+ # Slots the text slotter cannot tell from a broader one: any email address
89
+ # reads as user/email, whether it is personal or where invoices go.
90
+ _REFINES: dict[tuple[str, str], tuple[str, str]] = {
91
+ ("user", "billing_email"): ("user", "email"),
80
92
  }
81
93
 
82
94
 
@@ -111,6 +123,13 @@ def canonicalize_slot(entity: str | None, attribute: str | None) -> Slot | None:
111
123
  return Slot(entity=ent, attribute=attr, slot_class=slot_class)
112
124
 
113
125
 
126
+ def slot_supported_by(explicit: Slot, inferred: Slot) -> bool:
127
+ """Whether the slot the text implies is consistent with the one chosen."""
128
+ chosen = (explicit.entity, explicit.attribute)
129
+ implied = (inferred.entity, inferred.attribute)
130
+ return chosen == implied or _REFINES.get(chosen) == implied
131
+
132
+
114
133
  _WEEKDAYS = (
115
134
  "monday",
116
135
  "tuesday",
@@ -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
  )
@@ -137,6 +137,21 @@ def speaker_id(
137
137
  return "anonymous"
138
138
 
139
139
 
140
+ def _first_party(item: MemoryItem) -> bool:
141
+ return item.confirmed or item.epistemic_source == "user_stated"
142
+
143
+
144
+ def outranks(current: MemoryItem, incoming: MemoryItem) -> bool:
145
+ """Whether ``incoming`` may not replace ``current`` in a slot.
146
+
147
+ Speaker identity alone cannot tell a user's correction from a document
148
+ the agent stored on the user's behalf: both arrive under the same user.
149
+ A first-party or confirmed fact is therefore never superseded by a
150
+ lower-trust write; the slot is contested instead.
151
+ """
152
+ return _first_party(current) and not _first_party(incoming)
153
+
154
+
140
155
  class TrustEngine:
141
156
  """Slot-aware write path used by ``factual.add`` and consolidation."""
142
157
 
@@ -252,20 +267,27 @@ class TrustEngine:
252
267
  )
253
268
  return updated, "corroborated"
254
269
 
270
+ protected = [c for c in current if outranks(c, item)]
255
271
  same_speaker = [
256
- c for c in current if incoming_speaker in (c.corroborated_by or [])
272
+ c
273
+ for c in current
274
+ if c not in protected and incoming_speaker in (c.corroborated_by or [])
257
275
  ]
258
276
  other_speaker = [
259
- c for c in current if incoming_speaker not in (c.corroborated_by or [])
277
+ c
278
+ for c in current
279
+ if c not in protected and incoming_speaker not in (c.corroborated_by or [])
260
280
  ]
261
281
 
262
282
  item.write_generation = max_gen + 1
263
283
  # Independent speakers asserting different values: contest, do not
264
- # last-write-win. Same-speaker corrections still supersede.
265
- if other_speaker and not same_speaker and not item.pending_consolidation:
284
+ # last-write-win. Same-speaker corrections still supersede, except a
285
+ # lower-trust write never replaces a first-party fact.
286
+ contest = protected + ([] if same_speaker else other_speaker)
287
+ if contest and not item.pending_consolidation:
266
288
  item.contested = True
267
289
  stored = await self.store.add(item)
268
- for candidate in other_speaker:
290
+ for candidate in contest:
269
291
  await self.store.update(candidate.id, contested=True)
270
292
  await self._log(
271
293
  "CONFLICT",
@@ -278,8 +300,13 @@ class TrustEngine:
278
300
  "old_value": candidate.content,
279
301
  "new_value": stored.content,
280
302
  "speaker": incoming_speaker,
303
+ "reason": (
304
+ "outranked_source" if candidate in protected else "other_speaker"
305
+ ),
281
306
  },
282
307
  )
308
+ for candidate in [c for c in current if c not in contest]:
309
+ await self._supersede(candidate, stored, user_id, item, now)
283
310
  await self._log(
284
311
  "CREATE",
285
312
  stored.id,
@@ -297,25 +324,9 @@ class TrustEngine:
297
324
  for candidate in current:
298
325
  if item.pending_consolidation and not candidate.pending_consolidation:
299
326
  continue
300
- await self.store.update(
301
- candidate.id,
302
- valid_until=now,
303
- superseded_by=stored.id,
304
- contested=False,
305
- )
306
- await self._log(
307
- "SUPERSEDE",
308
- candidate.id,
309
- user_id,
310
- {
311
- "superseded_by": stored.id,
312
- "entity": item.entity_key,
313
- "attribute": item.attribute_key,
314
- "old_value": candidate.content,
315
- "new_value": stored.content,
316
- "generation": item.write_generation,
317
- },
318
- )
327
+ if candidate in protected:
328
+ continue
329
+ await self._supersede(candidate, stored, user_id, item, now)
319
330
  outcome = "superseded"
320
331
  await self._log(
321
332
  "CREATE",
@@ -325,6 +336,34 @@ class TrustEngine:
325
336
  )
326
337
  return stored, outcome
327
338
 
339
+ async def _supersede(
340
+ self,
341
+ candidate: MemoryItem,
342
+ stored: MemoryItem,
343
+ user_id: str | None,
344
+ item: MemoryItem,
345
+ now: datetime,
346
+ ) -> None:
347
+ await self.store.update(
348
+ candidate.id,
349
+ valid_until=now,
350
+ superseded_by=stored.id,
351
+ contested=False,
352
+ )
353
+ await self._log(
354
+ "SUPERSEDE",
355
+ candidate.id,
356
+ user_id,
357
+ {
358
+ "superseded_by": stored.id,
359
+ "entity": item.entity_key,
360
+ "attribute": item.attribute_key,
361
+ "old_value": candidate.content,
362
+ "new_value": stored.content,
363
+ "generation": item.write_generation,
364
+ },
365
+ )
366
+
328
367
  async def evolve_write(
329
368
  self,
330
369
  item: MemoryItem,
@@ -591,22 +630,24 @@ class TrustEngine:
591
630
  previous_memory_ids=previous_ids,
592
631
  )
593
632
 
633
+ protected = [candidate for candidate in current if outranks(candidate, item)]
594
634
  same_speaker = [
595
635
  candidate
596
636
  for candidate in current
597
- if incoming_speaker in candidate.corroborated_by
637
+ if candidate not in protected and incoming_speaker in candidate.corroborated_by
598
638
  ]
599
639
  other_speaker = [
600
640
  candidate
601
641
  for candidate in current
602
- if incoming_speaker not in candidate.corroborated_by
642
+ if candidate not in protected and incoming_speaker not in candidate.corroborated_by
603
643
  ]
604
644
  item.write_generation = max_generation + 1
605
645
 
606
- if other_speaker and not same_speaker:
646
+ contest = protected + ([] if same_speaker else other_speaker)
647
+ if contest:
607
648
  item.contested = True
608
649
  stored = await self.store.add(item)
609
- for candidate in other_speaker:
650
+ for candidate in contest:
610
651
  await self.store.update(candidate.id, contested=True)
611
652
  await self._log(
612
653
  "CONFLICT",
@@ -619,8 +660,13 @@ class TrustEngine:
619
660
  "old_value": candidate.content,
620
661
  "new_value": stored.content,
621
662
  "speaker": incoming_speaker,
663
+ "reason": (
664
+ "outranked_source" if candidate in protected else "other_speaker"
665
+ ),
622
666
  },
623
667
  )
668
+ for candidate in [c for c in current if c not in contest]:
669
+ await self._supersede(candidate, stored, user_id, item, now)
624
670
  await self._log(
625
671
  "CREATE",
626
672
  stored.id,
@@ -166,12 +166,24 @@ class FactualMemory:
166
166
  as_of: datetime | None = None,
167
167
  *,
168
168
  user_id: str | None = None,
169
+ entity: str | None = None,
170
+ attribute: str | None = None,
169
171
  ) -> list[MemoryItem]:
170
- """Recall only facts an agent may act on without confirming first."""
172
+ """Recall only facts an agent may act on without confirming first.
173
+
174
+ Without a slot, any trusted memory the query retrieves counts, related
175
+ or not. Pass the ``entity`` and ``attribute`` the action needs to rely
176
+ only on trusted facts in that slot.
177
+ """
171
178
  policy = self.client.trust_policy
172
- candidates = await self.recall(
173
- query, top_k=top_k * 4, as_of=as_of, user_id=self._user(user_id)
174
- )
179
+ if entity is not None or attribute is not None:
180
+ candidates = await self.client.slot_facts(
181
+ entity, attribute, user_id=self._user(user_id), as_of=as_of
182
+ )
183
+ else:
184
+ candidates = await self.recall(
185
+ query, top_k=top_k * 4, as_of=as_of, user_id=self._user(user_id)
186
+ )
175
187
  return [m for m in candidates if policy.is_trusted(m)][:top_k]
176
188
 
177
189
  async def confirm(self, memory_id: str, user_id: str | None = None) -> MemoryItem:
@@ -182,10 +194,13 @@ class FactualMemory:
182
194
  self,
183
195
  user_id: str | None = None,
184
196
  limit: int = 100,
197
+ *,
198
+ entity: str | None = None,
199
+ attribute: str | None = None,
185
200
  ) -> list[MemoryItem]:
186
201
  """Action-relevant facts that still need a yes from the user."""
187
202
  return await self.client.pending_confirmations(
188
- user_id=self._user(user_id), limit=limit
203
+ user_id=self._user(user_id), limit=limit, entity=entity, attribute=attribute
189
204
  )
190
205
 
191
206
  async def update_fact(
@@ -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.4"
7
+ version = "0.4.6"
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"