pycontextdb 0.4.0__tar.gz → 0.4.1__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.0 → pycontextdb-0.4.1}/CHANGELOG.md +7 -0
  2. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/PKG-INFO +1 -1
  3. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/__init__.py +1 -1
  4. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/client.py +48 -17
  5. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/dynamics/trust.py +31 -7
  6. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/memory_evolution.md +17 -6
  7. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/pyproject.toml +1 -1
  8. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_memory_evolution.py +184 -1
  9. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/.github/CODEOWNERS +0 -0
  10. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/.github/copilot-instructions.md +0 -0
  11. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/.github/pull_request_template.md +0 -0
  12. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/.github/workflows/ci.yml +0 -0
  13. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/.github/workflows/publish.yml +0 -0
  14. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/.gitignore +0 -0
  15. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/.pre-commit-config.yaml +0 -0
  16. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/COMMERCIAL.md +0 -0
  17. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/CONTRIBUTING.md +0 -0
  18. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/LICENSE +0 -0
  19. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/NOTICE +0 -0
  20. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/OPEN_CORE.md +0 -0
  21. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/README.md +0 -0
  22. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/ai-tools/.cursorrules +0 -0
  23. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/ai-tools/CLAUDE.md +0 -0
  24. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/ai-tools/README.md +0 -0
  25. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/ai-tools/llms-full.txt +0 -0
  26. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/ai-tools/llms.txt +0 -0
  27. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/assets/README.md +0 -0
  28. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/assets/architecture.png +0 -0
  29. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/assets/benchmarks.png +0 -0
  30. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/assets/hero.png +0 -0
  31. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/assets/performance.png +0 -0
  32. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/assets/problem.png +0 -0
  33. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/assets/quickstart.png +0 -0
  34. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/benchmarks/__init__.py +0 -0
  35. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/benchmarks/run_benchmarks.py +0 -0
  36. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/benchmarks/trust_bakeoff.py +0 -0
  37. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/agents/__init__.py +0 -0
  38. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/agents/memory_bus.py +0 -0
  39. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/agents/rl_manager.py +0 -0
  40. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/cli.py +0 -0
  41. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/connectors/__init__.py +0 -0
  42. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/connectors/base.py +0 -0
  43. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/connectors/postgres.py +0 -0
  44. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/core/__init__.py +0 -0
  45. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/core/clock.py +0 -0
  46. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/core/config.py +0 -0
  47. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/core/exceptions.py +0 -0
  48. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/core/models.py +0 -0
  49. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/core/policy.py +0 -0
  50. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/core/slots.py +0 -0
  51. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/dynamics/__init__.py +0 -0
  52. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/dynamics/evolution.py +0 -0
  53. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/dynamics/formation.py +0 -0
  54. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/dynamics/retrieval.py +0 -0
  55. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/dynamics/salience.py +0 -0
  56. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/graphs/__init__.py +0 -0
  57. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/graphs/base.py +0 -0
  58. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/graphs/causal.py +0 -0
  59. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/graphs/entity.py +0 -0
  60. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/graphs/semantic.py +0 -0
  61. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/graphs/temporal.py +0 -0
  62. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/integrations/__init__.py +0 -0
  63. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/integrations/act.py +0 -0
  64. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/integrations/autogen.py +0 -0
  65. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/integrations/crewai.py +0 -0
  66. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/integrations/langchain.py +0 -0
  67. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/integrations/livekit.py +0 -0
  68. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/integrations/openai_tools.py +0 -0
  69. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/integrations/pipecat.py +0 -0
  70. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/integrations/prompting.py +0 -0
  71. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/mcp.py +0 -0
  72. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/memory/__init__.py +0 -0
  73. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/memory/experiential.py +0 -0
  74. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/memory/factual.py +0 -0
  75. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/memory/working.py +0 -0
  76. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/pool.py +0 -0
  77. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/privacy/__init__.py +0 -0
  78. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/privacy/audit.py +0 -0
  79. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/privacy/injection.py +0 -0
  80. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/privacy/pii_detector.py +0 -0
  81. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/privacy/retention.py +0 -0
  82. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/py.typed +0 -0
  83. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/serve.py +0 -0
  84. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/store/__init__.py +0 -0
  85. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/store/base.py +0 -0
  86. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/store/factory.py +0 -0
  87. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/store/pg_sql.py +0 -0
  88. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/store/postgres_store.py +0 -0
  89. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/store/sqlite_store.py +0 -0
  90. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/store/vector_index.py +0 -0
  91. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/utils/__init__.py +0 -0
  92. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/utils/embeddings.py +0 -0
  93. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/utils/llm.py +0 -0
  94. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/contextdb/utils/migrations.py +0 -0
  95. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/api.md +0 -0
  96. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/architecture.md +0 -0
  97. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/connectors.md +0 -0
  98. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/index.md +0 -0
  99. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/migrations.md +0 -0
  100. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/multi_tenant.md +0 -0
  101. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/postgres.md +0 -0
  102. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/prompts.md +0 -0
  103. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/quickstart.md +0 -0
  104. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/serve.md +0 -0
  105. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/docs/trust_policy.md +0 -0
  106. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/examples/customer_support.py +0 -0
  107. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/examples/multi_agent_team.py +0 -0
  108. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/examples/phone_agent.py +0 -0
  109. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/examples/research_assistant.py +0 -0
  110. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/legal/CLA-entity.md +0 -0
  111. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/legal/CLA-individual.md +0 -0
  112. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/legal/signed/README.md +0 -0
  113. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/mkdocs.yml +0 -0
  114. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/scripts/check_open_core_boundary.py +0 -0
  115. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/scripts/check_release.py +0 -0
  116. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/__init__.py +0 -0
  117. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/conftest.py +0 -0
  118. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/evals/__init__.py +0 -0
  119. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/evals/test_trust_model.py +0 -0
  120. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/integration/__init__.py +0 -0
  121. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/integration/test_client.py +0 -0
  122. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/integration/test_langchain_integration.py +0 -0
  123. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/pg_util.py +0 -0
  124. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/test_version.py +0 -0
  125. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/__init__.py +0 -0
  126. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_audit.py +0 -0
  127. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_config.py +0 -0
  128. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_connectors.py +0 -0
  129. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_decision_scope.py +0 -0
  130. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_embeddings.py +0 -0
  131. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_evolution.py +0 -0
  132. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_formation.py +0 -0
  133. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_graphs.py +0 -0
  134. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_host_api.py +0 -0
  135. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_init.py +0 -0
  136. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_memory_bus.py +0 -0
  137. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_models.py +0 -0
  138. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_open_core_boundary.py +0 -0
  139. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_pg_sql.py +0 -0
  140. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_pii_detector.py +0 -0
  141. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_postgres_concurrency.py +0 -0
  142. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_postgres_connector.py +0 -0
  143. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_postgres_memory_evolution.py +0 -0
  144. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_retention.py +0 -0
  145. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_retrieval.py +0 -0
  146. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_scoped_id_access.py +0 -0
  147. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_serve.py +0 -0
  148. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_store.py +0 -0
  149. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_trust_policy.py +0 -0
  150. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_vector_index.py +0 -0
  151. {pycontextdb-0.4.0 → pycontextdb-0.4.1}/tests/unit/test_vector_scope.py +0 -0
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.1 — 2026-08-24
6
+
7
+ - NOOP audit entries now carry the existing memory ID and canonical slot
8
+ identifiers when a duplicate, explicit target, or unambiguous slot resolves
9
+ to a current memory. Ambiguous slot-only NOOPs fail closed, while all NOOPs
10
+ remain content-free and leave the memory version unchanged.
11
+
5
12
  ## 0.4.0 — 2026-08-23
