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,500 @@
1
+ """`merge`, `promote` and `discard`: the three consolidator write verbs, each one transaction.
2
+
3
+ `architecture.md` §"Consolidator tool surface" and §"Row-level completion" are normative. Each verb
4
+ runs the ladder in `authorization`, then mutates, then dispositions the members it touched and
5
+ closes whatever that closure reaches. Nothing here re-implements a row rule: `records.memory`'s
6
+ `apply_*` mutators own the column writes, version bumps and receipts, and `indexing.writes` owns
7
+ the index.
8
+
9
+ **Completion is tracked per journal row, never per group.** A group with a mixed disposition —
10
+ merge A, promote B, discard C — takes three calls, and the group closes only when its last member is
11
+ dispositioned. That is what makes the never-lose guard structural rather than a protocol: a model
12
+ that returns nothing, stops halfway, crashes or lets its lease expire loses **no** rows, because the
13
+ undispositioned ones are still `tier='journal' AND active=1` and the next run plans them again. No
14
+ cooperation from the model is required for the guard to hold.
15
+
16
+ **Every response carries `remaining_uuids`**, in the success shape *and* in the conflict shape, so a
17
+ caller learns the same remaining state whether its call landed or raced. It is recomputed from the
18
+ member table at response time rather than tracked, which is what makes it shrink within one call.
19
+
20
+ **The chunking preflight runs before the transaction, and `promote` pays for one it may discard.**
21
+ The preflight is where `gist_max_tokens` is enforced — rung 1, before the store is touched — and
22
+ where the embedding happens, and holding SQLite's write lock across a model load is what the whole
23
+ outside-the-transaction rule exists to avoid. `promote`'s form, though, can only be decided from
24
+ authorized state *inside* the transaction, since it turns on whether the sole absorbed row's stored
25
+ prose is byte-identical to the arguments. So an in-place promotion embeds a vector it then does not
26
+ use: a few milliseconds on a manually-invoked, rare, unwaited call, in exchange for the form being
27
+ decided once, from state that has been version-checked. Deciding it from a pre-transaction read
28
+ would have been sound — a matching version implies unchanged prose — but it buys nothing and adds a
29
+ disagreement between two reads that would have to be handled somewhere.
30
+ """
31
+
32
+ from collections.abc import Sequence
33
+
34
+ import aiosqlite
35
+
36
+ from zikaron.core.consolidation import authorization, groups, runs
37
+ from zikaron.core.consolidation.authorization import Authorized
38
+ from zikaron.core.consolidation.context import ConsolidationCall
39
+ from zikaron.core.consolidation.groups import Disposition, GroupStatus
40
+ from zikaron.core.consolidation.payload import (
41
+ Discarded,
42
+ DiscardOutcome,
43
+ GroupConflict,
44
+ Merged,
45
+ MergeOutcome,
46
+ MergeTarget,
47
+ NamedRow,
48
+ NamedRows,
49
+ Promoted,
50
+ PromoteOutcome,
51
+ )
52
+ from zikaron.core.errors import ErrorCode, IndexStage, ZikaronError
53
+ from zikaron.core.events import (
54
+ AuthoredSize,
55
+ DiscardDetail,
56
+ GroupRef,
57
+ MergeDetail,
58
+ MergeRole,
59
+ PromoteDetail,
60
+ PromoteForm,
61
+ PromoteRole,
62
+ )
63
+ from zikaron.core.indexing import vectors, writes
64
+ from zikaron.core.indexing.chunking import ChunkPlan
65
+ from zikaron.core.indexing.writes import PreparedIndex
66
+ from zikaron.core.records import memory as records
67
+ from zikaron.core.records.memory import Memory, Rewrite, Tier, log_event
68
+ from zikaron.core.store import transactions
69
+
70
+
71
+ def _authored(plan: ChunkPlan) -> AuthoredSize:
72
+ """The four size fields, from the preflight that actually cut the chunks being written."""
73
+ return AuthoredSize(
74
+ token_count=plan.content_tokens,
75
+ gist_tokens=plan.gist_tokens,
76
+ n_chunks=plan.n_chunks,
77
+ truncated=plan.truncated,
78
+ )
79
+
80
+
81
+ async def _stored_size(
82
+ db: aiosqlite.Connection, *, row: Memory, call: ConsolidationCall
83
+ ) -> AuthoredSize:
84
+ """The four size fields read off what the store already holds, for a call that authored nothing
85
+ new.
86
+
87
+ `promote`'s in-place form only: it changes no prose, so it rebuilds no index, and a preflight
88
+ run under a `chunk_max_tokens` that has moved since the row was written would report an
89
+ `n_chunks` the store does not contain. `token_count` is the row's own column, `n_chunks` and
90
+ `truncated` come from its chunk rows, and `gist_tokens` is counted over the stored gist with
91
+ the deployed tokenizer — which is a fresh measurement of stored prose rather than a number
92
+ carried from elsewhere, because no column records it.
93
+ """
94
+ stored = await vectors.stored_chunks(db, memory_uuid=row.uuid)
95
+ return AuthoredSize(
96
+ token_count=row.token_count,
97
+ gist_tokens=call.index.encoder.count_tokens(row.gist),
98
+ n_chunks=stored.n_chunks,
99
+ truncated=stored.truncated,
100
+ )
101
+
102
+
103
+ async def _disposition_and_close(
104
+ db: aiosqlite.Connection,
105
+ *,
106
+ authorized: Authorized,
107
+ disposition: Disposition,
108
+ call: ConsolidationCall,
109
+ at: str,
110
+ ) -> tuple[tuple[str, ...], bool, int]:
111
+ """Disposition the absorbed members, close what that closes, and refresh the lease.
112
+
113
+ The whole tail every verb shares, and it is shared rather than repeated because invariant 16's
114
+ closure property is stated over *committed* state: a group whose members are all dispositioned
115
+ is never left open, so the disposition and the group's transition — and, if that was the run's
116
+ last open group, the run's — must land in one transaction. Three copies of that sequence would
117
+ be three chances for one verb to leave a group uncloseable.
118
+
119
+ The lease is refreshed because this call made progress. A `{conflict: true}` response never
120
+ reaches here, which is deliberate: it mutates nothing, so extending the lease would let a
121
+ consolidator that is achieving nothing hold the store indefinitely.
122
+
123
+ Returns:
124
+ `(remaining_uuids, group_complete, dispositioned)` — the remaining set in group order,
125
+ whether this call closed the group, and how many member rows actually moved, which is what
126
+ `discard` reports as `retired` rather than the length of its own argument.
127
+ """
128
+ moved = await groups.disposition_members(
129
+ db,
130
+ group_id=authorized.group.group_id,
131
+ memory_uuids=authorized.absorbed_uuids,
132
+ disposition=disposition,
133
+ at=at,
134
+ )
135
+ remaining = await groups.open_member_uuids(db, authorized.group.group_id)
136
+ complete = not remaining
137
+ if complete:
138
+ await groups.set_status(
139
+ db,
140
+ group_id=authorized.group.group_id,
141
+ status=GroupStatus.COMPLETE,
142
+ expected=authorized.group.status,
143
+ )
144
+ await runs.close_if_finished(db, run_id=authorized.run.run_id, ctx=call.ctx)
145
+ await runs.refresh_lease(
146
+ db,
147
+ run_id=authorized.run.run_id,
148
+ at=at,
149
+ lease_seconds=call.settings.run_lease_seconds,
150
+ )
151
+ return remaining, complete, moved
152
+
153
+
154
+ async def _retire_absorbed(
155
+ db: aiosqlite.Connection, *, row: Memory, superseded_by: str | None, call: ConsolidationCall
156
+ ) -> Memory:
157
+ """Retire one absorbed member, pointing it at the survivor where there is one.
158
+
159
+ `superseded_by` set writes D25's supersession edge, validated in this same transaction; `None`
160
+ is `discard`'s outright retirement, which names no replacement because there is none — the row
161
+ was judged not worth keeping, and D16 leaves its prose intact so that judgment is recoverable.
162
+ """
163
+ return await records.apply_retirement(
164
+ db, current=row, superseded_by=superseded_by, ctx=call.ctx
165
+ )
166
+
167
+
168
+ def _failure_map(verb: str) -> transactions.FailureMap:
169
+ """Contention is `store_busy`, everything else `index_failed` — a consolidator verb is a write.
170
+
171
+ Named per verb so `store_busy`'s payload says which call may be retried, and decided here rather
172
+ than shared because `architecture.md` §Errors makes it a property of the *kind* of call: a write
173
+ performs index maintenance and so has an `index_failed` to report, while a read does not.
174
+ """
175
+ return lambda error: (
176
+ ZikaronError(ErrorCode.STORE_BUSY, verb=verb)
177
+ if transactions.is_contention(error)
178
+ else ZikaronError(ErrorCode.INDEX_FAILED, stage=IndexStage.INDEX_WRITE)
179
+ )
180
+
181
+
182
+ async def merge(
183
+ db: aiosqlite.Connection,
184
+ *,
185
+ group_id: str,
186
+ target: MergeTarget,
187
+ absorb: Sequence[NamedRow],
188
+ call: ConsolidationCall,
189
+ ) -> MergeOutcome:
190
+ """Rewrite an existing long-term record to absorb part or all of a group.
191
+
192
+ The target must be in this group's persisted authorization set — its anchor or one of its
193
+ candidates — and must still be an active long-term record at mutation time. Absorbed rows are
194
+ retired with `superseded_by = target.uuid`, which demotes rather than hides them (D25), so
195
+ historical-intent queries still reach them and their original `session_id` still records who
196
+ learned the lesson.
197
+
198
+ Emits one `merge` event per row it mutated: the target, carrying the preflight's size fields,
199
+ then one per absorbed row with those four fields null, because only the target authored prose.
200
+
201
+ Returns:
202
+ `Merged` with the target's new version, or `GroupConflict` when a named row's version has
203
+ moved — carrying the current record for every conflicting uuid and the remaining member set.
204
+
205
+ Raises:
206
+ ZikaronError: `BOUNDS` if the gist is empty or over `gist_max_tokens`, or a uuid repeats in
207
+ `absorb`; then the ladder's own rejections — `GROUP_UNKNOWN`, `GROUP_EXPIRED`,
208
+ `GROUP_COMPLETE`, `GROUP_DEFERRED`, `NOT_IN_GROUP`, `BAD_MERGE_TARGET`,
209
+ `NO_READ_RECEIPT`; or `INDEX_FAILED` / `STORE_BUSY`, either of which leaves every row
210
+ as it was.
211
+ """
212
+ named = NamedRows(absorb=tuple(absorb), target=target.row)
213
+ prepared = await writes.prepare(target.rewrite, index=call.index)
214
+
215
+ async def work(connection: aiosqlite.Connection) -> MergeOutcome:
216
+ authorized = await authorization.authorize(
217
+ connection, group_id=group_id, named=named, call=call, verb="merge"
218
+ )
219
+ if isinstance(authorized, GroupConflict):
220
+ return authorized
221
+ return await _apply_merge(connection, authorized=authorized, prepared=prepared, call=call)
222
+
223
+ return await transactions.in_one_transaction(db, work, failure=_failure_map("merge"))
224
+
225
+
226
+ async def _apply_merge(
227
+ db: aiosqlite.Connection,
228
+ *,
229
+ authorized: Authorized,
230
+ prepared: PreparedIndex,
231
+ call: ConsolidationCall,
232
+ ) -> Merged:
233
+ """Rung 7 for `merge`: rewrite the target, retire the absorbed rows, log, disposition, close."""
234
+ at = runs.now()
235
+ before = authorized.target
236
+ if before is None:
237
+ # `Authorized.target` is optional because `promote` and `discard` name none; `merge` always
238
+ # does, and rung 2 refused the call if it was not authorized. A real `raise` rather than a
239
+ # bare `assert`, for the reason `write.tools` gives at the same kind of site: an assertion
240
+ # can be compiled away, and this exists to catch the ladder changing what it returns.
241
+ raise ValueError("merge reached rung 7 with no authorized target")
242
+ after = await records.apply_rewrite(db, current=before, rewrite=prepared.rewrite, ctx=call.ctx)
243
+ await writes.reindex_rewrite(
244
+ db, before=before, after=after, prepared=prepared, index=call.index
245
+ )
246
+ reference = GroupRef(group_id=authorized.group.group_id, run_id=authorized.run.run_id)
247
+ n_absorbed = len(authorized.absorbed)
248
+ await log_event(
249
+ db,
250
+ ctx=call.ctx,
251
+ detail=MergeDetail(
252
+ group=reference,
253
+ role=MergeRole.TARGET,
254
+ from_version=before.version,
255
+ to_version=after.version,
256
+ n_absorbed=n_absorbed,
257
+ size=_authored(prepared.plan),
258
+ ),
259
+ memory_uuid=after.uuid,
260
+ )
261
+ for row in authorized.absorbed:
262
+ retired = await _retire_absorbed(db, row=row, superseded_by=after.uuid, call=call)
263
+ await log_event(
264
+ db,
265
+ ctx=call.ctx,
266
+ detail=MergeDetail(
267
+ group=reference,
268
+ role=MergeRole.ABSORBED,
269
+ from_version=row.version,
270
+ to_version=retired.version,
271
+ n_absorbed=n_absorbed,
272
+ size=AuthoredSize.none_authored(),
273
+ ),
274
+ memory_uuid=row.uuid,
275
+ )
276
+ remaining, complete, _ = await _disposition_and_close(
277
+ db, authorized=authorized, disposition=Disposition.MERGED, call=call, at=at
278
+ )
279
+ return Merged(
280
+ uuid=after.uuid,
281
+ version=after.version,
282
+ remaining_uuids=remaining,
283
+ group_complete=complete,
284
+ )
285
+
286
+
287
+ async def promote(
288
+ db: aiosqlite.Connection,
289
+ *,
290
+ group_id: str,
291
+ rewrite: Rewrite,
292
+ absorb: Sequence[NamedRow],
293
+ call: ConsolidationCall,
294
+ ) -> PromoteOutcome:
295
+ """Create a long-term record from part or all of a group, or lift one member where it stands.
296
+
297
+ **Which form runs is determined by the payload, not guessed**: exactly one `absorb` row whose
298
+ stored gist and content are byte-identical to the arguments flips that row's tier in place, and
299
+ anything else inserts a new long-term record and retires every absorbed row with `superseded_by`
300
+ set to the new uuid. The version bumps in both forms — invariant 8 makes a tier flip a real
301
+ mutation, because a primary agent may be amending the row concurrently.
302
+
303
+ Event cardinality differs between the forms and is fixed rather than left to this
304
+ implementation. `new_row` emits one `created` event plus one `absorbed` event per absorbed row.
305
+ `in_place` emits **exactly one**, `flipped`, `n_absorbed: 1` — because the flipped row *is* the
306
+ absorbed member and a singular role cannot say both. One mutated row, one event.
307
+
308
+ Returns:
309
+ `Promoted` with the long-term record's uuid and version, or `GroupConflict`.
310
+
311
+ Raises:
312
+ ZikaronError: as `merge`, minus `BAD_MERGE_TARGET` — this verb names no target.
313
+ """
314
+ named = NamedRows(absorb=tuple(absorb))
315
+ prepared = await writes.prepare(rewrite, index=call.index)
316
+
317
+ async def work(connection: aiosqlite.Connection) -> PromoteOutcome:
318
+ authorized = await authorization.authorize(
319
+ connection, group_id=group_id, named=named, call=call, verb="promote"
320
+ )
321
+ if isinstance(authorized, GroupConflict):
322
+ return authorized
323
+ if _is_in_place(authorized, rewrite=rewrite):
324
+ return await _apply_in_place_promote(connection, authorized=authorized, call=call)
325
+ return await _apply_new_row_promote(
326
+ connection, authorized=authorized, prepared=prepared, call=call
327
+ )
328
+
329
+ return await transactions.in_one_transaction(db, work, failure=_failure_map("promote"))
330
+
331
+
332
+ def _is_in_place(authorized: Authorized, *, rewrite: Rewrite) -> bool:
333
+ """Whether this promotion lifts its sole member in place rather than authoring a new record.
334
+
335
+ Byte-identical prose is the whole test, and it is evaluated against the row the ladder loaded
336
+ and version-checked — so "identical to what is stored" means identical to the version the caller
337
+ presented, not to whatever the row held when the payload was assembled.
338
+ """
339
+ if len(authorized.absorbed) != 1:
340
+ return False
341
+ only = authorized.absorbed[0]
342
+ return only.gist == rewrite.gist and only.content == rewrite.content
343
+
344
+
345
+ async def _apply_in_place_promote(
346
+ db: aiosqlite.Connection, *, authorized: Authorized, call: ConsolidationCall
347
+ ) -> Promoted:
348
+ """Rung 7 for `promote`'s in-place form: flip the tier, log one event, close.
349
+
350
+ No index work at all: the prose is byte-identical to what is stored, `memory_fts` indexes `gist`
351
+ and `content` only, and `tier` appears in neither index — so there is nothing to rebuild, and
352
+ rebuilding it anyway would rewrite chunks under a possibly-changed `chunk_max_tokens` for no
353
+ gain.
354
+ """
355
+ at = runs.now()
356
+ before = authorized.absorbed[0]
357
+ after = await records.apply_tier(db, current=before, tier=Tier.LONG_TERM, ctx=call.ctx)
358
+ await log_event(
359
+ db,
360
+ ctx=call.ctx,
361
+ detail=PromoteDetail(
362
+ group=GroupRef(group_id=authorized.group.group_id, run_id=authorized.run.run_id),
363
+ role=PromoteRole.FLIPPED,
364
+ form=PromoteForm.IN_PLACE,
365
+ from_version=before.version,
366
+ to_version=after.version,
367
+ n_absorbed=1,
368
+ size=await _stored_size(db, row=after, call=call),
369
+ ),
370
+ memory_uuid=after.uuid,
371
+ )
372
+ remaining, complete, _ = await _disposition_and_close(
373
+ db, authorized=authorized, disposition=Disposition.PROMOTED, call=call, at=at
374
+ )
375
+ return Promoted(
376
+ uuid=after.uuid,
377
+ version=after.version,
378
+ remaining_uuids=remaining,
379
+ group_complete=complete,
380
+ )
381
+
382
+
383
+ async def _apply_new_row_promote(
384
+ db: aiosqlite.Connection,
385
+ *,
386
+ authorized: Authorized,
387
+ prepared: PreparedIndex,
388
+ call: ConsolidationCall,
389
+ ) -> Promoted:
390
+ """Rung 7 for `promote`'s new-row form: insert the record long-term, retire what it absorbed."""
391
+ at = runs.now()
392
+ created = await writes.insert_row_and_indexes(
393
+ db, prepared=prepared, call=call.indexed, tier=Tier.LONG_TERM
394
+ )
395
+ reference = GroupRef(group_id=authorized.group.group_id, run_id=authorized.run.run_id)
396
+ n_absorbed = len(authorized.absorbed)
397
+ await log_event(
398
+ db,
399
+ ctx=call.ctx,
400
+ detail=PromoteDetail(
401
+ group=reference,
402
+ role=PromoteRole.CREATED,
403
+ form=PromoteForm.NEW_ROW,
404
+ from_version=created.version,
405
+ to_version=created.version,
406
+ n_absorbed=n_absorbed,
407
+ size=_authored(prepared.plan),
408
+ ),
409
+ memory_uuid=created.uuid,
410
+ )
411
+ for row in authorized.absorbed:
412
+ retired = await _retire_absorbed(db, row=row, superseded_by=created.uuid, call=call)
413
+ await log_event(
414
+ db,
415
+ ctx=call.ctx,
416
+ detail=PromoteDetail(
417
+ group=reference,
418
+ role=PromoteRole.ABSORBED,
419
+ form=PromoteForm.NEW_ROW,
420
+ from_version=row.version,
421
+ to_version=retired.version,
422
+ n_absorbed=n_absorbed,
423
+ size=AuthoredSize.none_authored(),
424
+ ),
425
+ memory_uuid=row.uuid,
426
+ )
427
+ remaining, complete, _ = await _disposition_and_close(
428
+ db, authorized=authorized, disposition=Disposition.PROMOTED, call=call, at=at
429
+ )
430
+ return Promoted(
431
+ uuid=created.uuid,
432
+ version=created.version,
433
+ remaining_uuids=remaining,
434
+ group_complete=complete,
435
+ )
436
+
437
+
438
+ async def discard(
439
+ db: aiosqlite.Connection,
440
+ *,
441
+ group_id: str,
442
+ absorb: Sequence[NamedRow],
443
+ reason: str,
444
+ call: ConsolidationCall,
445
+ ) -> DiscardOutcome:
446
+ """Retire journal rows judged not worth keeping: `active=0`, `superseded_by NULL` (D16).
447
+
448
+ `reason` is recorded in the **event log and nowhere else**, which is deliberate rather than an
449
+ omission: D16 leaves the row's own prose untouched, so "we decided this was noise" stays
450
+ recoverable if that judgment was wrong, without editing the row to say so.
451
+
452
+ Runs no chunking preflight and touches no index: a discard writes `active`, `version` and
453
+ `updated_at`, none of which is an indexed column, and D16 leaves the chunks exactly where they
454
+ are so a discarded row stays recoverable.
455
+
456
+ Returns:
457
+ `Discarded` with how many member rows actually moved, or `GroupConflict`.
458
+
459
+ Raises:
460
+ ZikaronError: the ladder's own rejections, or `STORE_BUSY` / `INDEX_FAILED`. No `BOUNDS`
461
+ from a gist, since this verb authors no prose — only from a repeated `absorb` uuid.
462
+ """
463
+ named = NamedRows(absorb=tuple(absorb))
464
+
465
+ async def work(connection: aiosqlite.Connection) -> DiscardOutcome:
466
+ authorized = await authorization.authorize(
467
+ connection, group_id=group_id, named=named, call=call, verb="discard"
468
+ )
469
+ if isinstance(authorized, GroupConflict):
470
+ return authorized
471
+ return await _apply_discard(connection, authorized=authorized, reason=reason, call=call)
472
+
473
+ return await transactions.in_one_transaction(db, work, failure=_failure_map("discard"))
474
+
475
+
476
+ async def _apply_discard(
477
+ db: aiosqlite.Connection, *, authorized: Authorized, reason: str, call: ConsolidationCall
478
+ ) -> Discarded:
479
+ """Rung 7 for `discard`: retire each named member outright and record why."""
480
+ at = runs.now()
481
+ reference = GroupRef(group_id=authorized.group.group_id, run_id=authorized.run.run_id)
482
+ n_absorbed = len(authorized.absorbed)
483
+ for row in authorized.absorbed:
484
+ retired = await _retire_absorbed(db, row=row, superseded_by=None, call=call)
485
+ await log_event(
486
+ db,
487
+ ctx=call.ctx,
488
+ detail=DiscardDetail(
489
+ group=reference,
490
+ reason=reason,
491
+ from_version=row.version,
492
+ to_version=retired.version,
493
+ n_absorbed=n_absorbed,
494
+ ),
495
+ memory_uuid=row.uuid,
496
+ )
497
+ remaining, complete, moved = await _disposition_and_close(
498
+ db, authorized=authorized, disposition=Disposition.DISCARDED, call=call, at=at
499
+ )
500
+ return Discarded(retired=moved, remaining_uuids=remaining, group_complete=complete)