superlocalmemory 4.0.3 → 4.0.5

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 (74) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +19 -13
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.mcp.json +1 -0
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +3 -2
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +5 -4
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +7 -6
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-graph/SKILL.md +3 -2
  30. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-profile/SKILL.md +5 -4
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +1 -1
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/brain/__init__.py +5 -0
  41. package/src/superlocalmemory/brain/truth.py +348 -0
  42. package/src/superlocalmemory/cli/commands.py +82 -25
  43. package/src/superlocalmemory/cli/main.py +12 -0
  44. package/src/superlocalmemory/core/context_cache.py +58 -1
  45. package/src/superlocalmemory/core/mutations.py +155 -25
  46. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  47. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  48. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  49. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  50. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  51. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  52. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +185 -0
  53. package/src/superlocalmemory/learning/database.py +2 -1
  54. package/src/superlocalmemory/mcp/profiles.py +25 -7
  55. package/src/superlocalmemory/mcp/server.py +7 -2
  56. package/src/superlocalmemory/mcp/tools_brain.py +138 -9
  57. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  58. package/src/superlocalmemory/retrieval/engine.py +7 -10
  59. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  60. package/src/superlocalmemory/server/routes/brain.py +21 -1
  61. package/src/superlocalmemory/server/routes/memories.py +129 -3
  62. package/src/superlocalmemory/storage/_migration_internals.py +8 -0
  63. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  64. package/src/superlocalmemory/storage/agent_experience.py +26 -4
  65. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  66. package/src/superlocalmemory/storage/database.py +194 -24
  67. package/src/superlocalmemory/storage/external_evidence.py +359 -0
  68. package/src/superlocalmemory/storage/migration_runner.py +12 -0
  69. package/src/superlocalmemory/storage/migrations/M041_external_evidence_receipts.py +189 -0
  70. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  71. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  72. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  73. package/src/superlocalmemory/ui/js/brain.js +43 -7
  74. package/src/superlocalmemory/ui/js/od-brain.js +44 -19