6
13
 
7
14
  - Explicit deterministic `add` / `update` / `delete` / `noop` memory
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pycontextdb
3
- Version: 0.4.0
3
+ Version: 0.4.1
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
@@ -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.0"
44
+ __version__ = "0.4.1"
45
45
 
46
46
  __all__ = [
47
47
  "Clock",
@@ -639,13 +639,26 @@ class ContextDB:
639
639
  attribute=attribute,
640
640
  )
641
641
  if self._audit is not None:
642
+ details: dict[str, object] = {
643
+ "operation": EvolutionOperation.NOOP.value,
644
+ "reason": safe_reason,
645
+ }
646
+ verified_slot = (
647
+ canonicalize_slot(
648
+ verified.entity_key,
649
+ verified.attribute_key,
650
+ )
651
+ if verified is not None
652
+ else None
653
+ )
654
+ if verified_slot is not None:
655
+ details["entity"] = verified_slot.entity
656
+ details["attribute"] = verified_slot.attribute
642
657
  await self._audit.log(
643
658
  operation="NOOP",
659
+ memory_id=verified.id if verified is not None else None,
644
660
  user_id=uid,
645
- details={
646
- "operation": EvolutionOperation.NOOP.value,
647
- "reason": safe_reason,
648
- },
661
+ details=details,
649
662
  )
