superlocalmemory 4.0.9 → 4.1.0

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +308 -20
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +26 -4
  44. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  45. package/src/superlocalmemory/code_graph/database.py +44 -0
  46. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  47. package/src/superlocalmemory/core/admission.py +231 -11
  48. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  49. package/src/superlocalmemory/core/config.py +90 -11
  50. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  51. package/src/superlocalmemory/core/engine.py +140 -11
  52. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  53. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  54. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  55. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  56. package/src/superlocalmemory/core/maintenance.py +44 -6
  57. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  58. package/src/superlocalmemory/core/memory_health.py +266 -0
  59. package/src/superlocalmemory/core/mode_capability.py +111 -0
  60. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  61. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  62. package/src/superlocalmemory/core/operation_request.py +1 -1
  63. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  64. package/src/superlocalmemory/core/projection_drain.py +380 -0
  65. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  66. package/src/superlocalmemory/core/recall_worker.py +6 -3
  67. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  68. package/src/superlocalmemory/core/scale_engine.py +16 -2
  69. package/src/superlocalmemory/core/score_contract.py +21 -1
  70. package/src/superlocalmemory/core/session_identity.py +85 -0
  71. package/src/superlocalmemory/core/status_contract.py +108 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  73. package/src/superlocalmemory/core/worker_pool.py +4 -4
  74. package/src/superlocalmemory/core/working_memory.py +288 -0
  75. package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
  76. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  77. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  78. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  79. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  80. package/src/superlocalmemory/encoding/type_router.py +12 -12
  81. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  82. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  83. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  84. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  85. package/src/superlocalmemory/learning/bandit.py +43 -0
  86. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  87. package/src/superlocalmemory/learning/database.py +60 -3
  88. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  89. package/src/superlocalmemory/learning/feedback.py +3 -1
  90. package/src/superlocalmemory/learning/outcomes.py +47 -16
  91. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  92. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  93. package/src/superlocalmemory/learning/pcos.py +291 -0
  94. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  96. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  97. package/src/superlocalmemory/mcp/profiles.py +14 -2
  98. package/src/superlocalmemory/mcp/server.py +1 -1
  99. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  100. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  101. package/src/superlocalmemory/mcp/tools_core.py +71 -42
  102. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  103. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  104. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  105. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  106. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
  107. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  108. package/src/superlocalmemory/retrieval/engine.py +106 -11
  109. package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
  110. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  111. package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
  112. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  113. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  114. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  115. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  116. package/src/superlocalmemory/server/api.py +26 -2
  117. package/src/superlocalmemory/server/asset_versions.py +171 -0
  118. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  119. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  120. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  121. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  122. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  123. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  124. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  125. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  126. package/src/superlocalmemory/server/routes/entity.py +13 -1
  127. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  128. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  129. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  130. package/src/superlocalmemory/server/routes/memories.py +49 -7
  131. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  132. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  133. package/src/superlocalmemory/server/routes/v3_api.py +193 -17
  134. package/src/superlocalmemory/server/ui.py +24 -1
  135. package/src/superlocalmemory/server/unified_daemon.py +292 -9
  136. package/src/superlocalmemory/storage/_migration_internals.py +35 -0
  137. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  138. package/src/superlocalmemory/storage/database.py +598 -82
  139. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  140. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  141. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  142. package/src/superlocalmemory/storage/migration_runner.py +130 -0
  143. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  144. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  145. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  146. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  147. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  148. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  149. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  150. package/src/superlocalmemory/storage/migrations.py +18 -2
  151. package/src/superlocalmemory/storage/models.py +40 -1
  152. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  153. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  154. package/src/superlocalmemory/storage/schema.py +110 -1
  155. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  156. package/src/superlocalmemory/summaries/base.py +1 -1
  157. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  158. package/src/superlocalmemory/trust/scorer.py +43 -1
  159. package/src/superlocalmemory/ui/index.html +10 -19
  160. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  161. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  162. package/src/superlocalmemory/ui/js/od-memories.js +209 -1
  163. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
  164. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  165. package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
@@ -22,7 +22,7 @@ import time
22
22
  from contextlib import contextmanager
23
23
  from pathlib import Path
24
24
  from types import ModuleType
25
- from typing import Any, Generator
25
+ from typing import Any, Generator, NoReturn
26
26
 
