superlocalmemory 4.0.10 → 4.1.2

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +12 -2
  2. package/CHANGELOG.md +244 -0
  3. package/README.md +40 -75
  4. package/package.json +6 -3
  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 +357 -18
  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 +24 -2
  44. package/src/superlocalmemory/code_graph/database.py +44 -0
  45. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  46. package/src/superlocalmemory/core/admission.py +231 -11
  47. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  48. package/src/superlocalmemory/core/config.py +90 -11
  49. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  50. package/src/superlocalmemory/core/engine.py +140 -11
  51. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  52. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  53. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  54. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  55. package/src/superlocalmemory/core/mode_capability.py +111 -0
  56. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  57. package/src/superlocalmemory/core/projection_drain.py +380 -0
  58. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  59. package/src/superlocalmemory/core/recall_worker.py +6 -3
  60. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  61. package/src/superlocalmemory/core/scale_engine.py +16 -2
  62. package/src/superlocalmemory/core/score_contract.py +21 -1
  63. package/src/superlocalmemory/core/session_identity.py +85 -0
  64. package/src/superlocalmemory/core/status_contract.py +108 -0
  65. package/src/superlocalmemory/core/worker_pool.py +4 -4
  66. package/src/superlocalmemory/core/working_memory.py +288 -0
  67. package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
  68. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  69. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  70. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  71. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  72. package/src/superlocalmemory/encoding/type_router.py +12 -12
  73. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  74. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  75. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  76. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  77. package/src/superlocalmemory/learning/bandit.py +43 -0
  78. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  79. package/src/superlocalmemory/learning/database.py +60 -3
  80. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  81. package/src/superlocalmemory/learning/feedback.py +3 -1
  82. package/src/superlocalmemory/learning/outcomes.py +47 -16
  83. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  84. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  85. package/src/superlocalmemory/learning/pcos.py +291 -0
  86. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  87. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  88. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  89. package/src/superlocalmemory/mcp/profiles.py +14 -2
  90. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  91. package/src/superlocalmemory/mcp/tools_core.py +31 -3
  92. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  93. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  94. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  95. package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
  96. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  97. package/src/superlocalmemory/retrieval/engine.py +106 -11
  98. package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
  99. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  100. package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
  101. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  102. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  103. package/src/superlocalmemory/server/api.py +24 -5
  104. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  105. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  106. package/src/superlocalmemory/server/recall_health.py +87 -10
  107. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  108. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  109. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  110. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  111. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  112. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  113. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  114. package/src/superlocalmemory/server/routes/memories.py +49 -7
  115. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  116. package/src/superlocalmemory/server/routes/v3_api.py +191 -15
  117. package/src/superlocalmemory/server/ui.py +20 -4
  118. package/src/superlocalmemory/server/unified_daemon.py +241 -7
  119. package/src/superlocalmemory/storage/_migration_internals.py +54 -2
  120. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  121. package/src/superlocalmemory/storage/database.py +477 -59
  122. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  123. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  124. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  125. package/src/superlocalmemory/storage/migration_runner.py +119 -0
  126. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
  127. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  128. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  129. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  130. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  131. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  132. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  133. package/src/superlocalmemory/storage/migrations.py +18 -2
  134. package/src/superlocalmemory/storage/models.py +40 -1
  135. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  136. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  137. package/src/superlocalmemory/storage/schema.py +35 -1
  138. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  139. package/src/superlocalmemory/trust/scorer.py +43 -1
  140. package/src/superlocalmemory/ui/index.html +9 -18
  141. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  142. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  143. package/src/superlocalmemory/ui/js/od-memories.js +19 -0
  144. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  145. 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:
@@ -788,13 +1075,12 @@ class DatabaseManager:
788
1075
  ``include_quarantined=True`` is for repair, erasure and export — paths
789
1076
  that must reach a withheld row to act on it.
