pycontextdb 0.4.5__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.5 → pycontextdb-0.4.6}/CHANGELOG.md +20 -0
  2. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/PKG-INFO +2 -2
  3. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/README.md +1 -1
  4. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/__init__.py +1 -1
  5. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/client.py +57 -3
  6. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/slots.py +20 -1
  7. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/trust.py +74 -28
  8. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/memory/factual.py +20 -5
  9. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/pyproject.toml +1 -1
  10. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/evals/test_trust_model.py +205 -1
  11. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/CODEOWNERS +0 -0
  12. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/copilot-instructions.md +0 -0
  13. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/pull_request_template.md +0 -0
  14. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/workflows/ci.yml +0 -0
  15. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.github/workflows/publish.yml +0 -0
  16. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.gitignore +0 -0
  17. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/.pre-commit-config.yaml +0 -0
  18. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/COMMERCIAL.md +0 -0
  19. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/CONTRIBUTING.md +0 -0
  20. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/LICENSE +0 -0
  21. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/NOTICE +0 -0
  22. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/OPEN_CORE.md +0 -0
  23. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/.cursorrules +0 -0
  24. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/CLAUDE.md +0 -0
  25. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/README.md +0 -0
  26. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/llms-full.txt +0 -0
  27. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/ai-tools/llms.txt +0 -0
  28. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/README.md +0 -0
  29. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/architecture.png +0 -0
  30. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/benchmarks.png +0 -0
  31. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/hero.png +0 -0
  32. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/performance.png +0 -0
  33. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/problem.png +0 -0
  34. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/assets/quickstart.png +0 -0
  35. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/benchmarks/__init__.py +0 -0
  36. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/benchmarks/run_benchmarks.py +0 -0
  37. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/benchmarks/trust_bakeoff.py +0 -0
  38. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/agents/__init__.py +0 -0
  39. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/agents/memory_bus.py +0 -0
  40. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/agents/rl_manager.py +0 -0
  41. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/cli.py +0 -0
  42. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/connectors/__init__.py +0 -0
  43. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/connectors/base.py +0 -0
  44. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/connectors/postgres.py +0 -0
  45. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/__init__.py +0 -0
  46. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/clock.py +0 -0
  47. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/config.py +0 -0
  48. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/exceptions.py +0 -0
  49. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/models.py +0 -0
  50. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/core/policy.py +0 -0
  51. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/__init__.py +0 -0
  52. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/evolution.py +0 -0
  53. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/formation.py +0 -0
  54. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/retrieval.py +0 -0
  55. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/dynamics/salience.py +0 -0
  56. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/__init__.py +0 -0
  57. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/base.py +0 -0
  58. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/causal.py +0 -0
  59. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/entity.py +0 -0
  60. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/semantic.py +0 -0
  61. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/graphs/temporal.py +0 -0
  62. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/__init__.py +0 -0
  63. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/act.py +0 -0
  64. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/autogen.py +0 -0
  65. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/crewai.py +0 -0
  66. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/langchain.py +0 -0
  67. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/livekit.py +0 -0
  68. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/openai_tools.py +0 -0
  69. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/pipecat.py +0 -0
  70. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/integrations/prompting.py +0 -0
  71. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/mcp.py +0 -0
  72. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/memory/__init__.py +0 -0
  73. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/memory/experiential.py +0 -0
  74. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/memory/working.py +0 -0
  75. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/pool.py +0 -0
  76. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/__init__.py +0 -0
  77. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/audit.py +0 -0
  78. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/injection.py +0 -0
  79. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/pii_detector.py +0 -0
  80. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/privacy/retention.py +0 -0
  81. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/py.typed +0 -0
  82. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/serve.py +0 -0
  83. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/__init__.py +0 -0
  84. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/base.py +0 -0
  85. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/factory.py +0 -0
  86. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/pg_sql.py +0 -0
  87. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/postgres_store.py +0 -0
  88. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/sqlite_store.py +0 -0
  89. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/store/vector_index.py +0 -0
  90. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/utils/__init__.py +0 -0
  91. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/utils/embeddings.py +0 -0
  92. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/utils/llm.py +0 -0
  93. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/contextdb/utils/migrations.py +0 -0
  94. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/api.md +0 -0
  95. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/architecture.md +0 -0
  96. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/connectors.md +0 -0
  97. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/index.md +0 -0
  98. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/memory_evolution.md +0 -0
  99. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/migrations.md +0 -0
  100. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/multi_tenant.md +0 -0
  101. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/postgres.md +0 -0
  102. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/prompts.md +0 -0
  103. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/quickstart.md +0 -0
  104. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/serve.md +0 -0
  105. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/docs/trust_policy.md +0 -0
  106. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/examples/customer_support.py +0 -0
  107. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/examples/multi_agent_team.py +0 -0
  108. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/examples/phone_agent.py +0 -0
  109. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/examples/research_assistant.py +0 -0
  110. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/legal/CLA-entity.md +0 -0
  111. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/legal/CLA-individual.md +0 -0
  112. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/legal/signed/README.md +0 -0
  113. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/mkdocs.yml +0 -0
  114. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/scripts/check_open_core_boundary.py +0 -0
  115. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/scripts/check_release.py +0 -0
  116. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/__init__.py +0 -0
  117. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/conftest.py +0 -0
  118. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/evals/__init__.py +0 -0
  119. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/integration/__init__.py +0 -0
  120. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/integration/test_client.py +0 -0
  121. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/integration/test_langchain_integration.py +0 -0
  122. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/pg_util.py +0 -0
  123. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/test_version.py +0 -0
  124. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/__init__.py +0 -0
  125. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_audit.py +0 -0
  126. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_config.py +0 -0
  127. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_connectors.py +0 -0
  128. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_decision_scope.py +0 -0
  129. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_embeddings.py +0 -0
  130. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_evolution.py +0 -0
  131. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_formation.py +0 -0
  132. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_graphs.py +0 -0
  133. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_host_api.py +0 -0
  134. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_init.py +0 -0
  135. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_memory_bus.py +0 -0
  136. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_memory_evolution.py +0 -0
  137. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_models.py +0 -0
  138. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_open_core_boundary.py +0 -0
  139. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_pg_sql.py +0 -0
  140. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_pii_detector.py +0 -0
  141. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_postgres_concurrency.py +0 -0
  142. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_postgres_connector.py +0 -0
  143. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_postgres_memory_evolution.py +0 -0
  144. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_retention.py +0 -0
  145. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_retrieval.py +0 -0
  146. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_scoped_id_access.py +0 -0
  147. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_serve.py +0 -0
  148. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_store.py +0 -0
  149. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_trust_policy.py +0 -0
  150. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_vector_index.py +0 -0
  151. {pycontextdb-0.4.5 → pycontextdb-0.4.6}/tests/unit/test_vector_scope.py +0 -0