27
27
  from superlocalmemory.storage.models import (
28
28
  AtomicFact,
@@ -40,13 +40,33 @@ from superlocalmemory.storage.models import (
40
40
  TemporalEvent,
41
41
  TrustScore,
42
42
  )
43
- from superlocalmemory.storage.embedding_codec import decode_embedding, encode_embedding
43
+ from superlocalmemory.storage.embedding_codec import (
44
+ decode_embedding,
45
+ decode_float_vector,
46
+ encode_embedding,
47
+ encode_float_vector,
48
+ )
44
49
  from superlocalmemory.storage.write_lock import get_write_lock
50
+ from superlocalmemory.storage import projection_outbox
45
51
 
46
52
  logger = logging.getLogger(__name__)
47
53
 
48
54
  _MISSING = object()
49
55
 
56
+
57
+ class ProfileOwnershipConflict(ValueError):
58
+ """A store tried to hand one profile's existing row to another profile.
59
+
60
+ ``memories.memory_id`` and ``atomic_facts.fact_id`` are bare
61
+ ``TEXT PRIMARY KEY`` — global, not per-profile — while every row carries a
62
+ ``profile_id``. So two profiles on one store can be handed the same
63
+ caller-chosen id (an importer keyed on an external record id syncing one
64
+ source into two workspaces does exactly this), and the upsert would
65
+ otherwise rewrite the owner and the content: the first profile's memory
66
+ silently became the second's.
67
+ """
68
+
69
+
50
70
  def _jl(raw: Any, default: Any = _MISSING) -> Any:
51
71
  """JSON-load a value, returning *default* on None/empty.
52
72
 
@@ -146,6 +166,92 @@ def _scope_where(
146
166
  return where, params
147
167
 
148
168
 
169
+ def _compose_visible_clause(
170
+ prefix: str,
171
+ *,
172
+ has_archive: bool,
173
+ has_quarantine: bool,
174
+ include_quarantined: bool = False,
175
+ ) -> str:
176
+ """Build the AND-clause from what the store actually has.
177
+
178
+ Split out from ``DatabaseManager.visible_fact_clause`` so that callers
179
+ holding a bare ``sqlite3.Connection`` produce a byte-identical predicate
180
+ rather than a second, drifting copy.
181
+ """
182
+ table = f"{prefix}." if prefix else ""
183
+ clause = ""
184
+ if has_archive:
185
+ clause += f" AND COALESCE({table}archive_status, 'live') != 'archived'"
186
+ if not include_quarantined and has_quarantine:
187
+ clause += f" AND COALESCE({table}quarantined, 0) = 0"
188
+ return clause
189
+
190
+
191
+ def visible_fact_clause_for_connection(
192
+ conn: sqlite3.Connection,
193
+ prefix: str = "",
194
+ *,
195
+ include_quarantined: bool = False,
196
+ ) -> str:
197
+ """``visible_fact_clause`` for a caller that hand-rolls its SQL.
198
+
199
+ WHY THIS EXISTS. 4.0.10 put the withheld-row filter on every
200
+ ``DatabaseManager`` read path and every retrieval channel, and enumerated
201
+ them in a test. The enumeration was of *methods*, and two HTTP routes never
202
+ call one: ``/api/memories`` and ``/api/v3/timeline/`` build their own SQL
203
+ against ``atomic_facts``. Measured against the author's store on 4.0.10,
204
+ ``/api/memories?limit=50`` served **24 withheld rows on page one** and
205
+ reported a total of 5,218 against 3,919 real memories -- the release's
206
+ central claim, false on the dashboard's main list.
207
+
208
+ So the rule is not "read paths go through DatabaseManager"; plenty of
209
+ reasonable code will not. The rule is that anything answering "what does my
210
+ memory contain" resolves its predicate from here. Presence-guarded on the
211
+ passed connection, because a route may be pointed at a store the engine has
212
+ never opened, and filtering on an absent column would turn a cosmetic gap
213
+ into a 500 on every request.
214
+
215
+ Grep is not a sufficient test for this: ``mcp/tools_active.py`` also selects
216
+ from ``atomic_facts`` directly and was already clean, while these two routes
217
+ were not. The test that matters calls the surface and inspects what it
218
+ served -- tests/test_server/test_a_listed_memory_belongs_to_its_profile.py
219
+ """
220
+ def _column_name(row: object) -> str:
221
+ """PRAGMA row -> column name, whatever row_factory the caller set.
222
+
223
+ Not paranoia: ``server/routes/memories.py`` -- the first caller and the
224
+ route that was leaking -- sets ``row_factory = dict_factory``, so a
225
+ positional ``row[1]`` raises KeyError there, and the except below would
226
+ have swallowed it into "no such column" and silently dropped the filter.
227
+ That is the same shape of failure this function exists to close.
228
+ """
229
+ if isinstance(row, dict):
230
+ return str(row.get("name", ""))
231
+ try:
232
+ return str(row["name"]) # sqlite3.Row
233
+ except (TypeError, IndexError, KeyError):
234
+ pass
235
+ try:
236
+ return str(row[1]) # plain tuple
237
+ except (TypeError, IndexError, KeyError):
238
+ return ""
239
+
240
+ def _has(column: str) -> bool:
241
+ try:
242
+ rows = list(conn.execute("PRAGMA table_info(atomic_facts)"))
243
+ except sqlite3.Error:
244
+ return False
245
+ return any(_column_name(r) == column for r in rows)
246
+
247
+ return _compose_visible_clause(
248
+ prefix,
249
+ has_archive=_has("archive_status"),
250
+ has_quarantine=_has("quarantined"),
251
+ include_quarantined=include_quarantined,
252
+ )
253
+
254
+
149
255
  class DatabaseManager:
150
256
  """Concurrent-safe SQLite manager with WAL, profile isolation, and FTS5.
151
257
 
@@ -442,21 +548,101 @@ class DatabaseManager:
442
548
  # Read-only path: concurrent reads are safe in WAL mode.
443
549
  return self._execute_one(sql, params)
444
550
 
551
+ # The two tables whose primary key is global but whose rows are owned by a
552
+ # profile. Literal, internal, and closed — never built from caller input.
553
+ _OWNED_ROWS: dict[str, str] = {"memories": "memory_id", "atomic_facts": "fact_id"}
554
+
555
+ def _refuse_cross_profile_reown(
556
+ self, table: str, row_id: str, profile_id: str,
557
+ ) -> NoReturn:
558
+ """Raise ``ProfileOwnershipConflict``, naming the profile that owns it.
559
+
560
+ Called only when an upsert below returned no row. Both statements carry
561
+ ``WHERE <table>.profile_id = excluded.profile_id`` on their
562
+ ``DO UPDATE``, so SQLite skips a conflicting row owned by a different
563
+ profile and ``RETURNING`` yields nothing — which makes the ownership
564
+ check part of the write instead of a read in front of it. That costs no
565
+ extra query on the ordinary path and leaves no window between deciding
566
+ and writing. The lookup here runs only on the refusal, to say whose row
567
+ it is; a refusal nobody can read gets worked around.
568
+
569
+ Taking a row away from the profile that owns it is not last-write-wins,
570
+ it is a different tenant's write, so it is refused rather than merged.
571
+ Before this the outcome depended on something unrelated:
572
+ ``scene_fact_members`` carries a composite
573
+ ``(profile_id, fact_id) -> atomic_facts (profile_id, fact_id)`` foreign
574
+ key, so where a scene referenced the fact the ownership change orphaned
575
+ it and SQLite raised a bare ``FOREIGN KEY constraint failed`` — and
576
+ where no scene did, the identical write succeeded in silence and the
577
+ second profile kept the fact. Isolation cannot rest on whether a
578
+ projection happens to exist.
579
+ """
580
+ # Interpolated, not parameterised: SQLite takes no parameter in a table
581
+ # or column position. Both come from _OWNED_ROWS, a closed literal map,
582
+ # and the row id stays bound.
583
+ id_column = self._OWNED_ROWS[table]
584
+ rows = self.execute(
585
+ f"SELECT profile_id FROM {table} WHERE {id_column} = ?",
586
+ (row_id,),
587
+ )
588
+ owner = dict(rows[0])["profile_id"] if rows else "<unknown>"
589
+ raise ProfileOwnershipConflict(
590
+ f"{id_column} {row_id!r} in {table} belongs to profile {owner!r}; "
591
+ f"refusing to re-own it as {profile_id!r}"
592
+ )
593
+
445
594
  def store_memory(self, record: MemoryRecord) -> str:
446
- """Persist a raw memory record. Returns memory_id."""
595
+ """Persist a raw memory record. Returns memory_id.
596
+
597
+ Upserts in place rather than replacing. ``INSERT OR REPLACE`` is a
598
+ DELETE followed by an INSERT, and ``atomic_facts.memory_id`` is a
599
+ foreign key with ``ON DELETE CASCADE`` — so storing a record whose
600
+ ``memory_id`` already existed silently deleted every fact extracted from
601
+ it. Reproduced in isolation: three facts stored, one re-store of the same
602
+ memory_id, zero facts left, no error raised.
603
+
604
+ Most callers pass a freshly generated id, which is why this never fired.
605
+ But ``cognitive_consolidator`` supplies its own ``block_id``, and the
606
+ queryable-promotion path in ``run_store`` deliberately avoids calling
607
+ this at all for an existing memory — a rule that has to be remembered
608
+ rather than enforced. ``ON CONFLICT DO UPDATE`` keeps the same
609
+ last-write-wins semantics and takes the loaded gun out of the room.
610
+
611
+ ``created_at`` is deliberately not overwritten: the row's first
612
+ observation is a historical fact, and a re-store is not a new one.
613
+ ``profile_id`` is not in the update list either, and a store that would
614
+ change it is refused outright — the ``DO UPDATE`` is conditioned on the
615
+ owner matching, so SQLite skips the row and ``RETURNING`` comes back
616
+ empty. See ``_refuse_cross_profile_reown``.
617
+ """
447
618
  _scope = getattr(record, 'scope', None) or 'personal'
448
619
  _shared = _jd(getattr(record, 'shared_with', None))
449
- self.execute(
450
- """INSERT OR REPLACE INTO memories
620
+ written = self.execute(
621
+ """INSERT INTO memories
451
622
  (memory_id, profile_id, content, session_id, speaker,
452
623
  role, session_date, created_at, metadata_json,
453
624
  scope, shared_with)
454
- VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
625
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)
626
+ ON CONFLICT(memory_id) DO UPDATE SET
627
+ content = excluded.content,
628
+ session_id = excluded.session_id,
629
+ speaker = excluded.speaker,
630
+ role = excluded.role,
631
+ session_date = excluded.session_date,
632
+ metadata_json = excluded.metadata_json,
633
+ scope = excluded.scope,
634
+ shared_with = excluded.shared_with
635
+ WHERE memories.profile_id = excluded.profile_id
636
+ RETURNING profile_id""",
455
637
  (record.memory_id, record.profile_id, record.content,
456
638
  record.session_id, record.speaker, record.role,
457
639
  record.session_date, record.created_at,
458
640
  json.dumps(record.metadata), _scope, _shared),
459
641
  )
642
+ if not written:
643
+ self._refuse_cross_profile_reown(
644
+ "memories", record.memory_id, record.profile_id,
645
+ )
460
646
  return record.memory_id
461
647
 
462
648
  def update_memory_summary(self, memory_id: str, summary: str) -> None:
@@ -485,6 +671,22 @@ class DatabaseManager:
485
671
  pass
486
672
  return ""
487
673
 
674
+ def _atomically(self, work: Any) -> Any:
675
+ """Run ``work`` in one transaction, joining an open one rather than nesting.
676
+
677
+ A caller already inside ``transaction()`` must not start a second one:
678
+ the lock is re-entrant but ``_connect`` is not, so a nested attempt opens
679
+ a separate connection to the same file while the first still holds its
680
+ write. ``store_fact`` carried this check inline; it is here because
681
+ three more methods now need the same thing, and a projection intent that
682
+ commits in a different transaction from the row it describes is exactly
683
+ the window this whole mechanism exists to close.
684
+ """
685
+ if getattr(self._txn_state, "conn", None) is not None:
686
+ return work()
687
+ with self.transaction():
688
+ return work()
689
+
488
690
  def store_fact(self, fact: AtomicFact) -> str:
489
691
  """Persist an atomic fact. Returns fact_id.
490
692
 
@@ -499,6 +701,39 @@ class DatabaseManager:
499
701
  twice is one fact" — preventing the duplicate explosion that poisons
500
702
  importance ranking and core-memory promotion. Empty/whitespace
501
703
  content is exempt (handled by placeholder filtering, not dedup).
704
+
705
+ The insert below upserts rather than replaces, for the reason
706
+ ``store_memory`` does. ``INSERT OR REPLACE`` is a DELETE followed by an
707
+ INSERT, and eight tables hold
708
+ ``FOREIGN KEY (fact_id) REFERENCES atomic_facts (fact_id) ON DELETE
709
+ CASCADE`` — so re-storing a fact under an occupied id dropped its
710
+ retention row, access history, context and importance, and raised
711
+ nothing.
712
+
713
+ The dedup above does not close this: it matches on *content*, so a
714
+ second store of the same id with *different* content falls straight
715
+ through to the insert. ``MemoryEngine.store_fact_direct`` reaches it —
716
+ ``canonical_store_fact`` exists to persist a caller-chosen id and
717
+ raises if that id is not preserved. Within one profile an idempotency
718
+ key of ``prebuilt:<fact_id>`` catches the second store, but that key is
719
+ scoped ``(profile_id, source_type, idempotency_key)`` while
720
+ ``atomic_facts.fact_id`` is a bare ``TEXT PRIMARY KEY``. Reproduced
721
+ across two profiles on one store: the first profile's fact was replaced
722
+ outright — new owner, new content — and its associations were gone.
723
+
724
+ ``created_at`` is deliberately not overwritten: the row's first
725
+ observation is a historical fact, and a re-store is not a new one.
726
+ ``pinned`` is not in the column list at all, so the upsert now leaves it
727
+ alone where the replace silently reset it to 0 — pinning is user intent,
728
+ not something a re-store gets to revoke. ``profile_id`` is out of the
729
+ update list for a stronger reason: a store that would change it is
730
+ refused rather than applied, because it is one profile taking another's
731
+ fact rather than a re-store at all. The refusal is a condition on the
732
+ ``DO UPDATE`` itself, so there is no window between checking the owner
733
+ and writing the row.
734
+
735
+ ``insert_fact_immutable`` remains the right call for a known-new fact
736
+ that must abort on a collision rather than win it.
502
737
  """
503
738
  if fact.content and fact.content.strip():
504
739
  # Dedup across all LIVE lifecycle zones (active/warm/cold). Excludes
@@ -533,12 +768,17 @@ class DatabaseManager:
533
768
  # trustworthy observation that the anchor was missing.
534
769
  if self.get_temporal_validity(canonical_id, fact.profile_id) is None:
535
770
  self.store_temporal_validity(canonical_id, fact.profile_id)
771
+ # Re-storing known content is the natural moment to notice a
772
+ # projection that was never written — an upgraded store whose
773
+ # graph predates the migration reaches this branch, not the
774
+ # insert below.
775
+ projection_outbox.enqueue(self, canonical_id, fact.profile_id)
536
776
  return canonical_id
537
777
  _scope = getattr(fact, 'scope', None) or 'personal'
538
778
  _shared = _jd(getattr(fact, 'shared_with', None))
539
779
  def _insert_with_knowledge_anchor() -> None:
540
- self.execute(
541
- """INSERT OR REPLACE INTO atomic_facts
780
+ written = self.execute(
781
+ """INSERT INTO atomic_facts
542
782
  (fact_id, memory_id, profile_id, content, fact_type,
543
783
  entities_json, canonical_entities_json,
544
784
  observation_date, referenced_date, interval_start, interval_end,
@@ -548,7 +788,35 @@ class DatabaseManager:
548
788
  lifecycle, langevin_position,
549
789
  emotional_valence, emotional_arousal, signal_type, created_at,
550
790
  scope, shared_with)
551
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
791
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
792
+ ON CONFLICT(fact_id) DO UPDATE SET
793
+ memory_id = excluded.memory_id,
794
+ content = excluded.content,
795
+ fact_type = excluded.fact_type,
796
+ entities_json = excluded.entities_json,
797
+ canonical_entities_json = excluded.canonical_entities_json,
798
+ observation_date = excluded.observation_date,
799
+ referenced_date = excluded.referenced_date,
800
+ interval_start = excluded.interval_start,
801
+ interval_end = excluded.interval_end,
802
+ confidence = excluded.confidence,
803
+ importance = excluded.importance,
804
+ evidence_count = excluded.evidence_count,
805
+ access_count = excluded.access_count,
806
+ source_turn_ids_json = excluded.source_turn_ids_json,
807
+ session_id = excluded.session_id,
808
+ embedding = excluded.embedding,
809
+ fisher_mean = excluded.fisher_mean,
810
+ fisher_variance = excluded.fisher_variance,
811
+ lifecycle = excluded.lifecycle,
812
+ langevin_position = excluded.langevin_position,
813
+ emotional_valence = excluded.emotional_valence,
814
+ emotional_arousal = excluded.emotional_arousal,
815
+ signal_type = excluded.signal_type,
816
+ scope = excluded.scope,
817
+ shared_with = excluded.shared_with
818
+ WHERE atomic_facts.profile_id = excluded.profile_id
819
+ RETURNING profile_id""",
552
820
  (fact.fact_id, fact.memory_id, fact.profile_id, fact.content,
553
821
  fact.fact_type.value,
554
822
  json.dumps(fact.entities), json.dumps(fact.canonical_entities),
@@ -556,15 +824,29 @@ class DatabaseManager:
556
824
  fact.interval_start, fact.interval_end,
557
825
  fact.confidence, fact.importance, fact.evidence_count, fact.access_count,
558
826
  json.dumps(fact.source_turn_ids), fact.session_id,
559
- encode_embedding(fact.embedding), _jd(fact.fisher_mean), _jd(fact.fisher_variance),
827
+ encode_embedding(fact.embedding),
828
+ encode_float_vector(fact.fisher_mean),
829
+ encode_float_vector(fact.fisher_variance),
560
830
  fact.lifecycle.value, _jd(fact.langevin_position),
561
831
  fact.emotional_valence, fact.emotional_arousal,
562
832
  fact.signal_type.value, fact.created_at, _scope, _shared),
563
833
  )
834
+ if not written:
835
+ self._refuse_cross_profile_reown(
836
+ "atomic_facts", fact.fact_id, fact.profile_id,
837
+ )
564
838
  # Every fact written after 4.0.2 has an explicit transaction-time
565
839
  # anchor. Absence deliberately represents pre-4.0.2
566
840
  # ``legacy_unknown``; never backfill it from ``created_at``.
567
841
  self.store_temporal_validity(fact.fact_id, fact.profile_id)
842
+ # The graph and the vectors live in other storage engines, so the
843
+ # intent to project this fact is queued here, in this transaction.
844
+ # Enqueueing in the storage layer rather than at each pipeline call
845
+ # site is deliberate: ingestion, the background materializer, the
846
+ # consolidator and the CLI all write facts through this method, and
847
+ # a call site that forgets would produce a memory that is stored
848
+ # and unrecallable.
849
+ projection_outbox.enqueue(self, fact.fact_id, fact.profile_id)
568
850
 
569
851
  # The fact and its transaction-time anchor are one logical write. Do
570
852
  # not open a nested transaction when an owner already holds one.
@@ -593,8 +875,12 @@ class DatabaseManager:
593
875
  source_turn_ids=_jl(d.get("source_turn_ids_json")),
594
876
  session_id=d.get("session_id", ""),
595
877
  embedding=decode_embedding(d.get("embedding"), fact_id=d.get("fact_id", "<unknown>")),
596
- fisher_mean=_jl(d.get("fisher_mean"), None),
597
- fisher_variance=_jl(d.get("fisher_variance"), None),
878
+ fisher_mean=decode_float_vector(
879
+ d.get("fisher_mean"), field="fisher_mean",
880
+ fact_id=d.get("fact_id", "<unknown>")),
881
+ fisher_variance=decode_float_vector(
882
+ d.get("fisher_variance"), field="fisher_variance",
883
+ fact_id=d.get("fact_id", "<unknown>")),
598
884
  lifecycle=MemoryLifecycle(d["lifecycle"]) if d.get("lifecycle") else MemoryLifecycle.ACTIVE,
599
885
  langevin_position=_jl(d.get("langevin_position"), None),
600
886
  emotional_valence=d.get("emotional_valence", 0.0),
@@ -647,8 +933,8 @@ class DatabaseManager:
647
933
  json.dumps(fact.source_turn_ids),
648
934
  fact.session_id,
649
935
  encode_embedding(fact.embedding),
650
- _jd(fact.fisher_mean),
651
- _jd(fact.fisher_variance),
936
+ encode_float_vector(fact.fisher_mean),
937
+ encode_float_vector(fact.fisher_variance),
652
938
  fact.lifecycle.value,
653
939
  _jd(fact.langevin_position),
654
940
  fact.emotional_valence,
@@ -660,6 +946,7 @@ class DatabaseManager:
660
946
  ),
