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.
Files changed (162) hide show
  1. zikaron/__init__.py +1 -0
  2. zikaron/cli/__init__.py +1 -0
  3. zikaron/cli/main.py +114 -0
  4. zikaron/core/__init__.py +1 -0
  5. zikaron/core/clock.py +78 -0
  6. zikaron/core/config/__init__.py +1 -0
  7. zikaron/core/config/keys.py +395 -0
  8. zikaron/core/config/resolution.py +267 -0
  9. zikaron/core/consolidation/__init__.py +1 -0
  10. zikaron/core/consolidation/authorization.py +316 -0
  11. zikaron/core/consolidation/candidates.py +147 -0
  12. zikaron/core/consolidation/context.py +166 -0
  13. zikaron/core/consolidation/grouping.py +383 -0
  14. zikaron/core/consolidation/groups.py +490 -0
  15. zikaron/core/consolidation/payload.py +246 -0
  16. zikaron/core/consolidation/planning.py +192 -0
  17. zikaron/core/consolidation/rowstate.py +68 -0
  18. zikaron/core/consolidation/runs.py +306 -0
  19. zikaron/core/consolidation/serving.py +462 -0
  20. zikaron/core/consolidation/verbs.py +500 -0
  21. zikaron/core/errors.py +355 -0
  22. zikaron/core/events.py +748 -0
  23. zikaron/core/indexing/__init__.py +1 -0
  24. zikaron/core/indexing/acquisition.py +255 -0
  25. zikaron/core/indexing/chunking.py +368 -0
  26. zikaron/core/indexing/encoder.py +537 -0
  27. zikaron/core/indexing/lexical.py +86 -0
  28. zikaron/core/indexing/model_cache.py +93 -0
  29. zikaron/core/indexing/model_pin.py +89 -0
  30. zikaron/core/indexing/vectors.py +223 -0
  31. zikaron/core/indexing/writes.py +461 -0
  32. zikaron/core/knowledge/__init__.py +5 -0
  33. zikaron/core/knowledge/arms.py +104 -0
  34. zikaron/core/knowledge/builds.py +204 -0
  35. zikaron/core/knowledge/candidates.py +130 -0
  36. zikaron/core/knowledge/changes.py +175 -0
  37. zikaron/core/knowledge/chunking.py +376 -0
  38. zikaron/core/knowledge/counters.py +228 -0
  39. zikaron/core/knowledge/database.py +380 -0
  40. zikaron/core/knowledge/ddl.py +196 -0
  41. zikaron/core/knowledge/disposal.py +213 -0
  42. zikaron/core/knowledge/errors.py +166 -0
  43. zikaron/core/knowledge/files.py +202 -0
  44. zikaron/core/knowledge/git.py +385 -0
  45. zikaron/core/knowledge/groups.py +450 -0
  46. zikaron/core/knowledge/lexical.py +64 -0
  47. zikaron/core/knowledge/lifecycle.py +418 -0
  48. zikaron/core/knowledge/lock.py +277 -0
  49. zikaron/core/knowledge/meta.py +393 -0
  50. zikaron/core/knowledge/paths.py +55 -0
  51. zikaron/core/knowledge/pending.py +59 -0
  52. zikaron/core/knowledge/registry.py +264 -0
  53. zikaron/core/knowledge/repair.py +152 -0
  54. zikaron/core/knowledge/reporting.py +436 -0
  55. zikaron/core/knowledge/roots.py +91 -0
  56. zikaron/core/knowledge/scan.py +429 -0
  57. zikaron/core/knowledge/search.py +346 -0
  58. zikaron/core/knowledge/state.py +174 -0
  59. zikaron/core/knowledge/text.py +166 -0
  60. zikaron/core/knowledge/vectors.py +102 -0
  61. zikaron/core/knowledge/walk.py +264 -0
  62. zikaron/core/knowledge/writes.py +127 -0
  63. zikaron/core/records/__init__.py +1 -0
  64. zikaron/core/records/memory.py +961 -0
  65. zikaron/core/records/receipts.py +161 -0
  66. zikaron/core/records/supersession.py +221 -0
  67. zikaron/core/retrieval/__init__.py +1 -0
  68. zikaron/core/retrieval/arms.py +318 -0
  69. zikaron/core/retrieval/block.py +107 -0
  70. zikaron/core/retrieval/eligibility.py +164 -0
  71. zikaron/core/retrieval/query.py +327 -0
  72. zikaron/core/retrieval/ranking.py +260 -0
  73. zikaron/core/retrieval/reads.py +294 -0
  74. zikaron/core/retrieval/retrieve.py +158 -0
  75. zikaron/core/retrieval/similarity.py +87 -0
  76. zikaron/core/signals/__init__.py +34 -0
  77. zikaron/core/signals/contention.py +106 -0
  78. zikaron/core/signals/dedup.py +201 -0
  79. zikaron/core/signals/horizon.py +47 -0
  80. zikaron/core/signals/repair.py +161 -0
  81. zikaron/core/signals/retirement.py +83 -0
  82. zikaron/core/signals/sessions.py +105 -0
  83. zikaron/core/signals/writes.py +200 -0
  84. zikaron/core/store/__init__.py +1 -0
  85. zikaron/core/store/connection.py +202 -0
  86. zikaron/core/store/ddl.py +215 -0
  87. zikaron/core/store/embedder.py +45 -0
  88. zikaron/core/store/meta.py +152 -0
  89. zikaron/core/store/permissions.py +160 -0
  90. zikaron/core/store/store.py +408 -0
  91. zikaron/core/store/transactions.py +181 -0
  92. zikaron/core/write/__init__.py +33 -0
  93. zikaron/core/write/dedup.py +145 -0
  94. zikaron/core/write/tools.py +290 -0
  95. zikaron/doctor/__init__.py +1 -0
  96. zikaron/doctor/checks.py +220 -0
  97. zikaron/doctor/main.py +64 -0
  98. zikaron/harness/__init__.py +1 -0
  99. zikaron/harness/detect.py +92 -0
  100. zikaron/harness/spec.py +320 -0
  101. zikaron/hook/__init__.py +1 -0
  102. zikaron/hook/connect.py +379 -0
  103. zikaron/hook/envelope.py +106 -0
  104. zikaron/hook/failure.py +104 -0
  105. zikaron/hook/limits.py +61 -0
  106. zikaron/hook/main.py +118 -0
  107. zikaron/hook/push.py +183 -0
  108. zikaron/hook/rpc.py +85 -0
  109. zikaron/hook/spawn_warm.py +81 -0
  110. zikaron/hook/subagent_policy.py +57 -0
  111. zikaron/hook/tripwire.py +54 -0
  112. zikaron/hook/warm_helper.py +137 -0
  113. zikaron/hook/write_policy.py +319 -0
  114. zikaron/install/__init__.py +4 -0
  115. zikaron/install/__main__.py +18 -0
  116. zikaron/install/assets.py +394 -0
  117. zikaron/install/entries.py +370 -0
  118. zikaron/install/harness.py +185 -0
  119. zikaron/install/main.py +375 -0
  120. zikaron/install/targets.py +789 -0
  121. zikaron/install/writer.py +973 -0
  122. zikaron/knowledge/__init__.py +1 -0
  123. zikaron/knowledge/__main__.py +17 -0
  124. zikaron/knowledge/indexer/__init__.py +1 -0
  125. zikaron/knowledge/indexer/__main__.py +17 -0
  126. zikaron/knowledge/indexer/detach.py +83 -0
  127. zikaron/knowledge/indexer/main.py +187 -0
  128. zikaron/knowledge/main.py +466 -0
  129. zikaron/knowledge/scope.py +133 -0
  130. zikaron/mcp/__init__.py +6 -0
  131. zikaron/mcp/connection.py +583 -0
  132. zikaron/mcp/consolidator.py +316 -0
  133. zikaron/mcp/errors.py +73 -0
  134. zikaron/mcp/main.py +66 -0
  135. zikaron/mcp/primary.py +420 -0
  136. zikaron/mcp/server.py +96 -0
  137. zikaron/mcp/spill.py +328 -0
  138. zikaron/mcp/tool_names.py +67 -0
  139. zikaron/py.typed +0 -0
  140. zikaron/service/__init__.py +1 -0
  141. zikaron/service/asyncio_compat.py +126 -0
  142. zikaron/service/context.py +251 -0
  143. zikaron/service/dispatch.py +332 -0
  144. zikaron/service/dispatch_consolidation.py +397 -0
  145. zikaron/service/dispatch_knowledge.py +469 -0
  146. zikaron/service/envelope.py +166 -0
  147. zikaron/service/lifecycle.py +467 -0
  148. zikaron/service/log.py +96 -0
  149. zikaron/service/main.py +531 -0
  150. zikaron/service/params.py +168 -0
  151. zikaron/service/paths.py +181 -0
  152. zikaron/service/rpc.py +176 -0
  153. zikaron/service/security.py +156 -0
  154. zikaron/service/serialize.py +204 -0
  155. zikaron/service/serialize_knowledge.py +238 -0
  156. zikaron/service/server.py +416 -0
  157. zikaron-0.1.0.dist-info/METADATA +770 -0
  158. zikaron-0.1.0.dist-info/RECORD +162 -0
  159. zikaron-0.1.0.dist-info/WHEEL +5 -0
  160. zikaron-0.1.0.dist-info/entry_points.txt +4 -0
  161. zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
  162. 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