zikaron 0.1.0__py3-none-any.whl
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.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
"""`memory` row mechanics: create, amend, retire, fetch — the primitives every write path composes.
|
|
2
|
+
|
|
3
|
+
Normative: `design/schema.md` §Tables and invariants 4-10; `design/architecture.md`
|
|
4
|
+
§"Validation precedence" (primary-agent ladder). `design/indexing.md` §"Implementation constraints"
|
|
5
|
+
states why `create`/`amend` do not emit `remember`/`amend` events and why `retire`/`fetch` do emit
|
|
6
|
+
theirs.
|
|
7
|
+
|
|
8
|
+
`memory_fts`, `memory_chunk` and `memory_vec` are untouched by every function here, per this
|
|
9
|
+
layer's own scope — an external-content FTS5 table is never auto-synced by SQLite itself, so
|
|
10
|
+
writing `memory` without also writing `memory_fts` produces no SQLite-level error, only a stale
|
|
11
|
+
index. Keeping both writes inside one transaction is the indexing layer's job, and nothing here
|
|
12
|
+
queries either index, so no function in this module can observe the staleness it leaves behind.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from typing import Final, cast
|
|
19
|
+
from uuid import uuid4
|
|
20
|
+
|
|
21
|
+
import aiosqlite
|
|
22
|
+
|
|
23
|
+
from zikaron.core.clock import timestamp
|
|
24
|
+
from zikaron.core.errors import ErrorCode, RowState, ZikaronError
|
|
25
|
+
from zikaron.core.events import (
|
|
26
|
+
EVENT_SPECS,
|
|
27
|
+
EventDetail,
|
|
28
|
+
FetchDetail,
|
|
29
|
+
NoReceiptDetail,
|
|
30
|
+
RetireDetail,
|
|
31
|
+
VersionConflictDetail,
|
|
32
|
+
)
|
|
33
|
+
from zikaron.core.records import receipts, supersession
|
|
34
|
+
from zikaron.core.records.receipts import ReceiptKey, ReceiptSource
|
|
35
|
+
from zikaron.core.records.supersession import ResolvedHead, RootState
|
|
36
|
+
from zikaron.core.store import transactions
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Tier(StrEnum):
|
|
40
|
+
"""`memory.tier`'s two legal values, exactly as `schema.md`'s `CHECK` states them."""
|
|
41
|
+
|
|
42
|
+
JOURNAL = "journal"
|
|
43
|
+
LONG_TERM = "long_term"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True, slots=True)
|
|
47
|
+
class CallParams:
|
|
48
|
+
"""Everything one mutating call holds constant across every rung it passes.
|
|
49
|
+
|
|
50
|
+
`session_id` and `client_kind` are the receipt scope (invariant 9); `op_id` correlates every
|
|
51
|
+
`event` row one RPC call emits (`schema.md`: "one RPC = one `op_id`"). `max_depth` is
|
|
52
|
+
`supersession_max_depth`, the cycle-walk cap every supersession-graph read or write in this
|
|
53
|
+
module needs. The four are not all *identity* fields — `max_depth` is a policy value, not a
|
|
54
|
+
fact about who is calling — but every mutating entry point genuinely needs all four together
|
|
55
|
+
and none of them changes partway through one call, so bundling them is the correct fix for
|
|
56
|
+
the `PLR0913` violation several functions here hit before this type existed, not a
|
|
57
|
+
suppression dressed up as a domain type.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
session_id: str
|
|
61
|
+
client_kind: str
|
|
62
|
+
op_id: str
|
|
63
|
+
max_depth: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class Memory:
|
|
68
|
+
"""One `memory` row, exactly as stored — no derived fields.
|
|
69
|
+
|
|
70
|
+
`state` is deliberately absent here: it is a *display* concept derived from `active` and
|
|
71
|
+
`superseded_by` (`RowState`, `architecture.md` §"MCP tool surface"), and a stored dataclass
|
|
72
|
+
that also carried a value computable from its own other fields would let the two disagree.
|
|
73
|
+
`resolved_state` computes it on demand instead.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
uuid: str
|
|
77
|
+
tier: Tier
|
|
78
|
+
gist: str
|
|
79
|
+
content: str
|
|
80
|
+
active: bool
|
|
81
|
+
superseded_by: str | None
|
|
82
|
+
version: int
|
|
83
|
+
created_at: str
|
|
84
|
+
updated_at: str
|
|
85
|
+
session_id: str | None
|
|
86
|
+
token_count: int
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def resolved_state(self) -> RowState:
|
|
90
|
+
"""This row's display state, per `schema.md` §"Retrieval eligibility"'s three-state
|
|
91
|
+
table: `live` iff `active`, else `superseded` iff `superseded_by` is set, else `retired`.
|
|
92
|
+
"""
|
|
93
|
+
if self.active:
|
|
94
|
+
return RowState.LIVE
|
|
95
|
+
if self.superseded_by is not None:
|
|
96
|
+
return RowState.SUPERSEDED
|
|
97
|
+
return RowState.RETIRED
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class ConflictRecord:
|
|
102
|
+
"""`architecture.md`'s `CONFLICT_RECORD`: `fetch`'s record minus `active` and the timestamps.
|
|
103
|
+
|
|
104
|
+
Returned by a version conflict and named in `read_receipt.source='conflict'` — this is what
|
|
105
|
+
D26's "re-decide in one round trip" promise actually hands back, and `superseded_by_latest`/
|
|
106
|
+
`superseded_by_latest_state` are populated the same way `fetch`'s are, for the same reason: a
|
|
107
|
+
conflict is often how an agent learns a row was superseded, and pointing it at a replacement
|
|
108
|
+
that is itself dead would restart the loop it is trying to leave.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
uuid: str
|
|
112
|
+
gist: str
|
|
113
|
+
content: str
|
|
114
|
+
version: int
|
|
115
|
+
tier: Tier
|
|
116
|
+
state: RowState
|
|
117
|
+
superseded_by: str | None
|
|
118
|
+
superseded_by_latest: str | None
|
|
119
|
+
superseded_by_latest_state: RowState | None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class FetchedMemory:
|
|
124
|
+
"""`fetch`'s per-record shape: `ConflictRecord`'s fields plus `active` and both timestamps."""
|
|
125
|
+
|
|
126
|
+
uuid: str
|
|
127
|
+
gist: str
|
|
128
|
+
content: str
|
|
129
|
+
version: int
|
|
130
|
+
tier: Tier
|
|
131
|
+
active: bool
|
|
132
|
+
state: RowState
|
|
133
|
+
superseded_by: str | None
|
|
134
|
+
superseded_by_latest: str | None
|
|
135
|
+
superseded_by_latest_state: RowState | None
|
|
136
|
+
created_at: str
|
|
137
|
+
updated_at: str
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class Rewrite:
|
|
142
|
+
"""The two fields a full rewrite supplies together — `create`'s and `amend`'s `gist`/
|
|
143
|
+
`content`, and the identically-named parameters of the `zikaron_memory_remember` and
|
|
144
|
+
`zikaron_memory_amend` tools.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
gist: str
|
|
148
|
+
content: str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
#: `memory`'s columns in the fixed order `_row_to_memory` unpacks them — a module-level constant
|
|
152
|
+
#: rather than a literal repeated at each `SELECT`, so every reader of this row shape agrees with
|
|
153
|
+
#: the one place that turns it into a `Memory`. `S608` (string-built SQL) does not apply: nothing
|
|
154
|
+
#: here is assembled from a request-time value, only from this fixed, source-level string.
|
|
155
|
+
_MEMORY_COLUMNS: Final = (
|
|
156
|
+
"uuid, tier, gist, content, active, superseded_by, version, created_at, updated_at, "
|
|
157
|
+
"session_id, token_count"
|
|
158
|
+
)
|
|
159
|
+
_SELECT_MEMORY_BY_UUID: Final = f"SELECT {_MEMORY_COLUMNS} FROM memory WHERE uuid = ?" # noqa: S608
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _row_to_memory(row: tuple[object, ...]) -> Memory:
|
|
163
|
+
(
|
|
164
|
+
uuid,
|
|
165
|
+
tier,
|
|
166
|
+
gist,
|
|
167
|
+
content,
|
|
168
|
+
active,
|
|
169
|
+
superseded_by,
|
|
170
|
+
version,
|
|
171
|
+
created_at,
|
|
172
|
+
updated_at,
|
|
173
|
+
session_id,
|
|
174
|
+
token_count,
|
|
175
|
+
) = row
|
|
176
|
+
return Memory(
|
|
177
|
+
uuid=str(uuid),
|
|
178
|
+
tier=Tier(str(tier)),
|
|
179
|
+
gist=str(gist),
|
|
180
|
+
content=str(content),
|
|
181
|
+
active=bool(active),
|
|
182
|
+
superseded_by=None if superseded_by is None else str(superseded_by),
|
|
183
|
+
version=cast(int, version),
|
|
184
|
+
created_at=str(created_at),
|
|
185
|
+
updated_at=str(updated_at),
|
|
186
|
+
session_id=None if session_id is None else str(session_id),
|
|
187
|
+
token_count=cast(int, token_count),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def load(db: aiosqlite.Connection, uuid: str) -> Memory | None:
|
|
192
|
+
"""One `memory` row by uuid, exactly as stored, or `None` if there is no such row.
|
|
193
|
+
|
|
194
|
+
Public because a uuid is a handle and resolving one is not a predicate path: any state resolves,
|
|
195
|
+
which is why `fetch` is absent from `eligibility.Consumer` too. Callers that need a *verb* — a
|
|
196
|
+
receipt minted, an event logged — use `fetch`; callers that need the row's current facts inside
|
|
197
|
+
a transaction they already hold, as serve-time re-validation and the consolidator ladder both
|
|
198
|
+
do, use this. Assumes the caller's own transaction, and mints and logs nothing.
|
|
199
|
+
"""
|
|
200
|
+
rows = await db.execute_fetchall(_SELECT_MEMORY_BY_UUID, (uuid,))
|
|
201
|
+
found = list(rows)
|
|
202
|
+
if not found:
|
|
203
|
+
return None
|
|
204
|
+
return _row_to_memory(tuple(found[0]))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def require_existing(db: aiosqlite.Connection, uuid: str) -> Memory:
|
|
208
|
+
"""`load`, but `not_found` rather than `None` — the existence rung of both ladders.
|
|
209
|
+
|
|
210
|
+
Public for the same reason as `load`, and separate from it because the two answer different
|
|
211
|
+
questions: whether a row exists is sometimes the caller's own business (serve-time re-validation
|
|
212
|
+
treats a missing row as a vacated member) and sometimes a rejection.
|
|
213
|
+
"""
|
|
214
|
+
found = await load(db, uuid)
|
|
215
|
+
if found is None:
|
|
216
|
+
raise ZikaronError(ErrorCode.NOT_FOUND, uuid=uuid)
|
|
217
|
+
return found
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def log_event(
|
|
221
|
+
db: aiosqlite.Connection,
|
|
222
|
+
*,
|
|
223
|
+
ctx: CallParams,
|
|
224
|
+
detail: EventDetail,
|
|
225
|
+
memory_uuid: str | None,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Insert one `event` row. Never committed here — invariant 10 requires it share the
|
|
228
|
+
caller's own transaction, so the caller's own `COMMIT` is what makes this durable.
|
|
229
|
+
|
|
230
|
+
The kind comes from `detail`, not from a separate argument, so a payload cannot be filed under
|
|
231
|
+
the wrong kind. `detail`'s own type fixes its field names and their types at type-check time,
|
|
232
|
+
and `EVENT_SPECS[kind].validate` then re-checks the shape that reaches the log — the field
|
|
233
|
+
order, and any closed set's membership — as defence in depth against a value type Python does
|
|
234
|
+
not enforce. `memory_uuid` is checked against whether the kind names a memory at all, since a
|
|
235
|
+
per-call kind naming one, or a per-memory kind naming none, would leave the signals joining on a
|
|
236
|
+
column whose meaning changed with the writer.
|
|
237
|
+
|
|
238
|
+
Public because every write path emits into the same log and there is exactly one `INSERT`
|
|
239
|
+
statement for it: the indexed write verbs compose their own transactions out of this module's
|
|
240
|
+
neutral cores and must not carry a second copy of this statement, since a schema change would
|
|
241
|
+
then have two places to reach.
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
ValueError: the payload's fields are not this kind's declared fields in order; a closed-set
|
|
245
|
+
field holds a value outside its set; or `memory_uuid` disagrees with whether this kind
|
|
246
|
+
names one.
|
|
247
|
+
"""
|
|
248
|
+
spec = EVENT_SPECS[detail.kind]
|
|
249
|
+
payload = detail.as_detail()
|
|
250
|
+
spec.validate(payload)
|
|
251
|
+
if spec.names_memory != (memory_uuid is not None):
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"{detail.kind}: names_memory={spec.names_memory} but memory_uuid={memory_uuid!r}"
|
|
254
|
+
)
|
|
255
|
+
await db.execute(
|
|
256
|
+
"INSERT INTO event (at, session_id, client_kind, op_id, kind, memory_uuid, detail) "
|
|
257
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
258
|
+
(
|
|
259
|
+
timestamp(),
|
|
260
|
+
ctx.session_id,
|
|
261
|
+
ctx.client_kind,
|
|
262
|
+
ctx.op_id,
|
|
263
|
+
detail.kind.value,
|
|
264
|
+
memory_uuid,
|
|
265
|
+
json.dumps(payload),
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def _resolve_head(
|
|
271
|
+
db: aiosqlite.Connection, memory: Memory, *, max_depth: int
|
|
272
|
+
) -> ResolvedHead | None:
|
|
273
|
+
if memory.superseded_by is None:
|
|
274
|
+
return None
|
|
275
|
+
return await supersession.resolve_latest(db, start_uuid=memory.uuid, max_depth=max_depth)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _root_state_to_row_state(state: RootState) -> RowState:
|
|
279
|
+
return RowState.LIVE if state is RootState.LIVE else RowState.RETIRED
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def _to_fetched(db: aiosqlite.Connection, memory: Memory, *, max_depth: int) -> FetchedMemory:
|
|
283
|
+
head = await _resolve_head(db, memory, max_depth=max_depth)
|
|
284
|
+
return FetchedMemory(
|
|
285
|
+
uuid=memory.uuid,
|
|
286
|
+
gist=memory.gist,
|
|
287
|
+
content=memory.content,
|
|
288
|
+
version=memory.version,
|
|
289
|
+
tier=memory.tier,
|
|
290
|
+
active=memory.active,
|
|
291
|
+
state=memory.resolved_state,
|
|
292
|
+
superseded_by=memory.superseded_by,
|
|
293
|
+
superseded_by_latest=None if head is None else head.uuid,
|
|
294
|
+
superseded_by_latest_state=None if head is None else _root_state_to_row_state(head.state),
|
|
295
|
+
created_at=memory.created_at,
|
|
296
|
+
updated_at=memory.updated_at,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def conflict_record(
|
|
301
|
+
db: aiosqlite.Connection, memory: Memory, *, max_depth: int
|
|
302
|
+
) -> ConflictRecord:
|
|
303
|
+
"""`architecture.md`'s `CONFLICT_RECORD` for one row, with its supersession head resolved.
|
|
304
|
+
|
|
305
|
+
Public because two ladders build it — this module's primary-agent one and the consolidation
|
|
306
|
+
layer's, which can name several rows in one call and so returns a list of these where the
|
|
307
|
+
primary verbs return one object. Both must produce the identical shape from the identical
|
|
308
|
+
fields, which is what having one constructor buys.
|
|
309
|
+
"""
|
|
310
|
+
head = await _resolve_head(db, memory, max_depth=max_depth)
|
|
311
|
+
return ConflictRecord(
|
|
312
|
+
uuid=memory.uuid,
|
|
313
|
+
gist=memory.gist,
|
|
314
|
+
content=memory.content,
|
|
315
|
+
version=memory.version,
|
|
316
|
+
tier=memory.tier,
|
|
317
|
+
state=memory.resolved_state,
|
|
318
|
+
superseded_by=memory.superseded_by,
|
|
319
|
+
superseded_by_latest=None if head is None else head.uuid,
|
|
320
|
+
superseded_by_latest_state=None if head is None else _root_state_to_row_state(head.state),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def create_within_transaction(
|
|
325
|
+
db: aiosqlite.Connection,
|
|
326
|
+
*,
|
|
327
|
+
gist: str,
|
|
328
|
+
content: str,
|
|
329
|
+
session_id: str,
|
|
330
|
+
tier: Tier = Tier.JOURNAL,
|
|
331
|
+
) -> Memory:
|
|
332
|
+
"""`create`'s work, assuming the caller already holds an open transaction.
|
|
333
|
+
|
|
334
|
+
Neither commits nor rolls back — a caller composing this into a wider transaction (the
|
|
335
|
+
indexing layer, which writes the chunks in the same transaction as the row) owns both, and
|
|
336
|
+
`create` itself is the thin standalone wrapper below for a caller that wants this alone.
|
|
337
|
+
|
|
338
|
+
`tier` defaults to `journal` because that is what D3 makes a *new* observation: every agent
|
|
339
|
+
write lands unconsolidated. It is a parameter rather than a constant because `promote`'s
|
|
340
|
+
new-row form authors a record that is long-term from its first version — writing it as journal
|
|
341
|
+
and flipping it would spend a version on a row no reader ever saw, and invariant 8 makes a tier
|
|
342
|
+
flip a real mutation rather than a fix-up.
|
|
343
|
+
"""
|
|
344
|
+
new_uuid = str(uuid4())
|
|
345
|
+
now = timestamp()
|
|
346
|
+
await db.execute(
|
|
347
|
+
"INSERT INTO memory "
|
|
348
|
+
"(uuid, tier, gist, content, active, superseded_by, version, created_at, "
|
|
349
|
+
" updated_at, session_id, token_count) "
|
|
350
|
+
"VALUES (?, ?, ?, ?, 1, NULL, 1, ?, ?, ?, 0)",
|
|
351
|
+
(new_uuid, tier.value, gist, content, now, now, session_id),
|
|
352
|
+
)
|
|
353
|
+
return await require_existing(db, new_uuid)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
#: The two codes invariant 10's rejection carve-out applies to are `store.transactions`'s to name,
|
|
357
|
+
#: because the same rule governs every layer that owns a transaction. Every `ZikaronError` this
|
|
358
|
+
#: module raises outside that pair (`NOT_FOUND`, `INACTIVE_ROW`, `BAD_SUPERSESSION`) has written
|
|
359
|
+
#: nothing at the point it raises, so it rolls back the ordinary way.
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def commit_or_roll_back(db: aiosqlite.Connection, error: BaseException | None) -> None:
|
|
363
|
+
"""Commit on success or on a carve-out rejection; roll back on anything else.
|
|
364
|
+
|
|
365
|
+
Re-exported from `store.transactions`, which owns the rule, so that this module's own wrappers
|
|
366
|
+
and the composing callers reading them see one name. See that function for why only a
|
|
367
|
+
transaction-*owning* caller may call it, and why the neutral `_<verb>_within_transaction` cores
|
|
368
|
+
below — including their rejection helpers — never touch the transaction on any path.
|
|
369
|
+
"""
|
|
370
|
+
await transactions.commit_or_roll_back(db, error)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
async def create(db: aiosqlite.Connection, *, gist: str, content: str, session_id: str) -> Memory:
|
|
374
|
+
"""Insert a new `memory` row at `version=1`, `tier='journal'`, `active=1`.
|
|
375
|
+
|
|
376
|
+
This is the row primitive `zikaron_memory_remember` writes through, after the chunking
|
|
377
|
+
preflight's `gist_max_tokens` bound and its own dedup search have already run — this function
|
|
378
|
+
enforces neither, since both need the tokenizer or the indexes this layer does not touch.
|
|
379
|
+
`content` and `gist` non-emptiness is enforced by the table's own `CHECK` constraints, which
|
|
380
|
+
SQLite raises as `sqlite3.IntegrityError` rather than a `ZikaronError` — a boundary this layer
|
|
381
|
+
leaves to its caller, since the chunking preflight refuses empty prose as `bounds` before any
|
|
382
|
+
SQL runs and is where an agent-supplied value is checked.
|
|
383
|
+
|
|
384
|
+
Does not emit a `remember` event: `indexing.md` §"Implementation constraints" says why. Mints
|
|
385
|
+
no receipt either — invariant 9's `own_write` source is for a row the caller can *already*
|
|
386
|
+
write, and a row that did not exist a moment ago needs no licence to create. Takes no
|
|
387
|
+
`CallParams`, unlike every other verb here, precisely because it authorizes nothing and logs
|
|
388
|
+
nothing.
|
|
389
|
+
|
|
390
|
+
Opens and commits its own transaction — invariant 2's "exactly one SQLite transaction" for a
|
|
391
|
+
mutation with no rejection path to also commit. Call `create_within_transaction` directly
|
|
392
|
+
instead if composing this into a wider transaction a caller already owns; calling this function
|
|
393
|
+
from inside one would raise `OperationalError`, since SQLite refuses a nested `BEGIN`.
|
|
394
|
+
"""
|
|
395
|
+
await db.execute("BEGIN")
|
|
396
|
+
error: BaseException | None = None
|
|
397
|
+
try:
|
|
398
|
+
created = await create_within_transaction(
|
|
399
|
+
db, gist=gist, content=content, session_id=session_id
|
|
400
|
+
)
|
|
401
|
+
except BaseException as caught:
|
|
402
|
+
error = caught
|
|
403
|
+
raise
|
|
404
|
+
finally:
|
|
405
|
+
await commit_or_roll_back(db, error)
|
|
406
|
+
return created
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
async def fetch_within_transaction(
|
|
410
|
+
db: aiosqlite.Connection, *, uuids: list[str], ctx: CallParams
|
|
411
|
+
) -> tuple[list[FetchedMemory], list[str]]:
|
|
412
|
+
"""`fetch`'s work, assuming the caller already holds an open transaction.
|
|
413
|
+
|
|
414
|
+
Neither commits nor rolls back — see `create_within_transaction`'s docstring for why this
|
|
415
|
+
split exists.
|
|
416
|
+
"""
|
|
417
|
+
seen: set[str] = set()
|
|
418
|
+
ordered_distinct: list[str] = []
|
|
419
|
+
for uuid in uuids:
|
|
420
|
+
if uuid not in seen:
|
|
421
|
+
seen.add(uuid)
|
|
422
|
+
ordered_distinct.append(uuid)
|
|
423
|
+
|
|
424
|
+
records: list[FetchedMemory] = []
|
|
425
|
+
missing: list[str] = []
|
|
426
|
+
for uuid in ordered_distinct:
|
|
427
|
+
memory = await load(db, uuid)
|
|
428
|
+
found = memory is not None
|
|
429
|
+
if memory is not None:
|
|
430
|
+
fetched = await _to_fetched(db, memory, max_depth=ctx.max_depth)
|
|
431
|
+
records.append(fetched)
|
|
432
|
+
await receipts.mint(
|
|
433
|
+
db,
|
|
434
|
+
key=ReceiptKey(
|
|
435
|
+
session_id=ctx.session_id,
|
|
436
|
+
client_kind=ctx.client_kind,
|
|
437
|
+
memory_uuid=uuid,
|
|
438
|
+
version=memory.version,
|
|
439
|
+
),
|
|
440
|
+
at=timestamp(),
|
|
441
|
+
source=ReceiptSource.FETCH,
|
|
442
|
+
)
|
|
443
|
+
else:
|
|
444
|
+
missing.append(uuid)
|
|
445
|
+
await log_event(
|
|
446
|
+
db,
|
|
447
|
+
ctx=ctx,
|
|
448
|
+
detail=FetchDetail(version=memory.version if memory is not None else None, found=found),
|
|
449
|
+
memory_uuid=uuid,
|
|
450
|
+
)
|
|
451
|
+
return records, missing
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
async def fetch(
|
|
455
|
+
db: aiosqlite.Connection, *, uuids: list[str], ctx: CallParams
|
|
456
|
+
) -> tuple[list[FetchedMemory], list[str]]:
|
|
457
|
+
"""Resolve every uuid in `uuids`, minting a `fetch` receipt for each one found.
|
|
458
|
+
|
|
459
|
+
Duplicate uuids are collapsed to one record, in first-occurrence order, per
|
|
460
|
+
`architecture.md`'s `zikaron_memory_fetch` contract. An unknown uuid is reported in the returned
|
|
461
|
+
`missing` list rather than failing the whole call — `schema.md` §Bounds: "a dead handle is
|
|
462
|
+
something the agent needs told rather than a reason to fail the batch."
|
|
463
|
+
|
|
464
|
+
Emits one `fetch` event per **distinct** uuid requested — found or not, since `found` is
|
|
465
|
+
itself part of what the event records — inside the same transaction as the receipts it
|
|
466
|
+
mints and commits, per invariant 10.
|
|
467
|
+
|
|
468
|
+
Opens and commits its own transaction. Call `fetch_within_transaction` directly instead if
|
|
469
|
+
composing this into a wider transaction a caller already owns — see `create`'s docstring for
|
|
470
|
+
why calling this function itself from inside one would fail.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
`(records, missing)` — `records` in the order `uuids` first named each distinct uuid,
|
|
474
|
+
and `missing` the subset of distinct uuids that named no row.
|
|
475
|
+
"""
|
|
476
|
+
await db.execute("BEGIN")
|
|
477
|
+
error: BaseException | None = None
|
|
478
|
+
try:
|
|
479
|
+
result = await fetch_within_transaction(db, uuids=uuids, ctx=ctx)
|
|
480
|
+
except BaseException as caught:
|
|
481
|
+
# Every statement inside `fetch_within_transaction` is a SELECT/upsert/insert with no
|
|
482
|
+
# CHECK constraint a well-formed call can trip — this guards against a driver-level
|
|
483
|
+
# failure (disk I/O, a killed connection) rather than a business-logic rejection, so no
|
|
484
|
+
# fixture in this test module can trigger it without faking the connection itself.
|
|
485
|
+
error = caught
|
|
486
|
+
raise
|
|
487
|
+
finally:
|
|
488
|
+
await commit_or_roll_back(db, error)
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
async def record_version_conflict(
|
|
493
|
+
db: aiosqlite.Connection,
|
|
494
|
+
*,
|
|
495
|
+
current: Memory,
|
|
496
|
+
ctx: CallParams,
|
|
497
|
+
verb: str,
|
|
498
|
+
presented_version: int,
|
|
499
|
+
) -> ConflictRecord:
|
|
500
|
+
"""Mint the `conflict` receipt, log the `version_conflict` event, and build the record.
|
|
501
|
+
|
|
502
|
+
Everything invariant 10's carve-out requires a version conflict to leave behind, and **no
|
|
503
|
+
raise** — because the two ladders differ in exactly that: a primary-agent verb names one row and
|
|
504
|
+
raises on it, while a consolidator verb names several, has to evaluate all of them, and raises
|
|
505
|
+
once carrying every conflicting record. Both need the identical per-row work, so it lives here
|
|
506
|
+
and neither ladder writes its own.
|
|
507
|
+
|
|
508
|
+
The receipt is what makes D26's "re-decide in one round trip" true: the payload hands back the
|
|
509
|
+
current full record, so the caller's retry has a licence for it without a `fetch` it may not
|
|
510
|
+
even have (the consolidator has no `fetch` at all).
|
|
511
|
+
|
|
512
|
+
Does **not** commit. Whether the audit trail this writes survives is the transaction-owning
|
|
513
|
+
caller's decision, through `commit_or_roll_back`, which inspects the raised error's code — see
|
|
514
|
+
that function for why a neutral core may never make it.
|
|
515
|
+
"""
|
|
516
|
+
await receipts.mint(
|
|
517
|
+
db,
|
|
518
|
+
key=ReceiptKey(
|
|
519
|
+
session_id=ctx.session_id,
|
|
520
|
+
client_kind=ctx.client_kind,
|
|
521
|
+
memory_uuid=current.uuid,
|
|
522
|
+
version=current.version,
|
|
523
|
+
),
|
|
524
|
+
at=timestamp(),
|
|
525
|
+
source=ReceiptSource.CONFLICT,
|
|
526
|
+
)
|
|
527
|
+
await log_event(
|
|
528
|
+
db,
|
|
529
|
+
ctx=ctx,
|
|
530
|
+
detail=VersionConflictDetail(
|
|
531
|
+
verb=verb,
|
|
532
|
+
expected_version=presented_version,
|
|
533
|
+
actual_version=current.version,
|
|
534
|
+
),
|
|
535
|
+
memory_uuid=current.uuid,
|
|
536
|
+
)
|
|
537
|
+
return await conflict_record(db, current, max_depth=ctx.max_depth)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
async def record_no_receipt(
|
|
541
|
+
db: aiosqlite.Connection, *, uuid: str, ctx: CallParams, verb: str, presented_version: int
|
|
542
|
+
) -> None:
|
|
543
|
+
"""Log the `no_receipt` event for one uuid, and mint nothing.
|
|
544
|
+
|
|
545
|
+
No receipt is minted, unlike a version conflict: a missing receipt hands back nothing to
|
|
546
|
+
re-decide from, since the caller already named the current version correctly — the record it
|
|
547
|
+
holds is not stale, it is simply unlicensed. Split from the raise for the same reason
|
|
548
|
+
`record_version_conflict` is: the consolidator ladder logs one of these per offending uuid and
|
|
549
|
+
then raises once naming all of them.
|
|
550
|
+
"""
|
|
551
|
+
await log_event(
|
|
552
|
+
db,
|
|
553
|
+
ctx=ctx,
|
|
554
|
+
detail=NoReceiptDetail(verb=verb, version_presented=presented_version),
|
|
555
|
+
memory_uuid=uuid,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
async def _reject_version_conflict(
|
|
560
|
+
db: aiosqlite.Connection,
|
|
561
|
+
*,
|
|
562
|
+
current: Memory,
|
|
563
|
+
ctx: CallParams,
|
|
564
|
+
verb: str,
|
|
565
|
+
presented_version: int,
|
|
566
|
+
) -> None:
|
|
567
|
+
"""Mint a `conflict` receipt at the current version, log `version_conflict`, then raise.
|
|
568
|
+
|
|
569
|
+
`schema.md` invariant 10: "A rejected call commits no domain mutation but does commit its
|
|
570
|
+
audit events and, for a version conflict, the receipt for the record it returned." Making
|
|
571
|
+
that true is the transaction-*owning* wrapper's job, not this function's — see the "Does
|
|
572
|
+
**not** commit" note below for why the two are split.
|
|
573
|
+
|
|
574
|
+
Raises `current` as a bare `ConflictRecord`, not a list: this function backs only
|
|
575
|
+
`_authorize_mutation`, which every primary-agent verb (`amend`, `retire`) calls for exactly
|
|
576
|
+
one named row, and `architecture.md`'s tool surface states
|
|
577
|
+
`zikaron_memory_amend`/`zikaron_memory_retire`'s
|
|
578
|
+
conflict shape as `{conflict: true, current: CONFLICT_RECORD}` — one object, never a list.
|
|
579
|
+
The list form (`current: [CONFLICT_RECORD, ...]`) belongs to the four consolidator verbs,
|
|
580
|
+
which can name several rows in one call and whose own ladder therefore composes
|
|
581
|
+
`record_version_conflict` directly rather than calling this.
|
|
582
|
+
|
|
583
|
+
Does **not** commit. A composing caller could stage its own write before ever calling into the
|
|
584
|
+
authorization ladder, and this function has no way to see that from where it sits —
|
|
585
|
+
so it never commits, unconditionally, rather than trying to reason about what came before it.
|
|
586
|
+
The transaction-*owning* wrapper (`create`/`fetch`/`amend`/`retire`) is the only place that
|
|
587
|
+
decides commit-vs-rollback at all, via `commit_or_roll_back`, which inspects the caught
|
|
588
|
+
error's *code* (this rejection's `VERSION_CONFLICT`, or `NO_READ_RECEIPT` from
|
|
589
|
+
`_reject_no_receipt`) and commits only for those two — see `commit_or_roll_back`'s own
|
|
590
|
+
docstring.
|
|
591
|
+
"""
|
|
592
|
+
record = await record_version_conflict(
|
|
593
|
+
db, current=current, ctx=ctx, verb=verb, presented_version=presented_version
|
|
594
|
+
)
|
|
595
|
+
raise ZikaronError(ErrorCode.VERSION_CONFLICT, current=record)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
async def _reject_no_receipt(
|
|
599
|
+
db: aiosqlite.Connection, *, uuid: str, ctx: CallParams, verb: str, presented_version: int
|
|
600
|
+
) -> None:
|
|
601
|
+
"""Log `no_receipt`, then raise — no receipt is minted for this rejection.
|
|
602
|
+
|
|
603
|
+
Unlike a version conflict, a missing receipt hands back nothing to re-decide from: the
|
|
604
|
+
caller already named the current version correctly, so the record it holds is not stale —
|
|
605
|
+
it is simply unlicensed. `schema.md` invariant 9 is normative for which verbs mint a receipt.
|
|
606
|
+
|
|
607
|
+
Does **not** commit, for the same reason `_reject_version_conflict` does not — see its
|
|
608
|
+
docstring.
|
|
609
|
+
"""
|
|
610
|
+
await record_no_receipt(db, uuid=uuid, ctx=ctx, verb=verb, presented_version=presented_version)
|
|
611
|
+
raise ZikaronError(
|
|
612
|
+
ErrorCode.NO_READ_RECEIPT, uuids=[uuid], hint="re-read it through fetch or next_group"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
async def _authorize_mutation(
|
|
617
|
+
db: aiosqlite.Connection,
|
|
618
|
+
*,
|
|
619
|
+
named_uuids: tuple[str, ...],
|
|
620
|
+
version: int,
|
|
621
|
+
ctx: CallParams,
|
|
622
|
+
verb: str,
|
|
623
|
+
) -> Memory:
|
|
624
|
+
"""Rungs 2-4 of the primary-agent ladder: existence, version, receipt — in that order.
|
|
625
|
+
|
|
626
|
+
`architecture.md`'s primary-agent ladder runs bounds, then existence, then version, then
|
|
627
|
+
receipt, then state legality, then the mutation itself. Bounds is this function's caller's job
|
|
628
|
+
(an agent-facing bound like `gist_max_tokens` needs a tokenizer this layer has none of); state
|
|
629
|
+
legality is the mutation-specific check `amend`/`retire` each run for themselves, since what
|
|
630
|
+
counts as illegal state differs between them. This function is exactly the three rungs every
|
|
631
|
+
mutating verb shares.
|
|
632
|
+
|
|
633
|
+
Args:
|
|
634
|
+
named_uuids: every uuid this call names, in order, **with the first element being the
|
|
635
|
+
row version/receipt is actually checked against** — `amend`'s `uuid`, or `retire`'s
|
|
636
|
+
`uuid` followed by `superseded_by` when given. Existence of *every* named uuid is
|
|
637
|
+
one rung (`architecture.md`: "existence of every named uuid"), so every element is
|
|
638
|
+
checked here, before the first element's own version and receipt, rather than left
|
|
639
|
+
to a later caller whose own check would run only after the first element had already
|
|
640
|
+
cleared version and receipt — which is exactly the ordering bug this parameter
|
|
641
|
+
exists to close. Must be non-empty.
|
|
642
|
+
"""
|
|
643
|
+
uuid, *other_named = named_uuids
|
|
644
|
+
current = await require_existing(db, uuid)
|
|
645
|
+
for other in other_named:
|
|
646
|
+
await require_existing(db, other)
|
|
647
|
+
if current.version != version:
|
|
648
|
+
await _reject_version_conflict(
|
|
649
|
+
db, current=current, ctx=ctx, verb=verb, presented_version=version
|
|
650
|
+
)
|
|
651
|
+
held = await receipts.spend(
|
|
652
|
+
db,
|
|
653
|
+
key=ReceiptKey(
|
|
654
|
+
session_id=ctx.session_id,
|
|
655
|
+
client_kind=ctx.client_kind,
|
|
656
|
+
memory_uuid=uuid,
|
|
657
|
+
version=version,
|
|
658
|
+
),
|
|
659
|
+
)
|
|
660
|
+
if not held:
|
|
661
|
+
await _reject_no_receipt(db, uuid=uuid, ctx=ctx, verb=verb, presented_version=version)
|
|
662
|
+
return current
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
async def mint_own_write_receipt(
|
|
666
|
+
db: aiosqlite.Connection, *, uuid: str, version: int, ctx: CallParams
|
|
667
|
+
) -> None:
|
|
668
|
+
"""Mint the writer's `own_write` receipt for `uuid` at `version`.
|
|
669
|
+
|
|
670
|
+
Invariant 9's `own_write` source: the caller authored this exact prose, so it may write it
|
|
671
|
+
again without fetching it back. Split out from the version-bump path because a *newly created*
|
|
672
|
+
row has nothing to revoke — every receipt for a uuid that did not exist a moment ago is the one
|
|
673
|
+
just minted — while an amended row must also revoke the receipts the bump invalidated.
|
|
674
|
+
"""
|
|
675
|
+
await receipts.mint(
|
|
676
|
+
db,
|
|
677
|
+
key=ReceiptKey(
|
|
678
|
+
session_id=ctx.session_id,
|
|
679
|
+
client_kind=ctx.client_kind,
|
|
680
|
+
memory_uuid=uuid,
|
|
681
|
+
version=version,
|
|
682
|
+
),
|
|
683
|
+
at=timestamp(),
|
|
684
|
+
source=ReceiptSource.OWN_WRITE,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
async def _bump_version_and_mint_own_write(
|
|
689
|
+
db: aiosqlite.Connection, *, uuid: str, new_version: int, ctx: CallParams
|
|
690
|
+
) -> None:
|
|
691
|
+
"""Mint the writer's own-write receipt at `new_version`, then revoke every other receipt for
|
|
692
|
+
`uuid`. Order matters: minting first is what keeps the row from being, for even one
|
|
693
|
+
statement's duration, a written row with no receipt for it at all."""
|
|
694
|
+
await mint_own_write_receipt(db, uuid=uuid, version=new_version, ctx=ctx)
|
|
695
|
+
await receipts.revoke_on_version_bump(
|
|
696
|
+
db,
|
|
697
|
+
keep=ReceiptKey(
|
|
698
|
+
session_id=ctx.session_id,
|
|
699
|
+
client_kind=ctx.client_kind,
|
|
700
|
+
memory_uuid=uuid,
|
|
701
|
+
version=new_version,
|
|
702
|
+
),
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _require_active(memory: Memory) -> None:
|
|
707
|
+
"""The shared half of `amend`/`retire`'s state-legality rung: refuse a row already `active=0`.
|
|
708
|
+
|
|
709
|
+
Extracted so the raise site is a function whose only job is raising, per `TRY301`'s "abstract
|
|
710
|
+
the raise" guidance — the two callers otherwise duplicated the same check, and duplicating a
|
|
711
|
+
validation rule is exactly the drift class `coding-standards.md` warns about for anything the
|
|
712
|
+
design states as a single rule.
|
|
713
|
+
|
|
714
|
+
Raises:
|
|
715
|
+
ZikaronError: `INACTIVE_ROW`, naming the row's current `state` — never `LIVE`, since
|
|
716
|
+
this function's own name is the guarantee that it is called only when `active` is
|
|
717
|
+
about to matter.
|
|
718
|
+
"""
|
|
719
|
+
if not memory.active:
|
|
720
|
+
raise ZikaronError(ErrorCode.INACTIVE_ROW, uuid=memory.uuid, state=memory.resolved_state)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
async def apply_rewrite(
|
|
724
|
+
db: aiosqlite.Connection, *, current: Memory, rewrite: Rewrite, ctx: CallParams
|
|
725
|
+
) -> Memory:
|
|
726
|
+
"""Write new prose over `current` and bump its `version`. Authorizes nothing, logs nothing.
|
|
727
|
+
|
|
728
|
+
The three `apply_*` functions here are the **column-level** mutators every write verb in the
|
|
729
|
+
system ends at, split out from the verbs so that a second verb writing the same columns cannot
|
|
730
|
+
write them a second, slightly different way. Each one bumps `version` (invariant 8), refreshes
|
|
731
|
+
`updated_at`, mints the writer's `own_write` receipt at the new version and revokes every other
|
|
732
|
+
receipt for the row (invariant 9) — and does **not** decide who may call it, which state is
|
|
733
|
+
legal, or what event describes it. Those three differ per verb: `amend` and `retire` run the
|
|
734
|
+
primary-agent ladder and emit their own kinds, while `merge`, `promote` and `discard` run the
|
|
735
|
+
consolidator ladder and emit theirs, and both classes of caller need the identical column write
|
|
736
|
+
underneath.
|
|
737
|
+
|
|
738
|
+
Args:
|
|
739
|
+
current: the row as authorization found it, at the version being replaced. Passed in rather
|
|
740
|
+
than re-read, because the caller's ladder has already read it and a second read inside
|
|
741
|
+
one transaction would only be another chance for the two to disagree about what is
|
|
742
|
+
being overwritten.
|
|
743
|
+
|
|
744
|
+
Returns:
|
|
745
|
+
The row as this call left it, re-read so the caller sees stored values rather than the ones
|
|
746
|
+
it hoped it wrote.
|
|
747
|
+
"""
|
|
748
|
+
new_version = current.version + 1
|
|
749
|
+
await db.execute(
|
|
750
|
+
"UPDATE memory SET gist = ?, content = ?, version = ?, updated_at = ? WHERE uuid = ?",
|
|
751
|
+
(rewrite.gist, rewrite.content, new_version, timestamp(), current.uuid),
|
|
752
|
+
)
|
|
753
|
+
await _bump_version_and_mint_own_write(db, uuid=current.uuid, new_version=new_version, ctx=ctx)
|
|
754
|
+
return await require_existing(db, current.uuid)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
async def apply_retirement(
|
|
758
|
+
db: aiosqlite.Connection, *, current: Memory, superseded_by: str | None, ctx: CallParams
|
|
759
|
+
) -> Memory:
|
|
760
|
+
"""Soft-retire `current`, optionally writing its one supersession edge, and bump `version`.
|
|
761
|
+
|
|
762
|
+
Validates the edge (invariant 6) immediately before writing it, in this same transaction, which
|
|
763
|
+
is what keeps edge validation and edge writing from drifting apart across the several verbs that
|
|
764
|
+
create edges — `retire` with a replacement, `merge` absorbing rows into its target, `promote`
|
|
765
|
+
absorbing rows into a new record. `superseded_by=None` retires outright and validates nothing,
|
|
766
|
+
because there is no claim about a replacement to be false.
|
|
767
|
+
|
|
768
|
+
Authorizes nothing and logs nothing; see `apply_rewrite` for the reason that split exists.
|
|
769
|
+
|
|
770
|
+
Raises:
|
|
771
|
+
ZikaronError: `BAD_SUPERSESSION` if `superseded_by` names an illegal edge — a self-edge, a
|
|
772
|
+
target already retired-outright at this instant, or one that would close a cycle; or
|
|
773
|
+
`NOT_FOUND` if it names no row, which the caller's own existence rung should have caught
|
|
774
|
+
first.
|
|
775
|
+
"""
|
|
776
|
+
if superseded_by is not None:
|
|
777
|
+
await supersession.validate_new_edge(
|
|
778
|
+
db, from_uuid=current.uuid, to_uuid=superseded_by, max_depth=ctx.max_depth
|
|
779
|
+
)
|
|
780
|
+
new_version = current.version + 1
|
|
781
|
+
await db.execute(
|
|
782
|
+
"UPDATE memory SET active = 0, superseded_by = ?, version = ?, updated_at = ? "
|
|
783
|
+
"WHERE uuid = ?",
|
|
784
|
+
(superseded_by, new_version, timestamp(), current.uuid),
|
|
785
|
+
)
|
|
786
|
+
await _bump_version_and_mint_own_write(db, uuid=current.uuid, new_version=new_version, ctx=ctx)
|
|
787
|
+
return await require_existing(db, current.uuid)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
async def apply_tier(
|
|
791
|
+
db: aiosqlite.Connection, *, current: Memory, tier: Tier, ctx: CallParams
|
|
792
|
+
) -> Memory:
|
|
793
|
+
"""Move `current` between tiers and bump its `version`. Authorizes nothing, logs nothing.
|
|
794
|
+
|
|
795
|
+
A tier flip is a full mutation rather than a metadata touch, which is invariant 8's own list
|
|
796
|
+
("including a `tier` flip and a `superseded_by` write") and not a strict reading of it: a
|
|
797
|
+
primary agent may be amending the row concurrently, and D3 makes the tier the difference between
|
|
798
|
+
a journal line and a long-term record, so a flip that left `version` alone would be a change
|
|
799
|
+
another actor's receipt still licensed it to overwrite.
|
|
800
|
+
|
|
801
|
+
Touches no indexed column — `memory_fts` indexes `gist` and `content`, and `tier` appears in
|
|
802
|
+
neither index — so a caller flipping a tier without changing prose has no index to rebuild.
|
|
803
|
+
"""
|
|
804
|
+
new_version = current.version + 1
|
|
805
|
+
await db.execute(
|
|
806
|
+
"UPDATE memory SET tier = ?, version = ?, updated_at = ? WHERE uuid = ?",
|
|
807
|
+
(tier.value, new_version, timestamp(), current.uuid),
|
|
808
|
+
)
|
|
809
|
+
await _bump_version_and_mint_own_write(db, uuid=current.uuid, new_version=new_version, ctx=ctx)
|
|
810
|
+
return await require_existing(db, current.uuid)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
async def amend_within_transaction(
|
|
814
|
+
db: aiosqlite.Connection, *, uuid: str, version: int, rewrite: Rewrite, ctx: CallParams
|
|
815
|
+
) -> tuple[Memory, Memory]:
|
|
816
|
+
"""`amend`'s work, assuming the caller already holds an open transaction.
|
|
817
|
+
|
|
818
|
+
Never commits or rolls back itself, on any path — see `create_within_transaction`'s
|
|
819
|
+
docstring for the general reason, and `_reject_version_conflict`/`_reject_no_receipt`'s own
|
|
820
|
+
docstrings for why even their rejection paths raise without touching the transaction.
|
|
821
|
+
Invariant 10's rejection carve-out (the audit event, and for a conflict the receipt, survive
|
|
822
|
+
even though the domain mutation does not) is honored by whichever wrapper calls this — via
|
|
823
|
+
`commit_or_roll_back` — not by this function.
|
|
824
|
+
|
|
825
|
+
Returns:
|
|
826
|
+
`(before, after)` — the row as authorization found it and as this call left it. Both,
|
|
827
|
+
rather than only the new row, because a composing caller needs the **pre-write** `gist`
|
|
828
|
+
and `content` to remove the right postings from `memory_fts`: that index is
|
|
829
|
+
external-content, so removing them by reading the content table would have to happen
|
|
830
|
+
before this function's own `UPDATE`, which is to say before authorization finished. The
|
|
831
|
+
`from_version` an `amend` event reports comes from the same place.
|
|
832
|
+
"""
|
|
833
|
+
current = await _authorize_mutation(
|
|
834
|
+
db, named_uuids=(uuid,), version=version, ctx=ctx, verb="amend"
|
|
835
|
+
)
|
|
836
|
+
_require_active(current)
|
|
837
|
+
return current, await apply_rewrite(db, current=current, rewrite=rewrite, ctx=ctx)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
async def amend(
|
|
841
|
+
db: aiosqlite.Connection, *, uuid: str, version: int, rewrite: Rewrite, ctx: CallParams
|
|
842
|
+
) -> Memory:
|
|
843
|
+
"""Full rewrite of `gist`/`content`, bumping `version` (D6, invariant 8).
|
|
844
|
+
|
|
845
|
+
Runs the shared authorization rungs, then this verb's own state-legality rung — `amend`
|
|
846
|
+
rejects a row already `active=0` as `inactive_row`, naming its current `state` — then
|
|
847
|
+
mutates in one transaction with the version bump and the receipt revocation/mint.
|
|
848
|
+
|
|
849
|
+
Does not emit an `amend` event: `indexing.md` §"Implementation constraints". The indexing layer
|
|
850
|
+
wraps this function with the chunking preflight and emits the composed event, where the size
|
|
851
|
+
fields it carries have real values.
|
|
852
|
+
|
|
853
|
+
A `version_conflict` or `no_read_receipt` rejection commits its own receipt and event
|
|
854
|
+
(invariant 10's carve-out for a rejected call) — see `_reject_version_conflict` and
|
|
855
|
+
`_reject_no_receipt`, and `commit_or_roll_back`, which is what actually decides that here,
|
|
856
|
+
in this wrapper rather than in the neutral core. A `not_found` or `inactive_row` rejection
|
|
857
|
+
has written nothing yet at the point it raises, so it rolls back cleanly with nothing to
|
|
858
|
+
preserve.
|
|
859
|
+
|
|
860
|
+
Opens and commits its own transaction. Call `amend_within_transaction` directly instead if
|
|
861
|
+
composing this into a wider transaction a caller already owns — see `create`'s docstring for
|
|
862
|
+
why calling this function itself from inside one would fail.
|
|
863
|
+
|
|
864
|
+
Raises:
|
|
865
|
+
ZikaronError: `NOT_FOUND` if `uuid` names no row; `VERSION_CONFLICT` if `version` is
|
|
866
|
+
not the row's current one (mints a `conflict` receipt and logs the event before
|
|
867
|
+
raising); `NO_READ_RECEIPT` if the caller holds no matching receipt (logs the event
|
|
868
|
+
before raising); `INACTIVE_ROW` if the row is already `active=0`.
|
|
869
|
+
"""
|
|
870
|
+
await db.execute("BEGIN")
|
|
871
|
+
error: BaseException | None = None
|
|
872
|
+
try:
|
|
873
|
+
_, amended = await amend_within_transaction(
|
|
874
|
+
db, uuid=uuid, version=version, rewrite=rewrite, ctx=ctx
|
|
875
|
+
)
|
|
876
|
+
except BaseException as caught:
|
|
877
|
+
error = caught
|
|
878
|
+
raise
|
|
879
|
+
finally:
|
|
880
|
+
await commit_or_roll_back(db, error)
|
|
881
|
+
return amended
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
async def retire_within_transaction(
|
|
885
|
+
db: aiosqlite.Connection,
|
|
886
|
+
*,
|
|
887
|
+
uuid: str,
|
|
888
|
+
version: int,
|
|
889
|
+
superseded_by: str | None,
|
|
890
|
+
ctx: CallParams,
|
|
891
|
+
) -> Memory:
|
|
892
|
+
"""`retire`'s work, assuming the caller already holds an open transaction.
|
|
893
|
+
|
|
894
|
+
Never commits or rolls back itself, on any path — see `amend_within_transaction`'s
|
|
895
|
+
docstring, which states the same contract for the same reason.
|
|
896
|
+
"""
|
|
897
|
+
named_uuids = (uuid,) if superseded_by is None else (uuid, superseded_by)
|
|
898
|
+
current = await _authorize_mutation(
|
|
899
|
+
db, named_uuids=named_uuids, version=version, ctx=ctx, verb="retire"
|
|
900
|
+
)
|
|
901
|
+
_require_active(current)
|
|
902
|
+
retired = await apply_retirement(db, current=current, superseded_by=superseded_by, ctx=ctx)
|
|
903
|
+
await log_event(
|
|
904
|
+
db,
|
|
905
|
+
ctx=ctx,
|
|
906
|
+
detail=RetireDetail(
|
|
907
|
+
from_version=current.version,
|
|
908
|
+
to_version=retired.version,
|
|
909
|
+
superseded_by=superseded_by,
|
|
910
|
+
),
|
|
911
|
+
memory_uuid=uuid,
|
|
912
|
+
)
|
|
913
|
+
return retired
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
async def retire(
|
|
917
|
+
db: aiosqlite.Connection,
|
|
918
|
+
*,
|
|
919
|
+
uuid: str,
|
|
920
|
+
version: int,
|
|
921
|
+
superseded_by: str | None,
|
|
922
|
+
ctx: CallParams,
|
|
923
|
+
) -> Memory:
|
|
924
|
+
"""Soft-retire a row (D16): `active=0`, optionally pointing `superseded_by` at a replacement.
|
|
925
|
+
|
|
926
|
+
`superseded_by` set writes a supersession edge, validated by `supersession.validate_new_edge`
|
|
927
|
+
(invariant 6) inside this same transaction — no self-edge, no cycle, and the target not
|
|
928
|
+
already retired-outright at this instant. `superseded_by` omitted retires outright: the row
|
|
929
|
+
becomes a terminal component if anything else already pointed at it, which is legal
|
|
930
|
+
(invariant 6's "a root is live or terminal, and both are legal").
|
|
931
|
+
|
|
932
|
+
Emits `retire`'s event — the one mutation kind this layer owns outright, since its `detail`
|
|
933
|
+
names no chunking-derived field (`from_version`, `to_version`, `superseded_by`, none of which
|
|
934
|
+
need a
|
|
935
|
+
tokenizer or an index).
|
|
936
|
+
|
|
937
|
+
Opens and commits its own transaction. Call `retire_within_transaction` directly instead if
|
|
938
|
+
composing this into a wider transaction a caller already owns — see `create`'s docstring for
|
|
939
|
+
why calling this function itself from inside one would fail.
|
|
940
|
+
|
|
941
|
+
Raises:
|
|
942
|
+
ZikaronError: as `amend`, for the shared rungs; `NOT_FOUND` if `uuid` **or**
|
|
943
|
+
`superseded_by` (when given) names no row — `uuid` checked first, since it is the
|
|
944
|
+
call's own subject, then `superseded_by`; both existence checks run before version or
|
|
945
|
+
receipt is checked for `uuid`, per the ladder's existence rung preceding its version
|
|
946
|
+
and receipt rungs; `INACTIVE_ROW` if the row is already `active=0`;
|
|
947
|
+
`BAD_SUPERSESSION` (from `supersession.validate_new_edge`) if `superseded_by` names
|
|
948
|
+
an illegal edge.
|
|
949
|
+
"""
|
|
950
|
+
await db.execute("BEGIN")
|
|
951
|
+
error: BaseException | None = None
|
|
952
|
+
try:
|
|
953
|
+
retired = await retire_within_transaction(
|
|
954
|
+
db, uuid=uuid, version=version, superseded_by=superseded_by, ctx=ctx
|
|
955
|
+
)
|
|
956
|
+
except BaseException as caught:
|
|
957
|
+
error = caught
|
|
958
|
+
raise
|
|
959
|
+
finally:
|
|
960
|
+
await commit_or_roll_back(db, error)
|
|
961
|
+
return retired
|