661
947
  )
662
948
  self.store_temporal_validity(fact.fact_id, fact.profile_id)
949
+ projection_outbox.enqueue(self, fact.fact_id, fact.profile_id)
663
950
  return fact.fact_id
664
951
 
665
952
  def set_pinned(self, fact_id: str, pinned: bool) -> None:
@@ -686,8 +973,12 @@ class DatabaseManager:
686
973
  include_shared=include_shared,
687
974
  prefix="f",
688
975
  )
976
+ # Pins are injected straight into an agent's context, which makes this
977
+ # the most consequential display path in the class: a withheld row here
978
+ # is not merely shown, it is asserted as background truth.
689
979
  rows = self.execute(
690
980
  f"SELECT f.* FROM atomic_facts f WHERE {where} AND f.pinned = 1 "
981
+ f"{self.visible_fact_clause('f')} "
691
982
  "AND NOT EXISTS ("
692
983
  " SELECT 1 FROM fact_temporal_validity tv "
693
984
  " WHERE tv.fact_id = f.fact_id "
@@ -733,6 +1024,64 @@ class DatabaseManager:
733
1024
  self._archive_col_present = True
734
1025
  return present
735
1026
 
1027
+ def _has_quarantine_column(self) -> bool:
1028
+ """Whether atomic_facts carries the ``quarantined`` column.
1029
+
1030
+ Same shape as ``_has_archive_status``: cached once True (a column never
1031
+ disappears), re-checked while absent so a later schema pass is picked
1032
+ up. ``storage.schema.create_all_tables`` adds the column at every engine
1033
+ init, so on any store the daemon has opened this is True — the guard
1034
+ exists for a bare DatabaseManager pointed at a store that engine init
1035
+ never touched, where filtering on the column would raise instead of
1036
+ returning results.
1037
+ """
1038
+ if getattr(self, "_quarantine_col_present", False):
1039
+ return True
1040
+ present = any(
1041
+ dict(row).get("name") == "quarantined"
1042
+ for row in self.execute("PRAGMA table_info(atomic_facts)")
1043
+ )
1044
+ if present:
1045
+ self._quarantine_col_present = True
1046
+ return present
1047
+
1048
+ def visible_fact_clause(
1049
+ self, prefix: str = "", *, include_quarantined: bool = False,
1050
+ ) -> str:
1051
+ """AND-clause excluding rows no caller should be shown as a memory.
1052
+
1053
+ Two exclusions, one definition: soft-deleted (``archive_status``) and
1054
+ withheld (``quarantined``). Both are presence-guarded, because each
1055
+ column arrives with a migration and may be absent on a store the engine
1056
+ has not opened.
1057
+
1058
+ WHY THIS EXISTS AS A FUNCTION. 4.0.10 first put the quarantine filter in
1059
+ ``get_facts_by_ids`` alone, reasoning that every retrieval channel
1060
+ re-authorises through it and the engine drops what it cannot hydrate.
1061
+ That reasoning was correct and the conclusion was wrong: it covered the
1062
+ RECALL pipeline, and ``search``, ``list_recent``, ``fetch``, the MCP
1063
+ resources and the dashboard's own search are not the recall pipeline.
1064
+ Measured on a copy of the author's store, ``search_facts_fts`` returned
1065
+ 20 withheld rows out of 50 and ``get_all_facts`` 66 out of 400 — the
1066
+ exact defect the design was meant to prevent, in the paths the design
1067
+ never looked at.
1068
+
1069
+ There is no single SQL choke point in this codebase; ``_scope_where`` is
1070
+ spliced against six other tables and cannot carry a fact column. So the
1071
+ honest form of "one place" is one CLAUSE with an enumerable set of call
1072
+ sites, and a test that fails when a read path does not use it:
1073
+ tests/test_storage/test_no_read_path_shows_a_withheld_row.py
1074
+
1075
+ ``include_quarantined=True`` is for repair, erasure and export — paths
1076
+ that must reach a withheld row to act on it.
1077
+ """
1078
+ return _compose_visible_clause(
1079
+ prefix,
1080
+ has_archive=self._has_archive_status(),
1081
+ has_quarantine=self._has_quarantine_column(),
1082
+ include_quarantined=include_quarantined,
1083
+ )
1084
+
736
1085
  def get_all_facts(
737
1086
  self, profile_id: str, limit: int | None = None,
738
1087
  *,
@@ -755,14 +1104,10 @@ class DatabaseManager:
755
1104
  # hard, env-tunable ceiling even when the caller passes limit=None.
756
1105
  if limit is None:
757
1106
  limit = _unbounded_facts_ceiling()
758
- # Archived facts are not live; never surface them in direct reads.
759
- archive_clause = (
760
- " AND COALESCE(archive_status, 'live') != 'archived'"
761
- if self._has_archive_status()
762
- else ""
763
- )
1107
+ # Soft-deleted and withheld rows are not memories a caller may see.
764
1108
  rows = self.execute(
765
- f"SELECT * FROM atomic_facts WHERE {where}{archive_clause} "
1109
+ f"SELECT * FROM atomic_facts WHERE {where}"
1110
+ f"{self.visible_fact_clause()} "
766
1111
  "ORDER BY created_at DESC LIMIT ?",
767
1112
  (*params, int(limit)),
768
1113
  )
@@ -790,14 +1135,12 @@ class DatabaseManager:
790
1135
  include_global=include_global,
791
1136
  include_shared=include_shared,
792
1137
  )
793
- archive_clause = (
794
- " AND COALESCE(archive_status, 'live') != 'archived'"
795
- if self._has_archive_status()
796
- else ""
797
- )
1138
+ # Crossing a profile boundary is the last place a withheld row should
1139
+ # appear: it would be a model's non-answer presented to somebody else
1140
+ # as one of this profile's shared memories.
798
1141
  rows = self.execute(
799
1142
  f"SELECT * FROM atomic_facts WHERE {where} AND profile_id != ?"
800
- f"{archive_clause} ORDER BY created_at DESC",
1143
+ f"{self.visible_fact_clause()} ORDER BY created_at DESC",
801
1144
  (*params, profile_id),
802
1145
  )
803
1146
  return [self._row_to_fact(r) for r in rows]
@@ -880,6 +1223,10 @@ class DatabaseManager:
880
1223
  # converted store, one fact at a time, undoing the conversion
881
1224
  # wherever a fact is updated.
882
1225
  clean[k] = encode_embedding(v) if v is not None else None
1226
+ elif k in ("fisher_mean", "fisher_variance"):
1227
+ # Same hazard, same answer: these are float vectors of the same
1228
+ # width as the embedding and are stored the same way.
1229
+ clean[k] = encode_float_vector(v) if v is not None else None
883
1230
  elif isinstance(v, (list, dict)):
884
1231
  clean[k] = json.dumps(v)
885
1232
  elif isinstance(v, (MemoryLifecycle, FactType, SignalType)):
@@ -887,25 +1234,49 @@ class DatabaseManager:
887
1234
  else:
888
1235
  clean[k] = v
889
1236
  set_clause = ", ".join(f"{k} = ?" for k in clean)
890
- if profile_id is not None:
891
- self.execute(
892
- f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ? AND profile_id = ?",
893
- (*clean.values(), fact_id, profile_id),
894
- )
895
- else:
896
- self.execute(
897
- f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ?",
898
- (*clean.values(), fact_id),
899
- )
1237
+
1238
+ def _write() -> None:
1239
+ if profile_id is not None:
1240
+ self.execute(
1241
+ f"UPDATE atomic_facts SET {set_clause} "
1242
+ "WHERE fact_id = ? AND profile_id = ?",
1243
+ (*clean.values(), fact_id, profile_id),
1244
+ )
1245
+ else:
1246
+ self.execute(
1247
+ f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ?",
1248
+ (*clean.values(), fact_id),
1249
+ )
1250
+ # Only an update that changes something a projection is derived
1251
+ # from needs re-projecting. Recall bumps access_count on every hit,
1252
+ # so queueing on any update at all would hand the drain worker one
1253
+ # row per returned memory per recall, for a column neither Cozo nor
1254
+ # Lance holds.
1255
+ if set(updates) & projection_outbox.PROJECTED_FACT_COLUMNS:
1256
+ if profile_id is not None:
1257
+ projection_outbox.enqueue(self, fact_id, profile_id)
1258
+ else:
1259
+ projection_outbox.enqueue_for_fact(self, fact_id)
1260
+
1261
+ self._atomically(_write)
900
1262
 
901
1263
  def delete_fact(self, fact_id: str, profile_id: str | None = None) -> None:
902
1264
  """Hard-delete a fact.
903
1265
 
904
1266
  DatabaseManager connections enforce FKs (PRAGMA foreign_keys=ON), so
905
- embedding_metadata / fact_retention / edges cascade. The explicit
1267
+ embedding_metadata / fact_retention cascade. The explicit
906
1268
  embedding_metadata delete below is belt-and-suspenders for the case a
907
1269
  future caller routes through a connection without FK enforcement.
908
1270
 
1271
+ ``graph_edges`` does NOT cascade, whatever this docstring used to say.
1272
+ Its only foreign key is to ``profiles``; there is none to
1273
+ ``atomic_facts``, because an edge's endpoints can be entity ids as well
1274
+ as fact ids and a single column cannot reference two tables. So the
1275
+ edges are deleted here, explicitly. Without that, deleting a memory left
1276
+ its connections behind pointing at nothing, and the graph tidy-up pass
1277
+ would not reach them until its next run -- during which a search could
1278
+ still follow an edge into a memory that no longer exists.
1279
+
909
1280
  Tenant safety: when ``profile_id`` is supplied the delete is constrained
910
1281
  to that tenant (the fact must belong to it), so a fact_id from another
911
1282
  profile cannot be destroyed. Authorized routes always pass it.
@@ -917,14 +1288,37 @@ class DatabaseManager:
917
1288
  )
918
1289
  if not row:
919
1290
  return # not this tenant's fact — no-op
920
- self.execute("DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,))
921
- if profile_id is not None:
1291
+ # A forgotten memory that survives in the graph or the vector index is
1292
+ # still recallable, which makes an erasure receipt a false statement. So
1293
+ # the deletes and the queued removal are one transaction: the process
1294
+ # can die immediately afterwards and the projections still get cleaned.
1295
+ def _write() -> None:
1296
+ # Read the tenant while the fact is still there. After the DELETE
1297
+ # there is nothing left to resolve it from, and a queued removal
1298
+ # filed under the wrong tenant is a fact id that outlives that
1299
+ # tenant's erasure.
1300
+ owner = profile_id or projection_outbox.resolve_profile(self, fact_id)
922
1301
  self.execute(
923
- "DELETE FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
924
- (fact_id, profile_id),
1302
+ "DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,),
1303
+ )
1304
+ if profile_id is not None:
1305
+ self.execute(
1306
+ "DELETE FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
1307
+ (fact_id, profile_id),
1308
+ )
1309
+ else:
1310
+ self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
1311
+ # Both directions: an edge naming this fact at either end is now an
1312
+ # edge to nothing.
1313
+ self.execute(
1314
+ "DELETE FROM graph_edges WHERE source_id = ? OR target_id = ?",
1315
+ (fact_id, fact_id),
1316
+ )
1317
+ projection_outbox.enqueue(
1318
+ self, fact_id, owner or "default", projection_outbox.OP_DELETE,
925
1319
  )
926
- else:
927
- self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
1320
+
1321
+ self._atomically(_write)
928
1322
 
929
1323
  def gc_orphaned_embedding_metadata(self) -> int:
930
1324
  """Remove embedding_metadata rows whose parent atomic_fact is gone.
@@ -953,14 +1347,21 @@ class DatabaseManager:
953
1347
  include_global: bool = False,
954
1348
  include_shared: bool = False,
955
1349
  ) -> int:
