ltcai 3.5.0 → 4.0.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 (181) hide show
  1. package/README.md +73 -35
  2. package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
  3. package/docs/CHANGELOG.md +32 -0
  4. package/docs/HANDOVER_v3.6.0.md +46 -0
  5. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
  6. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  8. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  9. package/docs/architecture.md +13 -12
  10. package/docs/kg-schema.md +102 -53
  11. package/docs/privacy.md +18 -2
  12. package/docs/security-model.md +17 -0
  13. package/kg_schema.py +139 -10
  14. package/knowledge_graph.py +874 -26
  15. package/knowledge_graph_api.py +11 -127
  16. package/latticeai/__init__.py +1 -1
  17. package/latticeai/api/admin.py +1 -1
  18. package/latticeai/api/agents.py +7 -1
  19. package/latticeai/api/auth.py +27 -4
  20. package/latticeai/api/browser.py +217 -0
  21. package/latticeai/api/chat.py +112 -76
  22. package/latticeai/api/health.py +1 -1
  23. package/latticeai/api/hooks.py +1 -1
  24. package/latticeai/api/knowledge_graph.py +146 -0
  25. package/latticeai/api/local_files.py +1 -1
  26. package/latticeai/api/mcp.py +23 -11
  27. package/latticeai/api/memory.py +1 -1
  28. package/latticeai/api/models.py +1 -1
  29. package/latticeai/api/network.py +81 -0
  30. package/latticeai/api/portability.py +93 -0
  31. package/latticeai/api/realtime.py +1 -1
  32. package/latticeai/api/search.py +26 -2
  33. package/latticeai/api/security_dashboard.py +2 -3
  34. package/latticeai/api/setup.py +2 -2
  35. package/latticeai/api/static_routes.py +2 -4
  36. package/latticeai/api/tools.py +3 -0
  37. package/latticeai/api/workflow_designer.py +46 -0
  38. package/latticeai/api/workspace.py +71 -49
  39. package/latticeai/app_factory.py +1710 -0
  40. package/latticeai/brain/__init__.py +18 -0
  41. package/latticeai/brain/context.py +213 -0
  42. package/latticeai/brain/conversations.py +236 -0
  43. package/latticeai/brain/identity.py +175 -0
  44. package/latticeai/brain/memory.py +102 -0
  45. package/latticeai/brain/network.py +205 -0
  46. package/latticeai/core/agent.py +31 -7
  47. package/latticeai/core/audit.py +0 -7
  48. package/latticeai/core/config.py +1 -1
  49. package/latticeai/core/context_builder.py +1 -2
  50. package/latticeai/core/enterprise.py +1 -1
  51. package/latticeai/core/graph_curator.py +2 -2
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/mcp_registry.py +791 -0
  54. package/latticeai/core/model_compat.py +1 -1
  55. package/latticeai/core/model_resolution.py +0 -1
  56. package/latticeai/core/multi_agent.py +238 -4
  57. package/latticeai/core/security.py +1 -1
  58. package/latticeai/core/sessions.py +37 -7
  59. package/latticeai/core/workflow_engine.py +114 -2
  60. package/latticeai/core/workspace_os.py +58 -10
  61. package/latticeai/models/__init__.py +7 -0
  62. package/latticeai/models/router.py +779 -0
  63. package/latticeai/server_app.py +29 -1504
  64. package/latticeai/services/agent_runtime.py +1 -0
  65. package/latticeai/services/app_context.py +75 -14
  66. package/latticeai/services/ingestion.py +318 -0
  67. package/latticeai/services/kg_portability.py +207 -0
  68. package/latticeai/services/memory_service.py +39 -11
  69. package/latticeai/services/model_runtime.py +2 -5
  70. package/latticeai/services/platform_runtime.py +100 -23
  71. package/latticeai/services/search_service.py +17 -8
  72. package/latticeai/services/tool_dispatch.py +12 -2
  73. package/latticeai/services/triggers.py +241 -0
  74. package/latticeai/services/upload_service.py +37 -12
  75. package/latticeai/services/workspace_service.py +31 -0
  76. package/llm_router.py +29 -772
  77. package/ltcai_cli.py +1 -2
  78. package/mcp_registry.py +25 -788
  79. package/p_reinforce.py +124 -14
  80. package/package.json +11 -8
  81. package/scripts/build_vsix.mjs +72 -0
  82. package/scripts/bump_version.py +99 -0
  83. package/scripts/generate_diagrams.py +0 -1
  84. package/scripts/lint_v3.mjs +82 -18
  85. package/scripts/validate_release_artifacts.py +0 -1
  86. package/scripts/wheel_smoke.py +142 -0
  87. package/server.py +11 -7
  88. package/setup_wizard.py +1142 -0
  89. package/static/account.html +2 -4
  90. package/static/admin.html +3 -5
  91. package/static/chat.html +3 -6
  92. package/static/graph.html +2 -4
  93. package/static/sw.js +81 -52
  94. package/static/v3/asset-manifest.json +20 -19
  95. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  96. package/static/v3/css/lattice.base.css +1 -1
  97. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  98. package/static/v3/css/lattice.components.css +1 -1
  99. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  100. package/static/v3/css/lattice.shell.css +1 -1
  101. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  102. package/static/v3/css/lattice.tokens.css +3 -0
  103. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  104. package/static/v3/css/lattice.views.css +2 -2
  105. package/static/v3/index.html +3 -4
  106. package/static/v3/js/{app.d086489d.js → app.356e6452.js} +1 -1
  107. package/static/v3/js/core/{api.12b568ad.js → api.7a308b89.js} +39 -1
  108. package/static/v3/js/core/api.js +38 -0
  109. package/static/v3/js/core/{routes.d214b399.js → routes.7222343d.js} +22 -22
  110. package/static/v3/js/core/routes.js +22 -22
  111. package/static/v3/js/core/{shell.d05266f5.js → shell.a1657f20.js} +4 -4
  112. package/static/v3/js/core/shell.js +1 -1
  113. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  114. package/static/v3/js/core/store.js +1 -1
  115. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  116. package/static/v3/js/views/graph-canvas.js +509 -0
  117. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  118. package/static/v3/js/views/hybrid-search.js +1 -2
  119. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +509 -0
  120. package/static/v3/js/views/knowledge-graph.js +326 -54
  121. package/static/vendor/chart.umd.min.js +20 -0
  122. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  123. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  124. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  125. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  126. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  127. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  128. package/static/vendor/fonts/inter.css +44 -0
  129. package/static/vendor/icons/tabler-icons.min.css +4 -0
  130. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  131. package/static/vendor/marked.min.js +69 -0
  132. package/static/workspace.html +2 -2
  133. package/telegram_bot.py +1 -2
  134. package/tools/commands.py +4 -2
  135. package/tools/computer.py +1 -1
  136. package/tools/documents.py +1 -3
  137. package/tools/filesystem.py +0 -4
  138. package/tools/knowledge.py +1 -3
  139. package/tools/network.py +1 -3
  140. package/codex_telegram_bot.py +0 -195
  141. package/docs/assets/v3.4.0/agent-run.png +0 -0
  142. package/docs/assets/v3.4.0/agents.png +0 -0
  143. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  144. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  145. package/docs/assets/v3.4.0/chat.png +0 -0
  146. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  147. package/docs/assets/v3.4.0/files.png +0 -0
  148. package/docs/assets/v3.4.0/home.png +0 -0
  149. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  150. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  151. package/docs/assets/v3.4.0/local-agent.png +0 -0
  152. package/docs/assets/v3.4.0/memory.png +0 -0
  153. package/docs/assets/v3.4.0/settings.png +0 -0
  154. package/docs/assets/v3.4.0/vision-input.png +0 -0
  155. package/docs/assets/v3.4.0/workflows.png +0 -0
  156. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  157. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  158. package/docs/assets/v3.4.1/local-agent.png +0 -0
  159. package/docs/images/admin-dashboard.png +0 -0
  160. package/docs/images/architecture.png +0 -0
  161. package/docs/images/enterprise.png +0 -0
  162. package/docs/images/graph.png +0 -0
  163. package/docs/images/hero.gif +0 -0
  164. package/docs/images/knowledge-graph.png +0 -0
  165. package/docs/images/lattice-ai-demo.gif +0 -0
  166. package/docs/images/lattice-ai-hero.png +0 -0
  167. package/docs/images/logo.svg +0 -33
  168. package/docs/images/mobile-responsive.png +0 -0
  169. package/docs/images/model-recommendation.png +0 -0
  170. package/docs/images/onboarding.png +0 -0
  171. package/docs/images/organization.png +0 -0
  172. package/docs/images/pipeline.png +0 -0
  173. package/docs/images/screenshot-admin.png +0 -0
  174. package/docs/images/screenshot-chat.png +0 -0
  175. package/docs/images/screenshot-graph.png +0 -0
  176. package/docs/images/skills.png +0 -0
  177. package/docs/images/workspace-dark.png +0 -0
  178. package/docs/images/workspace-light.png +0 -0
  179. package/docs/images/workspace.png +0 -0
  180. package/requirements.txt +0 -16
  181. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