@@ -2,6 +2,26 @@
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
+
5
25
  ## 0.4.5 — 2026-10-05
6
26
 
7
27
  - `forget_user` no longer deletes other users' memories. It added every graph
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pycontextdb
3
- Version: 0.4.5
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,7 +118,7 @@ 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
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
 
@@ -52,7 +52,7 @@ 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
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
 
@@ -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.5"
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)
@@ -1991,8 +1996,18 @@ class ContextDB:
1991
1996
  self,
1992
1997
  user_id: str | None = None,
1993
1998
  limit: int = 100,
1999
+ *,
2000
+ entity: str | None = None,
2001
+ attribute: str | None = None,
1994
2002
  ) -> list[MemoryItem]:
1995
- """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]
1996
2011
  uid = self._resolve_user(user_id)
1997
2012
  await self._ensure_init()
1998
2013
  store = self._require_store()
@@ -2001,6 +2016,45 @@ class ContextDB:
2001
2016
  )
2002
2017
  return [item for item in rows if self.trust_policy.requires_confirmation(item)]
2003
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
+
2004
2058
  # ------------------------------------------------------------------ #
2005
2059
  # Typed memory surfaces
2006
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",
@@ -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(
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycontextdb"
7
- version = "0.4.5"
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"
@@ -28,7 +28,7 @@ import pytest
28
28
  import pytest_asyncio
29
29
 
30
30
  import contextdb
31
- from contextdb import ContextDB, ContextDBConfig
31
+ from contextdb import ContextDB, ContextDBConfig, EvolutionOutcome
32
32
  from contextdb.core.exceptions import ConfigError
33
33
  from contextdb.core.models import MemoryType
34
34
  from contextdb.integrations.prompting import (
@@ -1384,3 +1384,207 @@ async def test_eval_pii_query_retrieves_redacted_memory(db: ContextDB) -> None:
1384
1384
  hits = await db.search("please look up jane.doe@example.com")
1385
1385
  assert any("[EMAIL]" in h.content for h in hits), [h.content for h in hits]
1386
1386
  assert all("jane.doe@example.com" not in h.content for h in hits)
1387
+
1388
+
1389
+ async def test_eval_lower_trust_write_contests_a_user_stated_slot(db: ContextDB) -> None:
1390
+ """A document stored under the user's id contests their fact; it never replaces it."""
1391
+ mine = await db.factual.add(
1392
+ "The meeting is at 3pm",
1393
+ source="user_stated",
1394
+ confidence=0.9,
1395
+ action_relevant=True,
1396
+ entity="meeting",
1397
+ attribute="time",
1398
+ )
1399
+ planted = await db.factual.add(
1400
+ "Forwarded vendor note: the meeting moved to 4pm",
1401
+ source="third_party",
1402
+ confidence=0.9,
1403
+ action_relevant=True,
1404
+ entity="meeting",
1405
+ attribute="time",
1406
+ )
1407
+ inferred = await db.factual.add(
1408
+ "From that note, the meeting is probably at 5pm",
1409
+ source="agent_inferred",
1410
+ confidence=0.9,
1411
+ action_relevant=True,
1412
+ entity="meeting",
1413
+ attribute="time",
1414
+ )
1415
+ assert planted.contested is True
1416
+ assert inferred.contested is True
1417
+ kept = await db.get(mine.id)
1418
+ assert kept is not None
1419
+ assert kept.valid_until is None
1420
+ assert kept.contested is True
1421
+ assert await db.factual.recall_for_action("when is the meeting") == []
1422
+
1423
+ await db.factual.confirm(mine.id)
1424
+ trusted = await db.factual.recall_for_action("when is the meeting")
1425
+ assert [m.id for m in trusted] == [mine.id]
1426
+ closed = await db.get(inferred.id)
1427
+ assert closed is not None
1428
+ assert closed.superseded_by == mine.id
1429
+
1430
+
1431
+ async def test_eval_corrections_of_equal_or_higher_trust_still_supersede(
1432
+ db: ContextDB,
1433
+ ) -> None:
1434
+ guess = await db.factual.add(
1435
+ "The meeting is at 3pm",
1436
+ source="agent_inferred",
1437
+ confidence=0.9,
1438
+ action_relevant=True,
1439
+ entity="meeting",
1440
+ attribute="time",
1441
+ )
1442
+ stated = await db.factual.add(
1443
+ "Actually, the meeting is at 4pm",
1444
+ source="user_stated",
1445
+ confidence=0.9,
1446
+ action_relevant=True,
1447
+ entity="meeting",
1448
+ attribute="time",
1449
+ )
1450
+ assert stated.contested is False
1451
+ replaced = await db.get(guess.id)
1452
+ assert replaced is not None
1453
+ assert replaced.superseded_by == stated.id
1454
+
1455
+ first = await db.factual.add(
1456
+ "Partner note: party of 4",
1457
+ source="third_party",
1458
+ action_relevant=True,
1459
+ entity="reservation",
1460
+ attribute="party_size",
1461
+ )
1462
+ second = await db.factual.add(
1463
+ "Partner note: party of 6",
1464
+ source="third_party",
1465
+ action_relevant=True,
1466
+ entity="reservation",
1467
+ attribute="party_size",
1468
+ )
1469
+ assert second.contested is False
1470
+ older = await db.get(first.id)
1471
+ assert older is not None
1472
+ assert older.superseded_by == second.id
1473
+
1474
+
1475
+ async def test_eval_explicit_update_from_a_document_contests_a_user_fact(
1476
+ db: ContextDB,
1477
+ ) -> None:
1478
+ added = await db.factual.evolve(
1479
+ "add",
1480
+ "The meeting is at 3pm",
1481
+ source="user_stated",
1482
+ entity="meeting",
1483
+ attribute="time",
1484
+ )
1485
+ assert added.memory is not None
1486
+ update = await db.factual.evolve(
1487
+ "update",
1488
+ "Vendor portal export: the meeting is at 4pm",
1489
+ source="third_party",
1490
+ entity="meeting",
1491
+ attribute="time",
1492
+ )
1493
+ assert update.outcome == EvolutionOutcome.CONTESTED
1494
+ kept = await db.get(added.memory.id)
1495
+ assert kept is not None
1496
+ assert kept.valid_until is None
1497
+ assert kept.contested is True
1498
+
1499
+
1500
+ async def test_eval_slot_scoped_action_relies_only_on_that_slot(db: ContextDB) -> None:
1501
+ """Without a slot any trusted memory counts; with one, only that slot's facts do."""
1502
+ await db.factual.add(
1503
+ "My home address is 12 Elm Street, Springfield",
1504
+ source="user_stated",
1505
+ action_relevant=True,
1506
+ )
1507
+ await db.factual.add(
1508
+ "We are a party of 4",
1509
+ source="user_stated",
1510
+ action_relevant=True,
1511
+ entity="reservation",
1512
+ attribute="party_size",
1513
+ )
1514
+ assert await db.factual.recall_for_action("Email me the receipt")
1515
+ assert (
1516
+ await db.factual.recall_for_action("Email me the receipt", entity="user", attribute="email")
1517
+ == []
1518
+ )
1519
+ email = await db.factual.add(
1520
+ "My email is avery.example@example.com",
1521
+ source="user_stated",
1522
+ action_relevant=True,
1523
+ entity="user",
1524
+ attribute="email",
1525
+ )
1526
+ scoped = await db.factual.recall_for_action(
1527
+ "Email me the receipt", entity="customer", attribute="e-mail"
1528
+ )
1529
+ assert [m.id for m in scoped] == [email.id]
1530
+
1531
+ tip = await db.factual.add(
1532
+ "Clinic portal note: the appointment is on Thursday",
1533
+ source="third_party",
1534
+ action_relevant=True,
1535
+ entity="appointment",
1536
+ attribute="day",
1537
+ )
1538
+ assert [m.id for m in await db.factual.pending_confirmations(
1539
+ entity="appointment", attribute="day"
1540
+ )] == [tip.id]
1541
+ assert await db.factual.pending_confirmations(entity="user", attribute="email") == []
1542
+ with pytest.raises(ValueError):
1543
+ await db.factual.recall_for_action("Email me the receipt", entity="user")
1544
+
1545
+
1546
+ async def test_eval_billing_and_personal_email_are_separate_slots(db: ContextDB) -> None:
1547
+ """Storing where invoices go must not erase the user's own email, or the reverse."""
1548
+ personal = await db.factual.add(
1549
+ "My email is avery.example@example.com",
1550
+ source="user_stated",
1551
+ action_relevant=True,
1552
+ entity="user",
1553
+ attribute="email",
1554
+ )
1555
+ billing = await db.factual.add(
1556
+ "Send invoices to billing.example@example.com",
1557
+ source="user_stated",
1558
+ action_relevant=True,
1559
+ entity="customer",
1560
+ attribute="invoice_email",
1561
+ )
1562
+ assert billing.epistemic_source == "user_stated"
1563
+ assert (billing.entity_key, billing.attribute_key, billing.slot_class) == (
1564
+ "user",
1565
+ "billing_email",
1566
+ "contact",
1567
+ )
1568
+ kept = await db.get(personal.id)
1569
+ assert kept is not None
1570
+ assert kept.valid_until is None
1571
+ trusted = {m.id for m in await db.factual.recall_for_action("send the invoice")}
1572
+ assert {personal.id, billing.id} <= trusted
1573
+
1574
+ phone = await db.factual.add(
1575
+ "Call my desk line at +1 555 0100",
1576
+ source="user_stated",
1577
+ action_relevant=True,
1578
+ entity="user",
1579
+ attribute="office_phone",
1580
+ )
1581
+ assert (phone.attribute_key, phone.slot_class) == ("work_phone", "contact")
1582
+
1583
+ mislabelled = await db.factual.add(
1584
+ "My account number is 12345678",
1585
+ source="user_stated",
1586
+ action_relevant=True,
1587
+ entity="user",
1588
+ attribute="billing_email",
1589
+ )
1590
+ assert mislabelled.epistemic_source == "agent_inferred"
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