956
- """Total fact count for a profile."""
1350
+ """Memories this profile has, as the owner would count them.
1351
+
1352
+ Counts what a caller can be shown, which is why it applies
1353
+ ``visible_fact_clause``. It fed the dashboard's "All memories 5,093" and
1354
+ was counting 1,195 withheld summaries and every soft-deleted row into
1355
+ that figure -- a number the owner reads as "how much do I remember".
1356
+ """
957
1357
  where, params = _scope_where(
958
1358
  profile_id,
959
1359
  include_global=include_global,
960
1360
  include_shared=include_shared,
961
1361
  )
962
1362
  rows = self.execute(
963
- f"SELECT COUNT(*) AS c FROM atomic_facts WHERE {where}", (*params,),
1363
+ f"SELECT COUNT(*) AS c FROM atomic_facts WHERE {where}"
1364
+ f"{self.visible_fact_clause()}", (*params,),
964
1365
  )
965
1366
  return int(rows[0]["c"]) if rows else 0
966
1367
 
@@ -1073,30 +1474,59 @@ class DatabaseManager:
1073
1474
  duplicate we keep the MAX weight (strongest association wins) and
1074
1475
  return the existing edge_id.
1075
1476
  """
1076
- existing = self.execute(
1077
- "SELECT edge_id FROM graph_edges "
1078
- "WHERE profile_id = ? AND source_id = ? AND target_id = ? AND edge_type = ? "
1079
- "LIMIT 1",
1080
- (edge.profile_id, edge.source_id, edge.target_id, edge.edge_type.value),
1081
- )
1082
- if existing:
1083
- canonical_id = dict(existing[0])["edge_id"]
1477
+ # The edge and the re-projection of its endpoints are one logical write.
1478
+ # Left as separate statements they were three separate commits, which
1479
+ # cost three fsyncs on a path the materializer runs tens of thousands of
1480
+ # times, and left a window where the edge was durable and the intent to
1481
+ # project it was not.
1482
+ def _write() -> str:
1483
+ existing = self.execute(
1484
+ "SELECT edge_id FROM graph_edges "
1485
+ "WHERE profile_id = ? AND source_id = ? AND target_id = ? AND edge_type = ? "
1486
+ "LIMIT 1",
1487
+ (edge.profile_id, edge.source_id, edge.target_id, edge.edge_type.value),
1488
+ )
1489
+ if existing:
1490
+ canonical_id = dict(existing[0])["edge_id"]
1491
+ self.execute(
1492
+ "UPDATE graph_edges SET weight = MAX(weight, ?) WHERE edge_id = ?",
1493
+ (edge.weight, canonical_id),
1494
+ )
1495
+ self._enqueue_edge_endpoints(edge)
1496
+ return canonical_id
1497
+ _scope = getattr(edge, 'scope', None) or 'personal'
1498
+ _shared = _jd(getattr(edge, 'shared_with', None))
1084
1499
  self.execute(
1085
- "UPDATE graph_edges SET weight = MAX(weight, ?) WHERE edge_id = ?",
1086
- (edge.weight, canonical_id),
1500
+ """INSERT OR REPLACE INTO graph_edges
1501
+ (edge_id, profile_id, source_id, target_id, edge_type, weight, created_at,
1502
+ scope, shared_with)
1503
+ VALUES (?,?,?,?,?,?,?,?,?)""",
1504
+ (edge.edge_id, edge.profile_id, edge.source_id, edge.target_id,
1505
+ edge.edge_type.value, edge.weight, edge.created_at, _scope, _shared),
1087
1506
  )
1088
- return canonical_id
1089
- _scope = getattr(edge, 'scope', None) or 'personal'
1090
- _shared = _jd(getattr(edge, 'shared_with', None))
1091
- self.execute(
1092
- """INSERT OR REPLACE INTO graph_edges
1093
- (edge_id, profile_id, source_id, target_id, edge_type, weight, created_at,
1094
- scope, shared_with)
1095
- VALUES (?,?,?,?,?,?,?,?,?)""",
1096
- (edge.edge_id, edge.profile_id, edge.source_id, edge.target_id,
1097
- edge.edge_type.value, edge.weight, edge.created_at, _scope, _shared),
1507
+ self._enqueue_edge_endpoints(edge)
1508
+ return edge.edge_id
1509
+
1510
+ return self._atomically(_write)
1511
+
1512
+ def _enqueue_edge_endpoints(self, edge: GraphEdge) -> None:
1513
+ """Re-queue both ends of an edge for projection.
1514
+
1515
+ Ingestion is queryable-first: a fact commits immediately and its edges
1516
+ arrive afterwards, from the background materializer. A projection
1517
+ queued only when the fact was inserted would therefore be written
1518
+ before a single edge existed, leaving the node in the graph with none
1519
+ of its connections — which is precisely the adjacency the graph is
1520
+ consulted for.
1521
+
1522
+ An endpoint may be an entity id rather than a fact id. Those are queued
1523
+ too and the drain skips whatever it cannot find as a fact; filtering
1524
+ here would mean a lookup per endpoint on every edge write, which the
1525
+ materializer does tens of thousands of times.
1526
+ """
1527
+ projection_outbox.enqueue_many(
1528
+ self, (edge.source_id, edge.target_id), edge.profile_id,
1098
1529
  )
1099
- return edge.edge_id
1100
1530
 
1101
1531
  def get_edges_for_node(
1102
1532
  self, node_id: str, profile_id: str,
@@ -1230,16 +1660,15 @@ class DatabaseManager:
1230
1660
  include_shared=include_shared,
1231
1661
  prefix="f",
1232
1662
  )
1233
- # Archived facts must not surface via full-text search either.
1234
- archive_clause = (
1235
- " AND COALESCE(f.archive_status, 'live') != 'archived'"
1236
- if self._has_archive_status()
1237
- else ""
1238
- )
1663
+ # Full-text search is a display path: the dashboard search box, the
1664
+ # `search` tool and `fetch` all land here, and none of them go through
1665
+ # the recall engine. Before 4.0.10 put the clause here it returned 20
1666
+ # withheld rows out of 50 on the author's store.
1239
1667
  rows = self.execute(
1240
1668
  f"""SELECT f.* FROM atomic_facts_fts AS fts