@@ -0,0 +1,241 @@
1
+ """Trigger system (T7d) — workflows fire beyond 'manual'.
2
+
3
+ Two real trigger types:
4
+
5
+ * **interval** — a supervised scheduler loop fires the workflow every
6
+ ``interval_seconds``. Firings missed while the server was down are
7
+ SKIPPED with a recorded skip event (design-review amendment: no silent
8
+ gaps, no thundering catch-up).
9
+ * **brain_event** — the killer Digital Brain feature: "when new knowledge
10
+ enters the brain, run this workflow". Wired through the existing hooks
11
+ bus (the ingestion pipeline fires ``post_tool`` on ``kg_ingest.<source>``);
12
+ an optional ``source_type`` filter narrows it.
13
+
14
+ Trigger-fired runs carry provenance: their inputs include ``__trigger__``
15
+ describing what fired them, persisted in the run record like any other
16
+ input.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import threading
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Dict, List, Optional
27
+
28
+ DEFAULT_TICK_SECONDS = 5.0
29
+ MIN_INTERVAL_SECONDS = 60
30
+
31
+ TRIGGER_HOOK_NAME = "brain-event-triggers"
32
+
33
+
34
+ class TriggerService:
35
+ """Scans workflow definitions for non-manual triggers and fires them."""
36
+
37
+ def __init__(
38
+ self,
39
+ *,
40
+ store: Any,
41
+ run_workflow: Callable[[str, Dict[str, Any]], Dict[str, Any]],
42
+ data_dir: Path,
43
+ clock: Callable[[], float] = time.time,
44
+ tick_seconds: float = DEFAULT_TICK_SECONDS,
45
+ ) -> None:
46
+ self._store = store
47
+ self._run_workflow = run_workflow
48
+ self._state_file = Path(data_dir) / "triggers_state.json"
49
+ self._clock = clock
50
+ self._tick = float(tick_seconds)
51
+ self._stop_event = threading.Event()
52
+ self._thread: Optional[threading.Thread] = None
53
+ self._lock = threading.Lock()
54
+
55
+ # ── durable state ──────────────────────────────────────────────────────
56
+ def _load_state(self) -> Dict[str, Any]:
57
+ if not self._state_file.exists():
58
+ return {}
59
+ try:
60
+ return json.loads(self._state_file.read_text(encoding="utf-8"))
61
+ except Exception:
62
+ return {}
63
+
64
+ def _save_state(self, state: Dict[str, Any]) -> None:
65
+ self._state_file.parent.mkdir(parents=True, exist_ok=True)
66
+ tmp = self._state_file.with_suffix(".tmp")
67
+ tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
68
+ tmp.replace(self._state_file)
69
+
70
+ def _record_event(self, state: Dict[str, Any], workflow_id: str, event: Dict[str, Any]) -> None:
71
+ entry = state.setdefault(workflow_id, {})
72
+ events = entry.setdefault("events", [])
73
+ events.append({**event, "at": self._clock()})
74
+ entry["events"] = events[-50:]
75
+
76
+ # ── definition scanning ────────────────────────────────────────────────
77
+ def _triggered_workflows(self) -> List[Dict[str, Any]]:
78
+ found = []
79
+ try:
80
+ workflows = list(self._store.load_state().get("workflows") or [])
81
+ except Exception:
82
+ return []
83
+ for wf in workflows:
84
+ for node in wf.get("nodes") or []:
85
+ if node.get("type") != "trigger":
86
+ continue
87
+ cfg = node.get("config") or {}
88
+ kind = str(cfg.get("trigger") or "manual")
89
+ if kind in ("interval", "brain_event"):
90
+ found.append({"workflow": wf, "node": node, "kind": kind, "config": cfg})
91
+ return found
92
+
93
+ def describe(self) -> Dict[str, Any]:
94
+ """Honest status surface: what is armed, when it last fired/skipped."""
95
+ state = self._load_state()
96
+ armed = []
97
+ for item in self._triggered_workflows():
98
+ wf_id = item["workflow"].get("id")
99
+ armed.append({
100
+ "workflow_id": wf_id,
101
+ "name": item["workflow"].get("name"),
102
+ "kind": item["kind"],
103
+ "config": {k: v for k, v in item["config"].items() if k != "trigger"},
104
+ "last_fired_at": (state.get(wf_id) or {}).get("last_fired_at"),
105
+ "recent_events": (state.get(wf_id) or {}).get("events", [])[-5:],
106
+ })
107
+ return {
108
+ "running": bool(self._thread and self._thread.is_alive()),
109
+ "tick_seconds": self._tick,
110
+ "armed": armed,
111
+ }
112
+
113
+ # ── interval scheduling ────────────────────────────────────────────────
114
+ def reconcile_missed(self) -> int:
115
+ """Startup pass: record (never replay) firings missed while down."""
116
+ now = self._clock()
117
+ skipped = 0
118
+ with self._lock:
119
+ state = self._load_state()
120
+ for item in self._triggered_workflows():
121
+ if item["kind"] != "interval":
122
+ continue
123
+ wf_id = item["workflow"].get("id")
124
+ interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
125
+ entry = state.setdefault(wf_id, {})
126
+ last = entry.get("last_fired_at")
127
+ if last is not None and now - float(last) > interval:
128
+ missed = int((now - float(last)) // interval)
129
+ self._record_event(state, wf_id, {
130
+ "type": "skipped",
131
+ "reason": f"{missed} interval firing(s) missed while the server was down",
132
+ })
133
+ skipped += missed
134
+ # Reset the cadence from now — no catch-up storm.
135
+ entry["last_fired_at"] = now if last is not None else entry.get("last_fired_at")
136
+ self._save_state(state)
137
+ return skipped
138
+
139
+ def tick_intervals(self) -> int:
140
+ """One scheduler pass; returns how many workflows fired."""
141
+ now = self._clock()
142
+ fired = 0
143
+ with self._lock:
144
+ state = self._load_state()
145
+ for item in self._triggered_workflows():
146
+ if item["kind"] != "interval":
147
+ continue
148
+ wf_id = item["workflow"].get("id")
149
+ interval = max(MIN_INTERVAL_SECONDS, int(item["config"].get("interval_seconds") or 0))
150
+ entry = state.setdefault(wf_id, {})
151
+ last = entry.get("last_fired_at")
152
+ if last is None:
153
+ # First sighting arms the schedule; it fires one interval later.
154
+ entry["last_fired_at"] = now
155
+ continue
156
+ if now - float(last) < interval:
157
+ continue
158
+ entry["last_fired_at"] = now
159
+ self._record_event(state, wf_id, {"type": "fired", "trigger": "interval"})
160
+ fired += 1
161
+ self._fire(wf_id, {
162
+ "type": "interval",
163
+ "interval_seconds": interval,
164
+ "fired_at": now,
165
+ })
166
+ self._save_state(state)
167
+ return fired
168
+
169
+ # ── brain events ───────────────────────────────────────────────────────
170
+ def on_brain_event(self, event: str, payload: Optional[Dict[str, Any]] = None) -> int:
171
+ """Fire workflows whose brain_event trigger matches this ingestion."""
172
+ payload = payload or {}
173
+ source_type = str(payload.get("source_type") or event.split(".", 1)[-1] or "")
174
+ fired = 0
175
+ with self._lock:
176
+ state = self._load_state()
177
+ for item in self._triggered_workflows():
178
+ if item["kind"] != "brain_event":
179
+ continue
180
+ wanted = str(item["config"].get("source_type") or "").strip()
181
+ if wanted and wanted != source_type:
182
+ continue
183
+ wf_id = item["workflow"].get("id")
184
+ state.setdefault(wf_id, {})["last_fired_at"] = self._clock()
185
+ self._record_event(state, wf_id, {
186
+ "type": "fired", "trigger": "brain_event", "source_type": source_type,
187
+ })
188
+ fired += 1
189
+ self._fire(wf_id, {
190
+ "type": "brain_event",
191
+ "event": event,
192
+ "source_type": source_type,
193
+ "node_id": payload.get("node_id"),
194
+ })
195
+ self._save_state(state)
196
+ return fired
197
+
198
+ def hook_runner(self):
199
+ """A post_tool hook runner: ingestion events fan into triggers."""
200
+ def runner(context):
201
+ event = str(getattr(context, "event", "") or "")
202
+ if not event.startswith("kg_ingest."):
203
+ return {"status": "ok", "output": "not an ingestion event"}
204
+ payload = context.payload if isinstance(context.payload, dict) else {}
205
+ fired = self.on_brain_event(event, payload)
206
+ return {"status": "ok", "output": f"fired {fired} workflow trigger(s)"}
207
+ return runner
208
+
209
+ # ── execution + lifecycle ──────────────────────────────────────────────
210
+ def _fire(self, workflow_id: str, trigger_info: Dict[str, Any]) -> None:
211
+ def _run():
212
+ try:
213
+ self._run_workflow(workflow_id, {"__trigger__": trigger_info})
214
+ except Exception as exc:
215
+ logging.warning("trigger run failed for %s: %s", workflow_id, exc)
216
+
217
+ threading.Thread(target=_run, name=f"trigger-{workflow_id}", daemon=True).start()
218
+
219
+ def start(self) -> None:
220
+ if self._thread and self._thread.is_alive():
221
+ return
222
+ self.reconcile_missed()
223
+ self._stop_event.clear()
224
+
225
+ def _loop():
226
+ while not self._stop_event.wait(self._tick):
227
+ try:
228
+ self.tick_intervals()
229
+ except Exception as exc:
230
+ logging.warning("trigger scheduler tick failed: %s", exc)
231
+
232
+ self._thread = threading.Thread(target=_loop, name="trigger-scheduler", daemon=True)
233
+ self._thread.start()
234
+
235
+ def stop(self) -> None:
236
+ self._stop_event.set()
237
+ if self._thread:
238
+ self._thread.join(timeout=2)
239
+
240
+
241
+ __all__ = ["TriggerService", "TRIGGER_HOOK_NAME", "MIN_INTERVAL_SECONDS"]
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
 
10
10
  from fastapi import HTTPException, Request, UploadFile
11
11
 
12
+ from latticeai.services.ingestion import IngestionItem
12
13
  from tools import ToolError, read_document
13
14
 
14
15
 
@@ -19,6 +20,7 @@ async def process_uploaded_document(
19
20
  current_user: str,
20
21
  enable_graph: bool,
21
22
  knowledge_graph,
23
+ ingestion_pipeline=None,
22
24
  bytes_match_extension,
23
25
  classify_sensitive_message,
24
26
  append_audit_event,
@@ -71,18 +73,41 @@ async def process_uploaded_document(
71
73
  try:
72
74
  if not (enable_graph and knowledge_graph):
73
75
  raise RuntimeError("graph disabled")
74
- graph_result = knowledge_graph.ingest_document(
75
- Path(tmp_path),
76
- original_filename=file.filename,
77
- mime_type=file.content_type,
78
- uploader=current_user,
79
- conversation_id=request.query_params.get("conversation_id"),
80
- extracted=result,
81
- )
82
- result["knowledge_graph"] = {
83
- "node_id": graph_result["node_id"],
84
- "sha256": graph_result["sha256"],
85
- }
76
+ if ingestion_pipeline is not None:
77
+ # v4: uploads enter the brain through the unified ingestion
78
+ # pipeline (provenance + kg_ingest hook lifecycle).
79
+ ingest = ingestion_pipeline.ingest(
80
+ IngestionItem(
81
+ source_type="upload",
82
+ title=file.filename,
83
+ path=tmp_path,
84
+ mime_type=file.content_type,
85
+ owner=current_user,
86
+ conversation_id=request.query_params.get("conversation_id"),
87
+ metadata={"extracted": result},
88
+ ),
89
+ user_email=current_user,
90
+ )
91
+ if ingest.status != "ok":
92
+ raise RuntimeError(ingest.detail or f"ingestion {ingest.status}")
93
+ result["knowledge_graph"] = {
94
+ "node_id": ingest.node_id,
95
+ "sha256": ingest.content_hash,
96
+ "provenance_id": ingest.provenance_id,
97
+ }
98
+ else:
99
+ graph_result = knowledge_graph.ingest_document(
100
+ Path(tmp_path),
101
+ original_filename=file.filename,
102
+ mime_type=file.content_type,
103
+ uploader=current_user,
104
+ conversation_id=request.query_params.get("conversation_id"),
105
+ extracted=result,
106
+ )
107
+ result["knowledge_graph"] = {
108
+ "node_id": graph_result["node_id"],
109
+ "sha256": graph_result["sha256"],
110
+ }
86
111
  except Exception as graph_error:
87
112
  logging.warning("knowledge graph document ingest failed: %s", graph_error)
88
113
  result["knowledge_graph"] = {"error": str(graph_error)}
@@ -72,6 +72,37 @@ class WorkspaceService:
72
72
  def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
73
73
  return self.store.has_permission(workspace_id, user_id, "write")
74
74
 
75
+ # ── record-level authorization (by-id access must not bypass gating) ──
76
+
77
+ def authorize_record_read(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
78
+ """Authorize reading a record against ITS OWN workspace.
79
+
80
+ Records predating workspace scoping carry no workspace_id and remain
81
+ readable (legacy-global compatibility); a scoped record requires read
82
+ permission on its workspace regardless of any caller-supplied header.
83
+ """
84
+ workspace_id = (record or {}).get("workspace_id")
85
+ if workspace_id:
86
+ self._ensure_permission(workspace_id, user_id, "read")
87
+
88
+ def authorize_memory_delete(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
89
+ """Delete requires owning the memory or write access to its workspace.
90
+
91
+ Ownerless records with no workspace keep their pre-v4 behaviour
92
+ (deletable by any authenticated local user).
93
+ """
94
+ owner = (record or {}).get("user_email")
95
+ workspace_id = (record or {}).get("workspace_id")
96
+ if owner and owner == user_id:
97
+ return
98
+ if workspace_id:
99
+ self._ensure_permission(workspace_id, user_id, "write")
100
+ return
101
+ if owner and owner != user_id:
102
+ raise PermissionError(
103
+ f"'{user_id or 'anonymous'}' is not the owner of memory '{record.get('id')}'"
104
+ )
105
+
75
106
  # ── workspace registry / summary ─────────────────────────────────────
76
107
 
77
108
  def summary(self, user_id: Optional[str]) -> Dict[str, Any]: