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,490 @@
1
+ """The `consolidation_group` aggregate and its two child tables, plus every state transition.
2
+
3
+ `schema.md` invariants 16, 17 and 19 are normative, and so is `architecture.md` §"Serving". Every
4
+ statement that reads or writes `consolidation_group`, `consolidation_group_member` or
5
+ `consolidation_group_candidate` is here, so a schema change reaches one module.
6
+
7
+ **Group order is `(created_at, uuid)` over the members**, named once in `consolidation.md` step 3
8
+ and implemented once here: it is the order members are delivered in, the order `remaining_uuids` is
9
+ reported in, the order the gist concatenation concatenates in, and the order shards are cut on.
10
+ `created_at` alone is not total — concurrent writes share the string — so the uuid is part of the
11
+ order rather than a tiebreak applied wherever a collision was noticed.
12
+
13
+ **Every status transition is a guarded update returning whether it fired.** That is what makes the
14
+ transitions idempotent under a retry and what lets two paths reach the same conclusion in one
15
+ transaction without writing it twice: invariant 16 gives `→ complete` **two** producers — the write
16
+ verb that dispositions the last member, and the serve transaction when serve-time vacating
17
+ dispositions it — so "did I actually close this" has to be an answer rather than an assumption.
18
+
19
+ **Frozen membership and the served set are different things.** `consolidation_group_member` is the
20
+ plan-time universe and stays that way; the served set is the payload view of it — the members with
21
+ `disposition IS NULL` after serve-time re-validation — and is never a second source of truth.
22
+ """
23
+
24
+ from collections.abc import Mapping, Sequence
25
+ from dataclasses import dataclass
26
+ from enum import StrEnum
27
+ from typing import Final
28
+ from uuid import uuid4
29
+
30
+ import aiosqlite
31
+
32
+ #: Which statuses mean "this group is still open", i.e. the two invariant 16's closure property is
33
+ #: stated over and the two `remaining_groups` counts. `deferred` is terminal for the run and
34
+ #: `complete` is terminal outright, so neither is open.
35
+ OPEN_STATUSES: Final = ("pending", "served")
36
+
37
+ _GROUP_COLUMNS: Final = (
38
+ "group_id, run_id, anchor_uuid, order_key, shard_index, shard_count, status, "
39
+ "serve_count, served_at"
40
+ )
41
+
42
+ #: The join and ordering that *is* group order. One constant rather than an `ORDER BY` per
43
+ #: statement, because five things are defined over this order and a statement ordering by
44
+ #: `created_at` alone would agree with the rest on every fixture with distinct timestamps.
45
+ _MEMBER_ORDER: Final = "ORDER BY m.created_at ASC, mem.memory_uuid ASC"
46
+ _MEMBER_JOIN: Final = (
47
+ "FROM consolidation_group_member mem "
48
+ "JOIN memory m ON m.uuid = mem.memory_uuid "
49
+ "WHERE mem.group_id = ?"
50
+ )
51
+
52
+
53
+ class GroupStatus(StrEnum):
54
+ """`consolidation_group.status`'s four values, exactly as `schema.md`'s `CHECK` states them."""
55
+
56
+ PENDING = "pending"
57
+ SERVED = "served"
58
+ COMPLETE = "complete"
59
+ DEFERRED = "deferred"
60
+
61
+
62
+ class Disposition(StrEnum):
63
+ """What became of one member.
64
+
65
+ `VACATED` is the one that is not a consolidator decision: the row left `tier='journal' AND
66
+ active=1` by another path between plan and serve — a primary agent retired it, or promoted it —
67
+ so it is not delivered, nothing was lost, and it blocks no completion. It **counts as
68
+ dispositioned**, which is what lets vacating the last open member close the group inside the
69
+ serve transaction rather than leaving it waiting for a write verb with nothing left to write.
70
+ """
71
+
72
+ MERGED = "merged"
73
+ PROMOTED = "promoted"
74
+ DISCARDED = "discarded"
75
+ VACATED = "vacated"
76
+
77
+
78
+ class CandidateRole(StrEnum):
79
+ """`consolidation_group_candidate.role`: which of the two kinds of long-term record this is."""
80
+
81
+ ANCHOR = "anchor"
82
+ CANDIDATE = "candidate"
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class Shard:
87
+ """One group's position among the shards of its pre-shard subgroup, both halves persisted.
88
+
89
+ **1-based**, so an unsharded group is `{index: 1, of: 1}` rather than a third convention meaning
90
+ "not sharded". `of` cannot be recomputed at serve time — that would mean replanning a subgroup
91
+ whose membership is frozen, against a store that has since moved because earlier groups mutate
92
+ it — so it is stored and returned verbatim.
93
+
94
+ Raises:
95
+ ValueError: `of < 1` or `index` outside `1…of`, the same conditions the table's own per-row
96
+ `CHECK`s state. Held here as well so a malformed shard cannot be constructed at all,
97
+ rather than only failing at the `INSERT`: the planner computes these, and a planner that
98
+ can build an impossible one can also build a set that violates invariant 19.
99
+ """
100
+
101
+ index: int
102
+ of: int
103
+
104
+ def __post_init__(self) -> None:
105
+ if self.of < 1:
106
+ raise ValueError(f"shard_count must be >= 1, got {self.of}")
107
+ if not 1 <= self.index <= self.of:
108
+ raise ValueError(f"shard_index {self.index} outside 1-{self.of}")
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class Group:
113
+ """One `consolidation_group` row, exactly as stored."""
114
+
115
+ group_id: str
116
+ run_id: str
117
+ anchor_uuid: str | None
118
+ order_key: str
119
+ shard: Shard
120
+ status: GroupStatus
121
+ serve_count: int
122
+ served_at: str | None
123
+
124
+
125
+ @dataclass(frozen=True, slots=True)
126
+ class Member:
127
+ """One `consolidation_group_member` row, exactly as stored, plus its ordering facts.
128
+
129
+ `created_at` is the member's own `memory.created_at` rather than a column of this table: group
130
+ order is defined over it, and carrying it here is what lets the order be established by one
131
+ statement instead of by a second lookup per member.
132
+
133
+ `version_seen` is the plan-time version and is **change detection only**; `version_served` is
134
+ the version whose prose was actually delivered on the most recent serve that delivered this row,
135
+ and is the `expected_version` the payload carried. `version_served IS NULL` iff the row has
136
+ never been delivered — which is what distinguishes a member that vacated on the serve that would
137
+ have delivered it from one delivered earlier and vacated later.
138
+ """
139
+
140
+ memory_uuid: str
141
+ created_at: str
142
+ version_seen: int
143
+ version_served: int | None
144
+ disposition: Disposition | None
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class AuthorizedRecord:
149
+ """One row of `consolidation_group_candidate`: a long-term record a `merge` may target.
150
+
151
+ This table, not the `group_served` events, is the authority — the events are instrumentation. It
152
+ is rewritten on every serve, in the serve's own transaction, so authorization survives a service
153
+ restart without recomputation.
154
+ """
155
+
156
+ memory_uuid: str
157
+ role: CandidateRole
158
+ version_served: int
159
+ rank: int
160
+
161
+
162
+ def new_group_id() -> str:
163
+ """A fresh `group_id`. The one handle this package exposes, and only to the consolidator."""
164
+ return str(uuid4())
165
+
166
+
167
+ def _to_group(row: Sequence[object]) -> Group:
168
+ (
169
+ group_id,
170
+ run_id,
171
+ anchor_uuid,
172
+ order_key,
173
+ shard_index,
174
+ shard_count,
175
+ status,
176
+ serve_count,
177
+ served_at,
178
+ ) = row
179
+ return Group(
180
+ group_id=str(group_id),
181
+ run_id=str(run_id),
182
+ anchor_uuid=None if anchor_uuid is None else str(anchor_uuid),
183
+ order_key=str(order_key),
184
+ shard=Shard(index=int(str(shard_index)), of=int(str(shard_count))),
185
+ status=GroupStatus(str(status)),
186
+ serve_count=int(str(serve_count)),
187
+ served_at=None if served_at is None else str(served_at),
188
+ )
189
+
190
+
191
+ async def load(db: aiosqlite.Connection, group_id: str) -> Group | None:
192
+ """One group by id, or `None` if the store holds no such row."""
193
+ rows = await db.execute_fetchall(
194
+ f"SELECT {_GROUP_COLUMNS} FROM consolidation_group WHERE group_id = ?", # noqa: S608 — a source-level column list; the id is bound.
195
+ (group_id,),
196
+ )
197
+ found = list(rows)
198
+ return None if not found else _to_group(tuple(found[0]))
199
+
200
+
201
+ async def next_candidate(db: aiosqlite.Connection, run_id: str) -> Group | None:
202
+ """The next group of `run_id` a serve should consider, in the order `architecture.md` fixes.
203
+
204
+ The earliest `served`-but-incomplete group by `(order_key, shard_index)` first, and only then
205
+ the earliest `pending` one. Re-serving before advancing is the point: a consolidator must not
206
+ be able to walk past a group it found hard simply by calling again.
207
+
208
+ **The two-step ordering is provably equivalent to plain `order_key` ordering on today's state
209
+ machine, and is written as the design states it anyway.** Serving always takes the earliest
210
+ remaining group, and every transition out of the candidate set (`→ complete`, `→ deferred`)
211
+ removes a group rather than returning it to `pending` — so among the groups that are still
212
+ `pending` or `served`, the served ones are always an order-prefix, and the `CASE` cannot change
213
+ which row comes back. That equivalence rests on a reachability argument about the whole state
214
+ machine rather than on anything local, so it is the wrong thing to encode: a future transition
215
+ that reopened a group would silently make skipping possible. Disclosed because it means a
216
+ mutation removing the `CASE` survives the suite, and no fixture can honestly make it fail.
217
+
218
+ `order_key` is `'earliest_created_at|min_uuid'` of the pre-shard subgroup, so ordering by it is
219
+ ordering by D29's step-3 total order's first two components, and `shard_index` is the third —
220
+ which is the only reason a third component is needed at all, since every shard of one subgroup
221
+ carries the same key.
222
+ """
223
+ rows = await db.execute_fetchall(
224
+ f"SELECT {_GROUP_COLUMNS} FROM consolidation_group " # noqa: S608 — a source-level column list; the id is bound.
225
+ "WHERE run_id = ? AND status IN ('served', 'pending') "
226
+ "ORDER BY CASE status WHEN 'served' THEN 0 ELSE 1 END, order_key ASC, shard_index ASC "
227
+ "LIMIT 1",
228
+ (run_id,),
229
+ )
230
+ found = list(rows)
231
+ return None if not found else _to_group(tuple(found[0]))
232
+
233
+
234
+ async def members(db: aiosqlite.Connection, group_id: str) -> tuple[Member, ...]:
235
+ """Every member of one group — the frozen plan-time universe — in group order."""
236
+ rows = await db.execute_fetchall(
237
+ # Every fragment is a source-level constant; the group id is the one bound value.
238
+ "SELECT mem.memory_uuid, m.created_at, mem.version_seen, mem.version_served, "
239
+ f"mem.disposition {_MEMBER_JOIN} {_MEMBER_ORDER}",
240
+ (group_id,),
241
+ )
242
+ return tuple(
243
+ Member(
244
+ memory_uuid=str(memory_uuid),
245
+ created_at=str(created_at),
246
+ version_seen=int(str(version_seen)),
247
+ version_served=None if version_served is None else int(str(version_served)),
248
+ disposition=None if disposition is None else Disposition(str(disposition)),
249
+ )
250
+ for memory_uuid, created_at, version_seen, version_served, disposition in rows
251
+ )
252
+
253
+
254
+ async def open_member_uuids(db: aiosqlite.Connection, group_id: str) -> tuple[str, ...]:
255
+ """`remaining_uuids`: this group's members with `disposition IS NULL`, in group order.
256
+
257
+ Read from the member table at response time rather than tracked in memory, because that is
258
+ what the design says it is — and because a write verb dispositions rows inside its own
259
+ transaction, so the value must shrink within one call rather than describe its starting state.
260
+ """
261
+ rows = await db.execute_fetchall(
262
+ # Every fragment is a source-level constant; the group id is the one bound value.
263
+ f"SELECT mem.memory_uuid {_MEMBER_JOIN} AND mem.disposition IS NULL {_MEMBER_ORDER}",
264
+ (group_id,),
265
+ )
266
+ return tuple(str(memory_uuid) for (memory_uuid,) in rows)
267
+
268
+
269
+ async def open_group_count(db: aiosqlite.Connection, run_id: str, *, excluding: str) -> int:
270
+ """`remaining_groups`: how many **other** groups of this run are still `pending` or `served`.
271
+
272
+ Excludes the group named, because the payload's own contract counts work *not* in the
273
+ consolidator's hands — the delivered group is still open and still has undispositioned members,
274
+ so counting it would make a final group report 1 and leave "is this the last one" unanswerable
275
+ from the payload.
276
+ """
277
+ rows = await db.execute_fetchall(
278
+ "SELECT count(*) FROM consolidation_group "
279
+ "WHERE run_id = ? AND group_id <> ? AND status IN ('pending', 'served')",
280
+ (run_id, excluding),
281
+ )
282
+ return int(str(next(iter(rows))[0]))
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class RunCounts:
287
+ """The three counts a `consolidate_run` event reports about one run.
288
+
289
+ A value type rather than three integers threaded through, because the event's own contract says
290
+ they describe **the run the transition is about** — and `plan_groups` emits two events about two
291
+ different runs in one call, which is exactly the situation in which three loose integers get
292
+ crossed. It lives with the group aggregate rather than with the run because all three are
293
+ counted over the two group tables, and this module is the only place their SQL may live.
294
+ """
295
+
296
+ n_groups: int
297
+ n_members: int
298
+ n_deferred: int
299
+
300
+
301
+ async def run_counts(db: aiosqlite.Connection, run_id: str) -> RunCounts:
302
+ """One run's group and member totals, and how many of its groups are currently `deferred`.
303
+
304
+ `n_groups` and `n_members` are fixed at plan time and so repeat unchanged on every later phase
305
+ of one run; `n_deferred` is the only one that moves. Read in one statement so all three
306
+ describe the same instant, which matters because they are reported together on one event row.
307
+ """
308
+ rows = await db.execute_fetchall(
309
+ """
310
+ SELECT
311
+ (SELECT count(*) FROM consolidation_group WHERE run_id = ?),
312
+ (SELECT count(*) FROM consolidation_group_member m
313
+ JOIN consolidation_group g ON g.group_id = m.group_id
314
+ WHERE g.run_id = ?),
315
+ (SELECT count(*) FROM consolidation_group WHERE run_id = ? AND status = 'deferred')
316
+ """,
317
+ (run_id, run_id, run_id),
318
+ )
319
+ n_groups, n_members, n_deferred = next(iter(rows))
320
+ return RunCounts(
321
+ n_groups=int(str(n_groups)),
322
+ n_members=int(str(n_members)),
323
+ n_deferred=int(str(n_deferred)),
324
+ )
325
+
326
+
327
+ async def has_open_groups(db: aiosqlite.Connection, run_id: str) -> bool:
328
+ """Whether any group of this run is still `pending` or `served`.
329
+
330
+ The condition invariant 17 makes the run's `active → complete` cause: "whichever transaction
331
+ first observes that no group of the run is `pending` or `served`". `deferred` groups do not
332
+ count — they are terminal for the run, and their members simply return to the next plan.
333
+ """
334
+ rows = await db.execute_fetchall(
335
+ "SELECT EXISTS (SELECT 1 FROM consolidation_group "
336
+ "WHERE run_id = ? AND status IN ('pending', 'served'))",
337
+ (run_id,),
338
+ )
339
+ return bool(next(iter(rows))[0])
340
+
341
+
342
+ async def insert(db: aiosqlite.Connection, group: Group) -> None:
343
+ """Write one planned group. `status` and `serve_count` come from the value, not from a default,
344
+ so a planner that tried to insert an already-served group would be visible here."""
345
+ await db.execute(
346
+ "INSERT INTO consolidation_group "
347
+ "(group_id, run_id, anchor_uuid, order_key, shard_index, shard_count, status, "
348
+ " serve_count, served_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
349
+ (
350
+ group.group_id,
351
+ group.run_id,
352
+ group.anchor_uuid,
353
+ group.order_key,
354
+ group.shard.index,
355
+ group.shard.of,
356
+ group.status.value,
357
+ group.serve_count,
358
+ group.served_at,
359
+ ),
360
+ )
361
+
362
+
363
+ async def insert_member(
364
+ db: aiosqlite.Connection, *, group_id: str, memory_uuid: str, version_seen: int
365
+ ) -> None:
366
+ """Write one planned member: undispositioned, never delivered, at its plan-time version."""
367
+ await db.execute(
368
+ "INSERT INTO consolidation_group_member "
369
+ "(group_id, memory_uuid, version_seen, version_served, disposition, disposed_at) "
370
+ "VALUES (?, ?, ?, NULL, NULL, NULL)",
371
+ (group_id, memory_uuid, version_seen),
372
+ )
373
+
374
+
375
+ async def set_status(
376
+ db: aiosqlite.Connection, *, group_id: str, status: GroupStatus, expected: GroupStatus
377
+ ) -> bool:
378
+ """Move one group from `expected` to `status`, and report whether it moved.
379
+
380
+ Guarded on the *source* status rather than only on the id, because every transition in
381
+ invariant 17's table names one, and a guard on the id alone would let a group another path had
382
+ already closed be reopened as `deferred` — which is exactly the "no reachable state left
383
+ without an exit" property the table is asserting.
384
+ """
385
+ cursor = await db.execute(
386
+ "UPDATE consolidation_group SET status = ? WHERE group_id = ? AND status = ?",
387
+ (status.value, group_id, expected.value),
388
+ )
389
+ return cursor.rowcount > 0
390
+
391
+
392
+ async def record_delivery(
393
+ db: aiosqlite.Connection, *, group_id: str, serve_count: int, served_at: str
394
+ ) -> None:
395
+ """Set this group to `served` at the given delivery count and timestamp.
396
+
397
+ One statement for both transitions invariant 17 gives a delivery — `pending → served` setting
398
+ `serve_count = 1`, and `served → served` incrementing it — because the counter is what
399
+ distinguishes them and the caller has already computed it. `served_at` is refreshed on **every**
400
+ delivery, so it records the most recent one rather than the first.
401
+ """
402
+ await db.execute(
403
+ "UPDATE consolidation_group SET status = ?, serve_count = ?, served_at = ? "
404
+ "WHERE group_id = ?",
405
+ (GroupStatus.SERVED.value, serve_count, served_at, group_id),
406
+ )
407
+
408
+
409
+ async def disposition_members(
410
+ db: aiosqlite.Connection,
411
+ *,
412
+ group_id: str,
413
+ memory_uuids: Sequence[str],
414
+ disposition: Disposition,
415
+ at: str,
416
+ ) -> int:
417
+ """Mark the named members with `disposition`, and report how many rows actually moved.
418
+
419
+ Guarded on `disposition IS NULL`, so a member some other path already dispositioned cannot be
420
+ overwritten — a `vacated` row must not silently become `merged` — and the returned count is what
421
+ a caller checks that against. `discard`'s `{retired: n}` is this number.
422
+
423
+ **The guard is unobservable through any call, and that is disclosed rather than tested.** Rung 2
424
+ already refuses an `absorb` uuid that is not an *undispositioned* member, and the serve computes
425
+ the vacating set from the same predicate, so no reachable caller reaches this statement with an
426
+ already-dispositioned uuid. It stays because it is one clause and because the rule it enforces
427
+ is the design's, not this function's: an implementation that later relaxed rung 2 would find the
428
+ disposition still protected. Removing it changes no test's outcome, which is exactly the claim
429
+ being made here rather than one a test could make.
430
+ """
431
+ if not memory_uuids:
432
+ return 0
433
+ placeholders = ", ".join("?" for _ in memory_uuids)
434
+ sql = (
435
+ "UPDATE consolidation_group_member SET disposition = ?, disposed_at = ? " # noqa: S608 — placeholders only; every uuid is a bound parameter.
436
+ f"WHERE group_id = ? AND memory_uuid IN ({placeholders}) AND disposition IS NULL"
437
+ )
438
+ cursor = await db.execute(sql, (disposition.value, at, group_id, *memory_uuids))
439
+ return cursor.rowcount
440
+
441
+
442
+ async def record_versions_served(
443
+ db: aiosqlite.Connection, *, group_id: str, versions: Mapping[str, int]
444
+ ) -> None:
445
+ """Record, per member, the version whose prose this serve is delivering.
446
+
447
+ Written for exactly the served set, because that is the set whose prose the payload carries and
448
+ whose receipts are minted at these versions. A member the same serve marked `vacated` delivered
449
+ nothing, so its `version_served` is deliberately left as it was — null if it had never been
450
+ delivered, and its earlier value if it had.
451
+ """
452
+ for memory_uuid, version in versions.items():
453
+ await db.execute(
454
+ "UPDATE consolidation_group_member SET version_served = ? "
455
+ "WHERE group_id = ? AND memory_uuid = ?",
456
+ (version, group_id, memory_uuid),
457
+ )
458
+
459
+
460
+ async def replace_authorization(
461
+ db: aiosqlite.Connection, *, group_id: str, records: Sequence[AuthorizedRecord]
462
+ ) -> None:
463
+ """Rewrite this group's merge authorization set, replacing any previous serve's rows.
464
+
465
+ Deleted and reinserted rather than merged, because the set is recomputed from scratch on every
466
+ serve against a store that has moved: a record that no longer ranks, or an anchor that stopped
467
+ being targetable, must stop being authorized. Leaving a stale row behind would authorize a merge
468
+ into a record this serve did not show.
469
+ """
470
+ await db.execute("DELETE FROM consolidation_group_candidate WHERE group_id = ?", (group_id,))
471
+ for record in records:
472
+ await db.execute(
473
+ "INSERT INTO consolidation_group_candidate "
474
+ "(group_id, memory_uuid, role, version_served, rank) VALUES (?, ?, ?, ?, ?)",
475
+ (group_id, record.memory_uuid, record.role.value, record.version_served, record.rank),
476
+ )
477
+
478
+
479
+ async def authorized_uuids(db: aiosqlite.Connection, group_id: str) -> frozenset[str]:
480
+ """Every uuid this group's most recent serve authorized a `merge` to target.
481
+
482
+ A set rather than the rows, because that is the whole question rung 2 asks — and the answer must
483
+ carry nothing else: `bad_merge_target`'s payload names a uuid and a reason that does not
484
+ distinguish "does not exist" from "not authorized", so the check cannot become an existence
485
+ oracle for the store.
486
+ """
487
+ rows = await db.execute_fetchall(
488
+ "SELECT memory_uuid FROM consolidation_group_candidate WHERE group_id = ?", (group_id,)
489
+ )
490
+ return frozenset(str(memory_uuid) for (memory_uuid,) in rows)