1241
1669
  JOIN atomic_facts AS f ON f.fact_id = fts.fact_id
1242
- WHERE fts.atomic_facts_fts MATCH ? AND {where}{archive_clause}
1670
+ WHERE fts.atomic_facts_fts MATCH ? AND {where}
1671
+ {self.visible_fact_clause('f')}
1243
1672
  ORDER BY fts.rank LIMIT ?""",
1244
1673
  (match_expr, *params, limit),
1245
1674
  )
@@ -1270,12 +1699,22 @@ class DatabaseManager:
1270
1699
  # ------------------------------------------------------------------
1271
1700
 
1272
1701
  def get_fact(self, fact_id: str, profile_id: str | None = None) -> AtomicFact | None:
1273
- """Get a single fact by ID.
1702
+ """Get a single row by ID, exactly as stored. NOT a display path.
1274
1703
 
1275
1704
  C4 defense-in-depth: when ``profile_id`` is provided the lookup is
1276
1705
  tenant-scoped so a fact_id from another profile cannot resolve. Left
1277
1706
  optional (fact_id is a random UUID sourced from already-scoped queries)
1278
1707
  to avoid destabilizing the core store/consolidation write path.
1708
+
1709
+ DELIBERATELY UNFILTERED, and this is load-bearing. It applies neither
1710
+ ``archive_status`` nor ``quarantined`` because it is the primitive that
1711
+ write paths, correction handling and the 4.0.10 repair use to read a row
1712
+ they already hold the id of — including a withheld one, which they must
1713
+ be able to see in order to act on it. ``visible_fact_clause`` is for the
1714
+ paths that answer a question; this one answers "what is in that row".
1715
+
1716
+ A caller taking a fact_id from user input and rendering the result wants
1717
+ ``get_facts_by_ids`` instead.
1279
1718
  """