650
663
  token = await store.consistency_token()
651
664
  return MemoryEvolutionResult(
@@ -890,12 +903,20 @@ class ContextDB:
890
903
  target = await self._evolution_target(
891
904
  target_memory_id,
892
905
  user_id=user_id,
893
- require_current=False,
906
+ require_current=True,
894
907
  )
895
908
 
896
909
  slot = canonicalize_slot(entity, attribute)
897
- if slot is not None:
898
- if target is not None:
910
+ has_slot_reference = bool(entity and entity.strip()) and bool(
911
+ attribute and attribute.strip()
912
+ )
913
+ if has_slot_reference and slot is None:
914
+ raise EvolutionTargetRequiredError(
915
+ "NOOP entity/attribute must resolve to a slot"
916
+ )
917
+
918
+ if target is not None:
919
+ if slot is not None:
899
920
  target_slot = canonicalize_slot(
900
921
  target.entity_key,
901
922
  target.attribute_key,
@@ -907,17 +928,27 @@ class ContextDB:
907
928
  raise EvolutionOperationConflictError(
908
929
  "target memory and requested entity/attribute identify different slots"
909
930
  )
910
- rows = await self._require_store().list_by_slot(
911
- slot.entity,
912
- slot.attribute,
913
- user_id=user_id,
931
+ return target
932
+
933
+ if slot is None:
934
+ return None
935
+
936
+ rows = await self._require_store().list_by_slot(
937
+ slot.entity,
938
+ slot.attribute,
939
+ user_id=user_id,
940
+ )
941
+ current_at = self.clock()
942
+ current = [row for row in rows if row.is_valid_at(current_at)]
943
+ if not current:
944
+ raise EvolutionTargetNotFoundError(
945
+ f"{slot.entity}/{slot.attribute}"
914
946
  )
915
- current = [row for row in rows if row.is_valid_at(self.clock())]
916
- if not current:
917
- raise EvolutionTargetNotFoundError(
918
- f"{slot.entity}/{slot.attribute}"
919
- )
920
- return target
947
+ if len(current) != 1:
948
+ raise EvolutionOperationConflictError(
949
+ "slot NOOP is ambiguous; pass target_memory_id"
950
+ )
951
+ return current[0]
921
952
 
922
953
  async def _evolve_delete_transactional(
923
954
  self,
@@ -225,8 +225,11 @@ class TrustEngine:
225
225
  speakers = list(candidate.corroborated_by)
226
226
  if incoming_speaker in speakers:
227
227
  await self._log_noop(
228
+ candidate.id,
228
229
  user_id,
229
230
  reason="same_speaker_same_value",
231
+ entity=item.entity_key,
232
+ attribute=item.attribute_key,
230
233
  )
231
234
  return candidate, "noop"
232
235
  speakers.append(incoming_speaker)
@@ -495,7 +498,13 @@ class TrustEngine:
495
498
  speakers = list(candidate.corroborated_by)
496
499
  if incoming_speaker in speakers:
497
500
  reason = "same_speaker_same_value"
498
- await self._log_noop(user_id, reason=reason)
501
+ await self._log_noop(
502
+ candidate.id,
503
+ user_id,
504
+ reason=reason,
505
+ entity=slot.entity,
506
+ attribute=slot.attribute,
507
+ )
499
508
  return EvolutionWrite(
500
509
  applied_operation=EvolutionOperation.NOOP,
501
510
  outcome=EvolutionOutcome.NOOP,
@@ -543,7 +552,13 @@ class TrustEngine:
543
552
  speakers = list(candidate.corroborated_by)
544
553
  if incoming_speaker in speakers:
545
554
  reason = "same_speaker_same_value"
546
- await self._log_noop(user_id, reason=reason)
555
+ await self._log_noop(
556
+ candidate.id,
557
+ user_id,
558
+ reason=reason,
559
+ entity=slot.entity,
560
+ attribute=slot.attribute,
561
+ )
547
562
  return EvolutionWrite(
548
563
  applied_operation=EvolutionOperation.NOOP,
549
564
  outcome=EvolutionOutcome.NOOP,
@@ -768,17 +783,26 @@ class TrustEngine:
768
783
 
769
784
  async def _log_noop(
770
785
  self,
786
+ memory_id: str,
771
787
  user_id: str | None,
772
788
  *,
773
789
  reason: str,
790
+ entity: str | None,
791
+ attribute: str | None,
774
792
  ) -> None:
775
- """Append a content-free NOOP audit without touching memory state."""
793
+ """Append scoped, content-free NOOP lineage without mutating memory."""
776
794
  if self.audit is not None:
795
+ details: dict[str, object] = {
796
+ "operation": EvolutionOperation.NOOP.value,
797
+ "reason": reason,
798
+ }
799
+ slot = canonicalize_slot(entity, attribute)
800
+ if slot is not None:
801
+ details["entity"] = slot.entity
802
+ details["attribute"] = slot.attribute
777
803
  await self.audit.log(
778
804
  operation="NOOP",
805
+ memory_id=memory_id,
779
806
  user_id=user_id,
780
- details={
781
- "operation": EvolutionOperation.NOOP.value,
782
- "reason": reason,
783
- },
807
+ details=details,
784
808
  )
@@ -33,9 +33,10 @@ canonicalized, and injection-shaped content is demoted.
33
33
 
34
34
  For an empty slot, ADD stores a new memory. If the same speaker repeats the
35
35
  same canonical slot value, the applied operation is NOOP: no row is inserted
36
- or updated and the memory version does not advance. The audit entry contains
37
- only the fixed operation and reason. A new independent speaker asserting the
38
- same value corroborates the current memory and applies as UPDATE.
36
+ or updated and the memory version does not advance. The audit entry points to
37
+ the existing memory and contains only the fixed operation and reason plus its
38
+ canonical entity and attribute identifiers. A new independent speaker
39
+ asserting the same value corroborates the current memory and applies as UPDATE.
39
40
 
40
41
  ADD fails with `EvolutionOperationConflictError` when the slot already holds a
41
42
  different value. Use UPDATE to correct an occupied slot.
@@ -83,9 +84,19 @@ the broader `forget_user()` and `verify_forgotten()` contracts.
83
84
 
84
85
  ## NOOP
85
86
 
86
- NOOP requires a nonempty reason of at most 256 characters. Optional target or
87
- slot references are scope-checked. The reason is passed through the configured
88
- PII processor before the content-free `NOOP` audit entry is appended.
87
+ NOOP requires a nonempty reason of at most 256 characters. An optional
88
+ `target_memory_id` must resolve to a current memory in the caller's scope. A
89
+ slot-only reference resolves only when exactly one current occupant exists;
90
+ an ambiguous contested slot raises `EvolutionOperationConflictError` and
91
+ requires an explicit target. The resolved memory is returned and its ID is
92
+ attached to the audit entry. A NOOP without a target or slot remains valid,
93
+ returns no memory, and has no audit `memory_id`.
94
+
95
+ The reason is passed through the configured PII processor and bounded to 256
96
+ characters before the content-free `NOOP` audit entry is appended. When a
97
+ memory resolves to a slot, the details include only the operation, processed
98
+ reason, and canonical entity and attribute identifiers. They never include
99
+ memory content, old or new values, raw user IDs, or PII annotation originals.
89
100
 
90
101
  NOOP does not change memory rows, vectors, or the memory version. Its result
91
102
  token therefore reports the same memory version visible before the operation
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycontextdb"
7
- version = "0.4.0"
7
+ version = "0.4.1"
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"
@@ -181,10 +181,12 @@ async def test_explicit_add_same_speaker_is_true_noop(
181
181
  assert [row.id for row in rows_after] == [row.id for row in rows_before]
182
182
  assert client.audit is not None
183
183
  noop = [entry for entry in await client.audit.get_history() if entry.operation == "NOOP"][-1]
184
- assert noop.memory_id is None
184
+ assert noop.memory_id == first.memory.id
185
185
  assert noop.details == {
186
186
  "operation": "noop",
187
187
  "reason": "same_speaker_same_value",
188
+ "entity": "profile",
189
+ "attribute": "color",
188
190
  }
189
191
 
190
192
 
@@ -374,6 +376,174 @@ async def test_target_ids_are_scoped_and_missing_is_indistinguishable(
374
376
  )
375
377
  assert await client._require_store().get_raw(alice.memory.id) is not None
376
378
 
379
+ version = (await client.consistency_token()).memory_version
380
+ for target in (alice.memory.id, "missing-memory"):
381
+ with pytest.raises(EvolutionTargetNotFoundError):
382
+ await client.factual.evolve(
383
+ "noop",
384
+ target_memory_id=target,
385
+ noop_reason="already checked",
386
+ user_id="bob",
387
+ )
388
+ assert (await client.consistency_token()).memory_version == version
389
+ assert client.audit is not None
390
+ assert not [
391
+ entry
392
+ for entry in await client.audit.get_history(user_id="bob")
393
+ if entry.operation == "NOOP"
394
+ ]
395
+
396
+
397
+ async def test_targeted_noop_returns_lineage_without_private_audit_values(
398
+ client: ContextDB,
399
+ ) -> None:
400
+ raw_content = "The profile color is blue for SSN 123-45-6789"
401
+ raw_user_id = "alice-private-user"
402
+ added = await client.factual.evolve(
403
+ "add",
404
+ raw_content,
405
+ source="user_stated",
406
+ entity="Profile",
407
+ attribute="Color",
408
+ user_id=raw_user_id,
409
+ )
410
+ assert added.memory is not None
411
+ assert added.memory.pii_annotations
412
+ version = added.consistency_token.memory_version
413
+
414
+ result = await client.factual.evolve(
415
+ "noop",
416
+ target_memory_id=added.memory.id,
417
+ user_id=raw_user_id,
418
+ noop_reason="duplicate reported by reviewer@example.com",
419
+ )
420
+
421
+ assert result.memory is not None
422
+ assert result.memory.id == added.memory.id
423
+ assert result.consistency_token.memory_version == version
424
+ assert result.noop_reason == "duplicate reported by [EMAIL]"
425
+ assert client.audit is not None
426
+ entry = [
427
+ entry
428
+ for entry in await client.audit.get_history(memory_id=added.memory.id)
429
+ if entry.operation == "NOOP"
430
+ ][-1]
431
+ assert entry.memory_id == added.memory.id
432
+ assert entry.details == {
433
+ "operation": "noop",
434
+ "reason": "duplicate reported by [EMAIL]",
435
+ "entity": "profile",
436
+ "attribute": "color",
437
+ }
438
+ serialized_details = str(entry.details)
439
+ for private_value in (
440
+ raw_content,
441
+ "123-45-6789",
442
+ "reviewer@example.com",
443
+ raw_user_id,
444
+ added.memory.content,
445
+ added.memory.pii_annotations[0].original,
446
+ ):
447
+ assert private_value not in serialized_details
448
+ assert {"content", "old_value", "new_value", "user_id", "pii_annotations"}.isdisjoint(
449
+ entry.details
450
+ )
451
+
452
+
453
+ async def test_slot_noop_returns_unambiguous_current_memory(
454
+ client: ContextDB,
455
+ ) -> None:
456
+ added = await client.factual.evolve(
457
+ "add",
458
+ "The user has a peanut allergy",
459
+ source="user_stated",
460
+ entity="Customer",
461
+ attribute="Allergies",
462
+ user_id="alice",
463
+ )
464
+ assert added.memory is not None
465
+ version = added.consistency_token.memory_version
466
+
467
+ result = await client.factual.evolve(
468
+ "noop",
469
+ entity="CUSTOMER",
470
+ attribute="ALLERGIES",
471
+ user_id="alice",
472
+ noop_reason="already verified",
473
+ )
474
+
475
+ assert result.memory is not None
476
+ assert result.memory.id == added.memory.id
477
+ assert result.consistency_token.memory_version == version
478
+ assert client.audit is not None
479
+ entry = [
480
+ entry
481
+ for entry in await client.audit.get_history(memory_id=added.memory.id)
482
+ if entry.operation == "NOOP"
483
+ ][-1]
484
+ assert entry.details == {
485
+ "operation": "noop",
486
+ "reason": "already verified",
487
+ "entity": "user",
488
+ "attribute": "allergy",
489
+ }
490
+
491
+
492
+ async def test_slot_noop_rejects_ambiguous_contested_slot(
493
+ client: ContextDB,
494
+ ) -> None:
495
+ first = await client.factual.evolve(
496
+ "add",
497
+ "The meeting is at 3pm",
498
+ source="user_stated",
499
+ entity="meeting",
500
+ attribute="time",
501
+ user_id="alice",
502
+ )
503
+ assert first.memory is not None
504
+ other = contextdb.init(
505
+ config=client.config,
506
+ session_id="independent-session",
507
+ )
508
+ try:
509
+ contested = await other.factual.evolve(
510
+ "update",
511
+ "The meeting is at 4pm",
512
+ source="user_stated",
513
+ entity="meeting",
514
+ attribute="time",
515
+ user_id="alice",
516
+ )
517
+ assert contested.outcome == EvolutionOutcome.CONTESTED
518
+ version = contested.consistency_token.memory_version
519
+ assert other.audit is not None
520
+ noops_before = [
521
+ entry
522
+ for entry in await other.audit.get_history(user_id="alice")
523
+ if entry.operation == "NOOP"
524
+ ]
525
+
526
+ with pytest.raises(EvolutionOperationConflictError, match="ambiguous"):
527
+ await other.factual.evolve(
528
+ "noop",
529
+ entity="meeting",
530
+ attribute="time",
531
+ user_id="alice",
532
+ noop_reason="already handled",
533
+ )
534
+
535
+ assert (await other.consistency_token()).memory_version == version
536
+ noops_after = [
537
+ entry
538
+ for entry in await other.audit.get_history(user_id="alice")
539
+ if entry.operation == "NOOP"
540
+ ]
541
+ assert [entry.id for entry in noops_after] == [
542
+ entry.id for entry in noops_before
543
+ ]
544
+ finally:
545
+ await other.close()
546
+
377
547
 
378
548
  async def test_explicit_noop_is_pii_safe_and_does_not_mutate_store(
379
549
  client: ContextDB,
@@ -454,3 +624,16 @@ async def test_existing_add_return_type_and_duplicate_id_are_compatible(
454
624
  assert isinstance(duplicate, MemoryItem)
455
625
  assert duplicate.id == first.id
456
626
  assert (await client.consistency_token()).memory_version == version
627
+ assert client.audit is not None
628
+ noop = [
629
+ entry
630
+ for entry in await client.audit.get_history(memory_id=first.id)
631
+ if entry.operation == "NOOP"
632
+ ][-1]
633
+ assert noop.memory_id == first.id
634
+ assert noop.details == {
635
+ "operation": "noop",
636
+ "reason": "same_speaker_same_value",
637
+ "entity": "profile",
638
+ "attribute": "color",
639
+ }
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
File without changes