790
1077
  """
791
- table = f"{prefix}." if prefix else ""
792
- clause = ""
793
- if self._has_archive_status():
794
- clause += f" AND COALESCE({table}archive_status, 'live') != 'archived'"
795
- if not include_quarantined and self._has_quarantine_column():
796
- clause += f" AND COALESCE({table}quarantined, 0) = 0"
797
- return clause
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
+ )
798
1084
 
799
1085
  def get_all_facts(
800
1086
  self, profile_id: str, limit: int | None = None,
@@ -937,6 +1223,10 @@ class DatabaseManager:
937
1223
  # converted store, one fact at a time, undoing the conversion
938
1224
  # wherever a fact is updated.
939
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
940
1230
  elif isinstance(v, (list, dict)):
941
1231
  clean[k] = json.dumps(v)
942
1232
  elif isinstance(v, (MemoryLifecycle, FactType, SignalType)):
@@ -944,25 +1234,49 @@ class DatabaseManager:
944
1234
  else:
945
1235
  clean[k] = v
946
1236
  set_clause = ", ".join(f"{k} = ?" for k in clean)
947
- if profile_id is not None:
948
- self.execute(
949
- f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ? AND profile_id = ?",
950
- (*clean.values(), fact_id, profile_id),
951
- )
952
- else:
953
- self.execute(
954
- f"UPDATE atomic_facts SET {set_clause} WHERE fact_id = ?",
955
- (*clean.values(), fact_id),
956
- )
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)
957
1262
 
958
1263
  def delete_fact(self, fact_id: str, profile_id: str | None = None) -> None:
959
1264
  """Hard-delete a fact.
960
1265
 
961
1266
  DatabaseManager connections enforce FKs (PRAGMA foreign_keys=ON), so
962
- embedding_metadata / fact_retention / edges cascade. The explicit
1267
+ embedding_metadata / fact_retention cascade. The explicit
963
1268
  embedding_metadata delete below is belt-and-suspenders for the case a
964
1269
  future caller routes through a connection without FK enforcement.
965
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
+
966
1280
  Tenant safety: when ``profile_id`` is supplied the delete is constrained
967
1281
  to that tenant (the fact must belong to it), so a fact_id from another
968
1282
  profile cannot be destroyed. Authorized routes always pass it.
@@ -974,14 +1288,37 @@ class DatabaseManager:
974
1288
  )
975
1289
  if not row:
976
1290
  return # not this tenant's fact — no-op
977
- self.execute("DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,))
978
- 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)
979
1301
  self.execute(
980
- "DELETE FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
981
- (fact_id, profile_id),
1302
+ "DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,),
982
1303
  )
983
- else:
984
- self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
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,
1319
+ )
1320
+
1321
+ self._atomically(_write)
985
1322
 
986
1323
  def gc_orphaned_embedding_metadata(self) -> int:
987
1324
  """Remove embedding_metadata rows whose parent atomic_fact is gone.
@@ -1137,30 +1474,59 @@ class DatabaseManager:
1137
1474
  duplicate we keep the MAX weight (strongest association wins) and
1138
1475
  return the existing edge_id.
1139
1476
  """
1140
- existing = self.execute(
1141
- "SELECT edge_id FROM graph_edges "
1142
- "WHERE profile_id = ? AND source_id = ? AND target_id = ? AND edge_type = ? "
1143
- "LIMIT 1",
1144
- (edge.profile_id, edge.source_id, edge.target_id, edge.edge_type.value),
1145
- )
1146
- if existing:
1147
- 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))
1148
1499
  self.execute(
1149
- "UPDATE graph_edges SET weight = MAX(weight, ?) WHERE edge_id = ?",
1150
- (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),
1151
1506
  )
1152
- return canonical_id
1153
- _scope = getattr(edge, 'scope', None) or 'personal'
1154
- _shared = _jd(getattr(edge, 'shared_with', None))
1155
- self.execute(
1156
- """INSERT OR REPLACE INTO graph_edges
1157
- (edge_id, profile_id, source_id, target_id, edge_type, weight, created_at,
1158
- scope, shared_with)
1159
- VALUES (?,?,?,?,?,?,?,?,?)""",
1160
- (edge.edge_id, edge.profile_id, edge.source_id, edge.target_id,
1161
- 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,
1162
1529
  )
1163
- return edge.edge_id
1164
1530
 
1165
1531
  def get_edges_for_node(
1166
1532
  self, node_id: str, profile_id: str,
@@ -1412,6 +1778,58 @@ class DatabaseManager:
1412
1778
  )
1413
1779
  return [self._row_to_fact(r) for r in rows]
1414
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
+
1415
1833
  def store_entity_profile(self, ep: EntityProfile) -> str:
1416
1834
  """Persist an entity profile. Returns profile_entry_id."""
1417
1835
  self.execute(