1280
1719
  if profile_id is not None:
1281
1720
  rows = self.execute(
@@ -1292,8 +1731,36 @@ class DatabaseManager:
1292
1731
  self, fact_ids: list[str], profile_id: str,
1293
1732
  include_global: bool = False,
1294
1733
  include_shared: bool = False,
1734
+ *,
1735
+ include_quarantined: bool = False,
1295
1736
  ) -> list[AtomicFact]:
1296
- """Get multiple facts by their IDs, scoped to a profile."""
1737
+ """Get multiple facts by their IDs, scoped to a profile.
1738
+
1739
+ THIS IS THE PLACE QUARANTINE IS ENFORCED, and the only one.
1740
+
1741
+ Every retrieval channel re-authorises its candidates through here
1742
+ (``retrieval/scope_policy.py`` — "candidate generators may use caches,
1743
+ approximate indexes, or graph stores that are not the authorization
1744
+ source of truth"), and the engine hydrates the fused set from here too.
1745
+ A fact this method does not return has no content to show, and
1746
+ ``retrieval/engine.py`` drops it: ``if fact is None: continue``. So one
1747
+ clause here covers bm25, semantic, temporal, entity, hopfield and
1748
+ spreading activation, in normal and deep recall alike, whether or not
1749
+ the forgetting filter is registered.
1750
+
1751
+ The alternatives were checked and rejected. ``_scope_where`` looks like
1752
+ the natural home but is spliced against ``graph_edges``,
1753
+ ``temporal_events``, ``memories``, ``bm25_tokens``,
1754
+ ``fact_temporal_validity`` and ``correction_cases`` as well as
1755
+ ``atomic_facts``, so a column reference there breaks eight call sites.
1756
+ ``ForgettingFilter`` is optional (it no-ops when forgetting is
1757
+ disabled) and excludes nothing in deep recall.
1758
+
1759
+ ``include_quarantined=True`` is for repair, export and erasure — paths
1760
+ that must be able to see a withheld row in order to act on it. It is
1761
+ keyword-only and greppable on purpose: every caller that opts in is
1762
+ meant to be found in one search.
1763
+ """
1297
1764
  if not fact_ids:
1298
1765
  return []
1299
1766
  where, params = _scope_where(
@@ -1301,19 +1768,68 @@ class DatabaseManager:
1301
1768
  include_global=include_global,
1302
1769
  include_shared=include_shared,
1303
1770
  )
1304
- archive_clause = (
1305
- " AND COALESCE(archive_status, 'live') != 'archived'"
1306
- if self._has_archive_status()
1307
- else ""
1308
- )
1309
1771
  placeholders = ",".join("?" for _ in fact_ids)
1310
1772
  rows = self.execute(
1311
1773
  f"SELECT * FROM atomic_facts WHERE fact_id IN ({placeholders}) "
1312
- f"AND {where}{archive_clause} ORDER BY created_at DESC",
1774
+ f"AND {where}"
1775
+ f"{self.visible_fact_clause(include_quarantined=include_quarantined)} "
1776
+ "ORDER BY created_at DESC",
1313
1777
  (*fact_ids, *params),
1314
1778
  )
1315
1779
  return [self._row_to_fact(r) for r in rows]
1316
1780
 
1781
+ def visible_fact_ids(
1782
+ self, fact_ids: list[str] | tuple[str, ...], profile_id: str,
1783
+ include_global: bool = False,
1784
+ include_shared: bool = False,
1785
+ *,
1786
+ include_quarantined: bool = False,
1787
+ ) -> set[str]:
1788
+ """Which of *fact_ids* this profile may see. Same rule, no hydration.
1789
+
1790
+ ``get_facts_by_ids`` is the authorization source of truth and every
1791
+ retrieval channel re-authorises through it — but a channel deciding
1792
+ *which* of its candidates are allowed does not need their content, and
1793
+ paying for the content is most of what recall costs.
1794
+
1795
+ Measured on the author's store: the entity channel authorised 3,659
1796
+ candidates to return 20, and that single call was **374 ms of a 430 ms
1797
+ recall — 87%**. Not the graph walk, which is 3.8 ms. The cost is
1798
+ ``_row_to_fact`` decoding a 768-float embedding and two 768-float Fisher
1799
+ vectors per row: about 8.4 million floats deserialised to answer a
1800
+ yes/no question about 3,659 ids. The same trap is recorded a few
1801
+ hundred lines up, where loading full facts "turned one new fact into a
1802
+ 5-second recall stall".
1803
+
1804
+ The predicate is built by the same two calls as ``get_facts_by_ids``, in
1805
+ the same order, so the two cannot answer differently. A test asserts
1806
+ that on the same inputs. Batched because the id list is unbounded and a
1807
+ single ``IN`` clause is not.
1808
+ """
1809
+ if not fact_ids:
1810
+ return set()
1811
+ where, params = _scope_where(
1812
+ profile_id,
1813
+ include_global=include_global,
1814
+ include_shared=include_shared,
1815
+ )
1816
+ visible = self.visible_fact_clause(include_quarantined=include_quarantined)
1817
+ unique = list(dict.fromkeys(fact_ids))
1818
+ allowed: set[str] = set()
1819
+ # Well inside SQLITE_MAX_VARIABLE_NUMBER once the scope parameters are
1820
+ # added, on every build this ships against.
1821
+ chunk = 800
1822
+ for start in range(0, len(unique), chunk):
1823
+ batch = unique[start:start + chunk]
1824
+ placeholders = ",".join("?" for _ in batch)
1825
+ rows = self.execute(
1826
+ f"SELECT fact_id FROM atomic_facts WHERE fact_id IN ({placeholders}) "
1827
+ f"AND {where}{visible}",
1828
+ (*batch, *params),
1829
+ )
1830
+ allowed.update(dict(row)["fact_id"] for row in rows)
1831
+ return allowed
1832
+
1317
1833
  def store_entity_profile(self, ep: EntityProfile) -> str:
1318
1834
  """Persist an entity profile. Returns profile_entry_id."""
1319
1835
  self.execute(