@@ -0,0 +1,670 @@
1
+ """Review-gated correction-case ledger and atomic temporal lifecycle seam.
2
+
3
+ The ledger stores identifiers and temporal snapshots only, never raw fact
4
+ text. A trusted reviewer may apply or roll back a case inside the same SQLite
5
+ transaction that changes the predecessor's temporal lifecycle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ from dataclasses import dataclass, replace
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Callable, Literal, Mapping
15
+ from uuid import uuid4
16
+
17
+ from superlocalmemory.storage.write_lock import get_write_lock
18
+
19
+ _SCOPES = frozenset({"personal", "project", "shared", "global"})
20
+ _STATUSES = frozenset({"proposed", "applied", "rejected", "rolled_back"})
21
+ _EVENTS = frozenset({"proposed", "applied", "rejected", "rolled_back"})
22
+ _MAX_FIELD_LENGTH = 128
23
+
24
+
25
+ class CorrectionCaseError(RuntimeError):
26
+ """Base exception for correction-case lifecycle failures."""
27
+
28
+
29
+ class CorrectionAuthorizationError(CorrectionCaseError):
30
+ """The host/server did not attest the acting identity as trusted."""
31
+
32
+
33
+ class CorrectionCompareAndSetError(CorrectionCaseError):
34
+ """The caller attempted a state transition against a stale case version."""
35
+
36
+
37
+ class CorrectionIdempotencyError(CorrectionCaseError):
38
+ """A replay key names a different correction proposal."""
39
+
40
+
41
+ class CorrectionNotFoundError(CorrectionCaseError):
42
+ """No correction case exists for the requested identifier."""
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class CorrectionActor:
47
+ """Actor provenance supplied by a host-authenticated integration seam."""
48
+
49
+ actor_id: str
50
+ actor_kind: str
51
+ trust_tier: str
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class CorrectionCase:
56
+ """A fact-identifier-only correction decision and its lifecycle state."""
57
+
58
+ case_id: str
59
+ profile_id: str
60
+ scope: str
61
+ predecessor_fact_id: str
62
+ successor_fact_id: str
63
+ reason_code: str
64
+ status: str
65
+ version: int
66
+ idempotency_key: str
67
+ created_at: str
68
+ updated_at: str
69
+ reviewed_by_actor_id: str | None
70
+ reviewed_at: str | None
71
+ applied_at: str | None
72
+ system_effective_at: str | None
73
+ event_valid_from: str | None
74
+ event_valid_until: str | None
75
+ predecessor_temporal_existed: bool | None
76
+ predecessor_valid_from: str | None
77
+ predecessor_valid_until: str | None
78
+ predecessor_system_created_at: str | None
79
+ predecessor_system_expired_at: str | None
80
+ predecessor_invalidated_by: str | None
81
+ predecessor_invalidation_reason: str | None
82
+
83
+
84
+ class CorrectionCaseStore:
85
+ """Own correction lifecycle metadata with explicit profile/trust seams.
86
+
87
+ ``is_actor_trusted`` must be implemented by the server/host integration.
88
+ This storage module never invents identity or authorization from an actor
89
+ string, which keeps unverified clients from self-approving corrections.
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ path: str | Path,
95
+ *,
96
+ is_profile_active: Callable[[str], bool],
97
+ is_actor_trusted: Callable[[CorrectionActor], bool],
98
+ ) -> None:
99
+ self._path = Path(path)
100
+ self._is_profile_active = is_profile_active
101
+ self._is_actor_trusted = is_actor_trusted
102
+
103
+ @property
104
+ def path(self) -> str:
105
+ return str(self._path)
106
+
107
+ def propose(
108
+ self,
109
+ *,
110
+ case_id: str,
111
+ profile_id: str,
112
+ scope: str,
113
+ predecessor_fact_id: str,
114
+ successor_fact_id: str,
115
+ reason_code: str,
116
+ actor: CorrectionActor,
117
+ idempotency_key: str,
118
+ event_valid_from: str | None = None,
119
+ event_valid_until: str | None = None,
120
+ ) -> CorrectionCase:
121
+ """Record a candidate without changing facts or retrieval behaviour."""
122
+ with self._transaction() as conn:
123
+ return propose_on_connection(
124
+ conn,
125
+ case_id=case_id,
126
+ profile_id=profile_id,
127
+ scope=scope,
128
+ predecessor_fact_id=predecessor_fact_id,
129
+ successor_fact_id=successor_fact_id,
130
+ reason_code=reason_code,
131
+ actor=actor,
132
+ idempotency_key=idempotency_key,
133
+ event_valid_from=event_valid_from,
134
+ event_valid_until=event_valid_until,
135
+ is_profile_active=self._is_profile_active,
136
+ is_actor_trusted=self._is_actor_trusted,
137
+ )
138
+
139
+ def apply(
140
+ self,
141
+ case_id: str,
142
+ *,
143
+ expected_version: int,
144
+ actor: CorrectionActor,
145
+ operation_id: str,
146
+ event_valid_until: str | None = None,
147
+ ) -> CorrectionCase:
148
+ """Atomically approve a case and supersede its scoped predecessor."""
149
+ return self._transition(
150
+ case_id, expected_version=expected_version, actor=actor,
151
+ operation_id=operation_id, from_status="proposed", to_status="applied",
152
+ mutate_temporal=True,
153
+ event_valid_until=event_valid_until,
154
+ )
155
+
156
+ def reject(
157
+ self, case_id: str, *, expected_version: int, actor: CorrectionActor, operation_id: str
158
+ ) -> CorrectionCase:
159
+ """Record a reviewed rejection atomically, preserving all history."""
160
+ return self._transition(
161
+ case_id, expected_version=expected_version, actor=actor,
162
+ operation_id=operation_id, from_status="proposed", to_status="rejected",
163
+ )
164
+
165
+ def rollback(
166
+ self, case_id: str, *, expected_version: int, actor: CorrectionActor, operation_id: str
167
+ ) -> CorrectionCase:
168
+ """Atomically restore the predecessor tuple and append a rollback event."""
169
+ return self._transition(
170
+ case_id, expected_version=expected_version, actor=actor,
171
+ operation_id=operation_id, from_status="applied", to_status="rolled_back",
172
+ mutate_temporal=True,
173
+ )
174
+
175
+ def get_case(self, case_id: str) -> CorrectionCase:
176
+ """Read one active-profile case without exposing fact text."""
177
+ _validate_field("case_id", case_id)
178
+ with self._read_connection() as conn:
179
+ case = _get_case(conn, case_id)
180
+ if not self._is_profile_active(case.profile_id):
181
+ raise CorrectionAuthorizationError("profile is inactive or closing")
182
+ return case
183
+
184
+ def list_cases(self, profile_id: str, *, limit: int = 100) -> list[CorrectionCase]:
185
+ """List bounded active-profile cases, newest first, identifiers only."""
186
+ _validate_field("profile_id", profile_id)
187
+ if not self._is_profile_active(profile_id):
188
+ raise CorrectionAuthorizationError("profile is inactive or closing")
189
+ if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 500:
190
+ raise ValueError("limit must be an integer from 1 to 500")
191
+ with self._read_connection() as conn:
192
+ rows = conn.execute(
193
+ "SELECT * FROM correction_cases WHERE profile_id=? "
194
+ "ORDER BY updated_at DESC, case_id DESC LIMIT ?",
195
+ (profile_id, limit),
196
+ ).fetchall()
197
+ return [_case_from_row(row) for row in rows]
198
+
199
+ def _transition(
200
+ self,
201
+ case_id: str,
202
+ *,
203
+ expected_version: int,
204
+ actor: CorrectionActor,
205
+ operation_id: str,
206
+ from_status: Literal["proposed", "applied"],
207
+ to_status: Literal["applied", "rejected", "rolled_back"],
208
+ mutate_temporal: bool = False,
209
+ event_valid_until: str | None = None,
210
+ ) -> CorrectionCase:
211
+ with self._transaction() as conn:
212
+ return transition_on_connection(
213
+ conn,
214
+ case_id=case_id,
215
+ expected_version=expected_version,
216
+ actor=actor,
217
+ operation_id=operation_id,
218
+ from_status=from_status,
219
+ to_status=to_status,
220
+ mutate_temporal=mutate_temporal,
221
+ event_valid_until=event_valid_until,
222
+ is_profile_active=self._is_profile_active,
223
+ is_actor_trusted=self._is_actor_trusted,
224
+ )
225
+
226
+ def _assert_admitted(self, profile_id: str, actor: CorrectionActor) -> None:
227
+ if not self._is_profile_active(profile_id):
228
+ raise CorrectionAuthorizationError("profile is inactive or closing")
229
+ if not self._is_actor_trusted(actor):
230
+ raise CorrectionAuthorizationError("actor is not trusted by the host/server")
231
+
232
+ def _transaction(self):
233
+ return _CorrectionTransaction(self._path)
234
+
235
+ def _read_connection(self):
236
+ return _CorrectionReadConnection(self._path)
237
+
238
+
239
+ def propose_on_connection(
240
+ conn: sqlite3.Connection,
241
+ *,
242
+ case_id: str,
243
+ profile_id: str,
244
+ scope: str,
245
+ predecessor_fact_id: str,
246
+ successor_fact_id: str,
247
+ reason_code: str,
248
+ actor: CorrectionActor,
249
+ idempotency_key: str,
250
+ event_valid_from: str | None = None,
251
+ event_valid_until: str | None = None,
252
+ is_profile_active: Callable[[str], bool],
253
+ is_actor_trusted: Callable[[CorrectionActor], bool],
254
+ ) -> CorrectionCase:
255
+ """Write one candidate through the caller-owned SQLite transaction.
256
+
257
+ This function never starts, commits, rolls back, or closes ``conn``. It is
258
+ the only storage entry point suitable for a canonical writer command or a
259
+ bound ``DatabaseManager.raw_connection()`` scope.
260
+ """
261
+ if not isinstance(conn, sqlite3.Connection):
262
+ raise TypeError("correction proposal requires a sqlite3 connection")
263
+ _validate_case_input(
264
+ case_id, profile_id, scope, predecessor_fact_id, successor_fact_id,
265
+ reason_code, actor, idempotency_key, event_valid_from, event_valid_until,
266
+ )
267
+ if not is_profile_active(profile_id):
268
+ raise CorrectionAuthorizationError("profile is inactive or closing")
269
+ if not is_actor_trusted(actor):
270
+ raise CorrectionAuthorizationError("actor is not trusted by the host/server")
271
+
272
+ existing = conn.execute(
273
+ "SELECT * FROM correction_cases WHERE profile_id=? AND idempotency_key=?",
274
+ (profile_id, idempotency_key),
275
+ ).fetchone()
276
+ if existing is not None:
277
+ case = _case_from_row(existing)
278
+ if (
279
+ case.case_id != case_id
280
+ or case.scope != scope
281
+ or case.predecessor_fact_id != predecessor_fact_id
282
+ or case.successor_fact_id != successor_fact_id
283
+ or case.reason_code != reason_code
284
+ ):
285
+ raise CorrectionIdempotencyError("proposal replay key names different data")
286
+ return case
287
+
288
+ now = _now()
289
+ conn.execute(
290
+ "INSERT INTO correction_cases (case_id, profile_id, scope, predecessor_fact_id, "
291
+ "successor_fact_id, reason_code, status, version, idempotency_key, "
292
+ "proposed_by_actor_id, proposed_by_actor_kind, proposed_by_trust_tier, created_at, "
293
+ "updated_at, event_valid_from, event_valid_until) "
294
+ "VALUES (?, ?, ?, ?, ?, ?, 'proposed', 0, ?, ?, ?, ?, ?, ?, ?, ?)",
295
+ (
296
+ case_id, profile_id, scope, predecessor_fact_id, successor_fact_id,
297
+ reason_code, idempotency_key, actor.actor_id, actor.actor_kind,
298
+ actor.trust_tier, now, now, event_valid_from, event_valid_until,
299
+ ),
300
+ )
301
+ _append_event(
302
+ conn, case_id=case_id, profile_id=profile_id, scope=scope,
303
+ event_type="proposed", operation_id=idempotency_key, actor=actor,
304
+ expected_version=None, resulting_version=0, occurred_at=now,
305
+ event_valid_from=event_valid_from, event_valid_until=event_valid_until,
306
+ )
307
+ return _get_case(conn, case_id)
308
+
309
+
310
+ def transition_on_connection(
311
+ conn: sqlite3.Connection,
312
+ *,
313
+ case_id: str,
314
+ expected_version: int,
315
+ actor: CorrectionActor,
316
+ operation_id: str,
317
+ from_status: Literal["proposed", "applied"],
318
+ to_status: Literal["applied", "rejected", "rolled_back"],
319
+ mutate_temporal: bool,
320
+ event_valid_until: str | None = None,
321
+ is_profile_active: Callable[[str], bool],
322
+ is_actor_trusted: Callable[[CorrectionActor], bool],
323
+ ) -> CorrectionCase:
324
+ """Transition one case in a caller-owned transaction, never nesting it."""
325
+ _validate_field("case_id", case_id)
326
+ _validate_field("operation_id", operation_id)
327
+ if expected_version < 0:
328
+ raise ValueError("expected_version must be non-negative")
329
+ _validate_actor(actor)
330
+ if not is_actor_trusted(actor):
331
+ raise CorrectionAuthorizationError("actor is not trusted by the host/server")
332
+ replay = conn.execute(
333
+ "SELECT event_type FROM correction_events WHERE case_id=? AND operation_id=?",
334
+ (case_id, operation_id),
335
+ ).fetchone()
336
+ if replay is not None:
337
+ if replay[0] != to_status:
338
+ raise CorrectionIdempotencyError("operation replay key has another transition")
339
+ return _get_case(conn, case_id)
340
+ case = _get_case(conn, case_id)
341
+ if not is_profile_active(case.profile_id):
342
+ raise CorrectionAuthorizationError("profile is inactive or closing")
343
+ if case.status != from_status or case.version != expected_version:
344
+ raise CorrectionCompareAndSetError("case state changed; reload before review action")
345
+ if to_status == "rolled_back":
346
+ dependent = conn.execute(
347
+ "SELECT 1 FROM correction_cases WHERE profile_id=? "
348
+ "AND predecessor_fact_id=? AND status IN ('proposed', 'applied') LIMIT 1",
349
+ (case.profile_id, case.successor_fact_id),
350
+ ).fetchone()
351
+ if dependent is not None:
352
+ raise CorrectionCompareAndSetError(
353
+ "dependent correction is active; resolve it before rollback"
354
+ )
355
+ now = _now()
356
+ if event_valid_until is not None:
357
+ if to_status != "applied":
358
+ raise ValueError("event-valid boundary is permitted only when applying a correction")
359
+ _validate_timestamp("event_valid_until", event_valid_until)
360
+ case = replace(case, event_valid_until=event_valid_until)
361
+ if mutate_temporal:
362
+ if to_status == "applied":
363
+ _apply_predecessor_temporal(conn, case, occurred_at=now)
364
+ else:
365
+ _restore_predecessor_temporal(conn, case)
366
+ next_version = case.version + 1
367
+ cursor = conn.execute(
368
+ "UPDATE correction_cases SET status=?, version=?, updated_at=?, event_valid_until=?, "
369
+ "reviewed_by_actor_id=?, reviewed_at=?, applied_at=?, system_effective_at=? "
370
+ "WHERE case_id=? AND status=? AND version=?",
371
+ (
372
+ to_status,
373
+ next_version,
374
+ now,
375
+ case.event_valid_until,
376
+ actor.actor_id,
377
+ now,
378
+ now if to_status == "applied" else case.applied_at,
379
+ now if to_status == "applied" else case.system_effective_at,
380
+ case_id,
381
+ from_status,
382
+ expected_version,
383
+ ),
384
+ )
385
+ if cursor.rowcount != 1:
386
+ raise CorrectionCompareAndSetError("case state changed; reload before review action")
387
+ _append_event(
388
+ conn,
389
+ case_id=case.case_id,
390
+ profile_id=case.profile_id,
391
+ scope=case.scope,
392
+ event_type=to_status,
393
+ operation_id=operation_id,
394
+ actor=actor,
395
+ expected_version=expected_version,
396
+ resulting_version=next_version,
397
+ occurred_at=now,
398
+ event_valid_from=case.event_valid_from,
399
+ event_valid_until=case.event_valid_until,
400
+ )
401
+ return _get_case(conn, case_id)
402
+
403
+
404
+ def _append_event(
405
+ conn: sqlite3.Connection,
406
+ *,
407
+ case_id: str,
408
+ profile_id: str,
409
+ scope: str,
410
+ event_type: str,
411
+ operation_id: str,
412
+ actor: CorrectionActor,
413
+ expected_version: int | None,
414
+ resulting_version: int,
415
+ occurred_at: str,
416
+ event_valid_from: str | None,
417
+ event_valid_until: str | None,
418
+ ) -> None:
419
+ conn.execute(
420
+ "INSERT INTO correction_events (event_id, case_id, profile_id, scope, event_type, "
421
+ "operation_id, actor_id, actor_kind, actor_trust_tier, expected_version, "
422
+ "resulting_version, system_occurred_at, event_valid_from, event_valid_until) "
423
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
424
+ (
425
+ uuid4().hex, case_id, profile_id, scope, event_type, operation_id,
426
+ actor.actor_id, actor.actor_kind, actor.trust_tier, expected_version,
427
+ resulting_version, occurred_at, event_valid_from, event_valid_until,
428
+ ),
429
+ )
430
+
431
+
432
+ def _get_case(conn: sqlite3.Connection, case_id: str) -> CorrectionCase:
433
+ row = conn.execute("SELECT * FROM correction_cases WHERE case_id=?", (case_id,)).fetchone()
434
+ if row is None:
435
+ raise CorrectionNotFoundError("correction case does not exist")
436
+ return _case_from_row(row)
437
+
438
+
439
+ def _apply_predecessor_temporal(
440
+ conn: sqlite3.Connection, case: CorrectionCase, *, occurred_at: str
441
+ ) -> None:
442
+ """Snapshot and supersede exactly one profile-and-scope-owned predecessor."""
443
+ rows = conn.execute(
444
+ "SELECT fact_id, profile_id, scope FROM atomic_facts "
445
+ "WHERE fact_id IN (?, ?)",
446
+ (case.predecessor_fact_id, case.successor_fact_id),
447
+ ).fetchall()
448
+ facts = {str(row["fact_id"]): row for row in rows}
449
+ if set(facts) != {case.predecessor_fact_id, case.successor_fact_id}:
450
+ raise CorrectionNotFoundError("correction facts are no longer available")
451
+ for row in facts.values():
452
+ if row["profile_id"] != case.profile_id or row["scope"] != case.scope:
453
+ raise CorrectionAuthorizationError("correction facts are outside the approved scope")
454
+
455
+ prior = conn.execute(
456
+ "SELECT valid_from, valid_until, system_created_at, system_expired_at, "
457
+ "invalidated_by, invalidation_reason FROM fact_temporal_validity "
458
+ "WHERE fact_id=? AND profile_id=?",
459
+ (case.predecessor_fact_id, case.profile_id),
460
+ ).fetchone()
461
+ if prior is None:
462
+ conn.execute(
463
+ "UPDATE correction_cases SET predecessor_temporal_existed=0, "
464
+ "predecessor_valid_from=NULL, predecessor_valid_until=NULL, "
465
+ "predecessor_system_created_at=NULL, predecessor_system_expired_at=NULL, "
466
+ "predecessor_invalidated_by=NULL, predecessor_invalidation_reason=NULL "
467
+ "WHERE case_id=?",
468
+ (case.case_id,),
469
+ )
470
+ conn.execute(
471
+ "INSERT INTO fact_temporal_validity "
472
+ "(fact_id, profile_id, system_created_at) VALUES (?, ?, ?)",
473
+ (case.predecessor_fact_id, case.profile_id, occurred_at),
474
+ )
475
+ else:
476
+ conn.execute(
477
+ "UPDATE correction_cases SET predecessor_temporal_existed=1, "
478
+ "predecessor_valid_from=?, predecessor_valid_until=?, "
479
+ "predecessor_system_created_at=?, predecessor_system_expired_at=?, "
480
+ "predecessor_invalidated_by=?, predecessor_invalidation_reason=? "
481
+ "WHERE case_id=?",
482
+ (*tuple(prior), case.case_id),
483
+ )
484
+ conn.execute(
485
+ "UPDATE fact_temporal_validity SET "
486
+ "valid_until=COALESCE(?, valid_until), system_expired_at=?, "
487
+ "invalidated_by=?, invalidation_reason=? WHERE fact_id=? AND profile_id=?",
488
+ (
489
+ case.event_valid_until,
490
+ occurred_at,
491
+ case.successor_fact_id,
492
+ case.reason_code,
493
+ case.predecessor_fact_id,
494
+ case.profile_id,
495
+ ),
496
+ )
497
+
498
+
499
+ def _restore_predecessor_temporal(conn: sqlite3.Connection, case: CorrectionCase) -> None:
500
+ """Restore the snapshot captured at apply; history rows themselves remain."""
501
+ if case.predecessor_temporal_existed is None:
502
+ raise CorrectionCaseError("approved case has no predecessor temporal snapshot")
503
+ if not case.predecessor_temporal_existed:
504
+ conn.execute(
505
+ "DELETE FROM fact_temporal_validity WHERE fact_id=? AND profile_id=?",
506
+ (case.predecessor_fact_id, case.profile_id),
507
+ )
508
+ return
509
+ cursor = conn.execute(
510
+ "UPDATE fact_temporal_validity SET valid_from=?, valid_until=?, system_created_at=?, "
511
+ "system_expired_at=?, invalidated_by=?, invalidation_reason=? "
512
+ "WHERE fact_id=? AND profile_id=?",
513
+ (
514
+ case.predecessor_valid_from,
515
+ case.predecessor_valid_until,
516
+ case.predecessor_system_created_at,
517
+ case.predecessor_system_expired_at,
518
+ case.predecessor_invalidated_by,
519
+ case.predecessor_invalidation_reason,
520
+ case.predecessor_fact_id,
521
+ case.profile_id,
522
+ ),
523
+ )
524
+ if cursor.rowcount != 1:
525
+ raise CorrectionNotFoundError("predecessor temporal record is no longer available")
526
+
527
+
528
+ class _CorrectionTransaction:
529
+ """Small transaction guard: all case/event writes commit or roll back together."""
530
+
531
+ def __init__(self, path: Path) -> None:
532
+ self._path = path
533
+ self._conn: sqlite3.Connection | None = None
534
+ # Standalone lifecycle operations are rare, but must serialize with
535
+ # DatabaseManager writes just like coordinator-owned operations do.
536
+ self._lock = get_write_lock(path)
537
+
538
+ def __enter__(self) -> sqlite3.Connection:
539
+ self._lock.acquire()
540
+ try:
541
+ conn = sqlite3.connect(str(self._path), timeout=5, isolation_level=None)
542
+ conn.row_factory = sqlite3.Row
543
+ conn.execute("PRAGMA journal_mode=WAL")
544
+ conn.execute("PRAGMA busy_timeout=5000")
545
+ conn.execute("BEGIN IMMEDIATE")
546
+ self._conn = conn
547
+ return conn
548
+ except Exception:
549
+ self._lock.release()
550
+ raise
551
+
552
+ def __exit__(self, exc_type, exc, _traceback) -> None:
553
+ assert self._conn is not None
554
+ try:
555
+ self._conn.execute("ROLLBACK" if exc_type is not None else "COMMIT")
556
+ finally:
557
+ try:
558
+ self._conn.close()
559
+ finally:
560
+ self._lock.release()
561
+
562
+
563
+ class _CorrectionReadConnection:
564
+ """Small read-only guard kept out of the canonical writer domain."""
565
+
566
+ def __init__(self, path: Path) -> None:
567
+ self._path = path
568
+ self._conn: sqlite3.Connection | None = None
569
+
570
+ def __enter__(self) -> sqlite3.Connection:
571
+ conn = sqlite3.connect(f"file:{self._path}?mode=ro", uri=True, timeout=0.25)
572
+ conn.row_factory = sqlite3.Row
573
+ conn.execute("PRAGMA query_only=ON")
574
+ self._conn = conn
575
+ return conn
576
+
577
+ def __exit__(self, _exc_type, _exc, _traceback) -> None:
578
+ assert self._conn is not None
579
+ self._conn.close()
580
+
581
+
582
+ def _case_from_row(
583
+ row: sqlite3.Row | tuple[object, ...] | Mapping[str, object],
584
+ ) -> CorrectionCase:
585
+ if not isinstance(row, sqlite3.Row):
586
+ columns = (
587
+ "case_id", "profile_id", "scope", "predecessor_fact_id", "successor_fact_id",
588
+ "reason_code", "status", "version", "idempotency_key", "proposed_by_actor_id",
589
+ "proposed_by_actor_kind", "proposed_by_trust_tier", "created_at", "updated_at",
590
+ "reviewed_by_actor_id", "reviewed_at", "applied_at", "system_effective_at",
591
+ "event_valid_from", "event_valid_until", "predecessor_temporal_existed",
592
+ "predecessor_valid_from", "predecessor_valid_until", "predecessor_system_created_at",
593
+ "predecessor_system_expired_at", "predecessor_invalidated_by",
594
+ "predecessor_invalidation_reason",
595
+ )
596
+ row = dict(zip(columns, row, strict=True))
597
+ return CorrectionCase(
598
+ case_id=row["case_id"], profile_id=row["profile_id"], scope=row["scope"],
599
+ predecessor_fact_id=row["predecessor_fact_id"], successor_fact_id=row["successor_fact_id"],
600
+ reason_code=row["reason_code"], status=row["status"], version=int(row["version"]),
601
+ idempotency_key=row["idempotency_key"], created_at=row["created_at"],
602
+ updated_at=row["updated_at"], reviewed_by_actor_id=row["reviewed_by_actor_id"],
603
+ reviewed_at=row["reviewed_at"], applied_at=row["applied_at"],
604
+ system_effective_at=row["system_effective_at"], event_valid_from=row["event_valid_from"],
605
+ event_valid_until=row["event_valid_until"],
606
+ predecessor_temporal_existed=(
607
+ None if row["predecessor_temporal_existed"] is None
608
+ else bool(row["predecessor_temporal_existed"])
609
+ ),
610
+ predecessor_valid_from=row["predecessor_valid_from"],
611
+ predecessor_valid_until=row["predecessor_valid_until"],
612
+ predecessor_system_created_at=row["predecessor_system_created_at"],
613
+ predecessor_system_expired_at=row["predecessor_system_expired_at"],
614
+ predecessor_invalidated_by=row["predecessor_invalidated_by"],
615
+ predecessor_invalidation_reason=row["predecessor_invalidation_reason"],
616
+ )
617
+
618
+
619
+ def _validate_case_input(
620
+ case_id: str, profile_id: str, scope: str, predecessor_fact_id: str, successor_fact_id: str,
621
+ reason_code: str, actor: CorrectionActor, idempotency_key: str,
622
+ event_valid_from: str | None, event_valid_until: str | None,
623
+ ) -> None:
624
+ for name, value in (
625
+ ("case_id", case_id),
626
+ ("profile_id", profile_id),
627
+ ("predecessor_fact_id", predecessor_fact_id),
628
+ ("successor_fact_id", successor_fact_id), ("reason_code", reason_code),
629
+ ("idempotency_key", idempotency_key),
630
+ ):
631
+ _validate_field(name, value)
632
+ if predecessor_fact_id == successor_fact_id:
633
+ raise ValueError("predecessor and successor facts must differ")
634
+ if scope not in _SCOPES:
635
+ raise ValueError("scope is unsupported")
636
+ _validate_actor(actor)
637
+ _validate_timestamp("event_valid_from", event_valid_from)
638
+ _validate_timestamp("event_valid_until", event_valid_until)
639
+ if event_valid_from and event_valid_until and event_valid_from >= event_valid_until:
640
+ raise ValueError("event-valid interval must be ordered")
641
+
642
+
643
+ def _validate_actor(actor: CorrectionActor) -> None:
644
+ for name, value in (
645
+ ("actor_id", actor.actor_id), ("actor_kind", actor.actor_kind),
646
+ ("trust_tier", actor.trust_tier),
647
+ ):
648
+ _validate_field(name, value)
649
+
650
+
651
+ def _validate_field(name: str, value: str) -> None:
652
+ if not isinstance(value, str) or not value or len(value.encode("utf-8")) > _MAX_FIELD_LENGTH:
653
+ raise ValueError(f"{name} must be a non-empty bounded identifier")
654
+ if any(ch.isspace() for ch in value) or "\x00" in value:
655
+ raise ValueError(f"{name} must be a safe identifier")
656
+
657
+
658
+ def _validate_timestamp(name: str, value: str | None) -> None:
659
+ if value is None:
660
+ return
661
+ if not isinstance(value, str):
662
+ raise ValueError(f"{name} must be an RFC3339 timestamp")
663
+ try:
664
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
665
+ except ValueError as exc:
666
+ raise ValueError(f"{name} must be an RFC3339 timestamp") from exc
667
+
668
+
669
+ def _now() -> str:
670
+ return datetime.now(timezone.utc).isoformat()