superlocalmemory 4.0.4 → 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.
- package/CHANGELOG.md +33 -0
- package/README.md +18 -13
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +3 -2
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +2 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +348 -0
- package/src/superlocalmemory/cli/commands.py +82 -25
- package/src/superlocalmemory/cli/main.py +12 -0
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +194 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-brain.js +44 -28
|
@@ -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()
|