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,161 @@
1
+ """Read receipts: what makes D26's read-before-write mechanical rather than trusted.
2
+
3
+ `schema.md` invariant 9 is normative. A receipt is keyed on `(session_id, client_kind,
4
+ memory_uuid, version)` — never on the process alone, because a subagent's MCP client and its
5
+ parent's hook share one `session_id`, so scoping to the session alone would let a consolidator's
6
+ serve license a primary agent's amend it never itself fetched. Minting is an idempotent upsert,
7
+ because a re-served consolidation group delivers the same rows at the same versions to the same
8
+ session and a plain insert would violate the primary key. Spending never deletes a receipt — the
9
+ version bump that revokes every *other* receipt for a row (invariant 9's own revocation rule) is
10
+ `memory.py`'s job, since it touches every receipt for a uuid rather than one row's read of it.
11
+ """
12
+
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+ from enum import StrEnum
16
+ from typing import Final
17
+
18
+ import aiosqlite
19
+
20
+ from zikaron.core.errors import ErrorCode, ZikaronError
21
+
22
+
23
+ class ReceiptSource(StrEnum):
24
+ """The four sources `read_receipt.source` may name, exactly as `schema.md`'s `CHECK` states
25
+ them: `fetch`, `next_group`'s serve, a rejected write's conflict payload, or the caller's own
26
+ successful write.
27
+ """
28
+
29
+ FETCH = "fetch"
30
+ GROUP = "group"
31
+ CONFLICT = "conflict"
32
+ OWN_WRITE = "own_write"
33
+
34
+
35
+ #: Every legal value of `read_receipt.source`, in `schema.md`'s own DDL order — checked against
36
+ #: the table's `CHECK` constraint by `tests/test_ddl.py`'s drift guard, not restated here.
37
+ RECEIPT_SOURCES: Final = tuple(ReceiptSource)
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ReadReceipt:
42
+ """One row of `read_receipt`: a licence to write `memory_uuid` at exactly `version`.
43
+
44
+ Constructing one does not mint it — `mint` does that, against the store. This type is the
45
+ typed shape a caller reads a minted receipt back as, per `coding-standards.md` §2's "types,
46
+ not dicts" rule.
47
+ """
48
+
49
+ session_id: str
50
+ client_kind: str
51
+ memory_uuid: str
52
+ version: int
53
+ at: str
54
+ source: ReceiptSource
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class ReceiptKey:
59
+ """`read_receipt`'s primary key, exactly: `(session_id, client_kind, memory_uuid, version)`.
60
+
61
+ A separate type from `ReadReceipt` rather than reusing it with `at`/`source` ignored: every
62
+ function in this module that looks a receipt up needs only the key, never the row's other two
63
+ columns, and a caller holding a full `ReadReceipt` when it means only the key would invite
64
+ reading `at`/`source` off a value that was never minted. Bundling the four key columns is also
65
+ what keeps `mint`/`spend`/`revoke_on_version_bump` under `PLR0913`'s argument-count style
66
+ bound — the four genuinely form one unit, since they are exactly the table's own key.
67
+ """
68
+
69
+ session_id: str
70
+ client_kind: str
71
+ memory_uuid: str
72
+ version: int
73
+
74
+
75
+ async def mint(
76
+ db: aiosqlite.Connection, *, key: ReceiptKey, at: str, source: ReceiptSource
77
+ ) -> None:
78
+ """Upsert one `read_receipt` row, refreshing `at` and `source` on a repeat mint.
79
+
80
+ The upsert is invariant 9's own requirement, not a convenience: a group re-served to the same
81
+ session at the same version must not violate `read_receipt`'s primary key, and the design
82
+ states the refresh explicitly ("refreshes `at` and `source`") rather than leaving a repeat
83
+ mint a no-op.
84
+
85
+ Callers pass an already-open connection and do not commit here: minting is one statement
86
+ inside whichever caller's transaction — `fetch`, a conflict, or a successful write — mints
87
+ it, per invariant 10's "the event commits with its mutation" rule extended to receipts.
88
+ """
89
+ await db.execute(
90
+ """
91
+ INSERT INTO read_receipt (session_id, client_kind, memory_uuid, version, at, source)
92
+ VALUES (?, ?, ?, ?, ?, ?)
93
+ ON CONFLICT (session_id, client_kind, memory_uuid, version)
94
+ DO UPDATE SET at = excluded.at, source = excluded.source
95
+ """,
96
+ (key.session_id, key.client_kind, key.memory_uuid, key.version, at, source.value),
97
+ )
98
+
99
+
100
+ async def spend(db: aiosqlite.Connection, *, key: ReceiptKey) -> bool:
101
+ """Whether a matching receipt exists, without consuming it.
102
+
103
+ Named `spend` rather than `check` because the caller's own transaction is what makes holding
104
+ the receipt meaningful here: by the time this returns, the same transaction is about to bump
105
+ `version`, which revokes every receipt for this uuid **except** the one the write mints for
106
+ itself (invariant 9) — so the receipt this call found is, in effect, used up by the write that
107
+ follows it, even though this function deletes nothing. A receipt is never deleted by *reading*
108
+ it; only a version bump prunes the table.
109
+ """
110
+ rows = await db.execute_fetchall(
111
+ """
112
+ SELECT 1 FROM read_receipt
113
+ WHERE session_id = ? AND client_kind = ? AND memory_uuid = ? AND version = ?
114
+ """,
115
+ (key.session_id, key.client_kind, key.memory_uuid, key.version),
116
+ )
117
+ return len(list(rows)) > 0
118
+
119
+
120
+ async def revoke_on_version_bump(db: aiosqlite.Connection, *, keep: ReceiptKey) -> None:
121
+ """Delete every receipt for `keep.memory_uuid` except the writer's own, freshly minted one.
122
+
123
+ Invariant 9: "On a version bump of uuid U, every receipt for U is deleted except the one
124
+ minted for the writer at the new version." The writer's own receipt is identified by all
125
+ three of its key columns, not by version alone — two different clients of one session could
126
+ otherwise both survive a bump that only one of them earned, which is exactly the leak
127
+ `client_kind` was added to the key to close.
128
+
129
+ Call this **after** minting the writer's own-write receipt at the new version, since deleting
130
+ first and minting second would, for one statement's duration, be indistinguishable from a
131
+ caller that deleted every receipt and simply forgot to mint one back.
132
+ """
133
+ await db.execute(
134
+ """
135
+ DELETE FROM read_receipt
136
+ WHERE memory_uuid = ?
137
+ AND NOT (session_id = ? AND client_kind = ? AND version = ?)
138
+ """,
139
+ (keep.memory_uuid, keep.session_id, keep.client_kind, keep.version),
140
+ )
141
+
142
+
143
+ def require_all(uuids_with_receipt: Sequence[str], *, uuids: Sequence[str]) -> None:
144
+ """Raise `no_read_receipt` naming every uuid in `uuids` missing a receipt.
145
+
146
+ `schema.md` invariant 9's precedence note — "the version check runs before the receipt
147
+ check" — is enforced by call order at each raise site, not by this function: this is purely
148
+ the payload assembly for the rung once a caller has already decided it is time to run it, and
149
+ architecture.md's ladders say the per-row steps are evaluated across **all** named rows
150
+ before any is rejected, so every missing uuid is named in one call rather than the first.
151
+
152
+ Args:
153
+ uuids_with_receipt: the subset of `uuids` a matching receipt was found for, in any order.
154
+ uuids: every uuid the caller's write is about to mutate.
155
+ """
156
+ held = set(uuids_with_receipt)
157
+ missing = [uuid for uuid in uuids if uuid not in held]
158
+ if missing:
159
+ raise ZikaronError(
160
+ ErrorCode.NO_READ_RECEIPT, uuids=missing, hint="re-read it through fetch or next_group"
161
+ )
@@ -0,0 +1,221 @@
1
+ """The supersession graph's write-time checks: invariant 6.
2
+
3
+ The graph is a rooted converging forest — out-degree ≤ 1, in-degree unbounded, acyclic — and
4
+ `schema.md` states three preconditions checked *at the moment an edge is created*, inside the
5
+ same transaction as the write: no self-edge, no cycle, and the target not already retired-outright
6
+ at that instant. Two of those three are permanent properties once the edge exists (a self-edge and
7
+ a cycle can never appear later, since edges are immutable); the third is a precondition only — the
8
+ target becoming retired-outright *afterwards* is new information, not a false claim, so nothing
9
+ here re-checks it once the edge is written.
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from enum import StrEnum
14
+
15
+ import aiosqlite
16
+
17
+ from zikaron.core.errors import ErrorCode, ZikaronError
18
+
19
+
20
+ class BadSupersessionReason(StrEnum):
21
+ """Why `retire`'s `superseded_by` was refused, for the `bad_supersession` payload's `reason`.
22
+
23
+ `architecture.md` §Errors names the four cases as prose ("self-edge, cycle, target retired-
24
+ outright, edge already set, or depth cap hit") rather than a stated closed set, so this
25
+ enumeration is this module's own decision about how to discriminate them, not a transcription
26
+ of a design table — unlike `BadMergeTargetReason`, which the design table itself constrains.
27
+
28
+ `ALREADY_SUPERSEDED` — "edge already set" — is a member with no raise site in this
29
+ code: `superseded_by` is immutable once written (invariant 6's third rule), and
30
+ the only row that could attempt a second edge is one already `active=0`, which the primary-
31
+ verb ladder's state-legality rung (`architecture.md` §"Validation precedence") catches as
32
+ `inactive_row` before any supersession check runs at all. It is declared anyway so this
33
+ enumeration matches the design's own five-case list exactly, rather than only the subset this
34
+ today's call sites happen to reach.
35
+ """
36
+
37
+ SELF_EDGE = "self_edge"
38
+ CYCLE = "cycle"
39
+ TARGET_RETIRED_OUTRIGHT = "target_retired_outright"
40
+ ALREADY_SUPERSEDED = "already_superseded"
41
+ DEPTH_CAP_HIT = "depth_cap_hit"
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class _TargetRow:
46
+ uuid: str
47
+ active: bool
48
+ superseded_by: str | None
49
+
50
+
51
+ async def _load_target(db: aiosqlite.Connection, uuid: str) -> _TargetRow | None:
52
+ rows = await db.execute_fetchall(
53
+ "SELECT uuid, active, superseded_by FROM memory WHERE uuid = ?", (uuid,)
54
+ )
55
+ found = list(rows)
56
+ if not found:
57
+ return None
58
+ (found_uuid, active, superseded_by) = found[0]
59
+ return _TargetRow(uuid=str(found_uuid), active=bool(active), superseded_by=superseded_by)
60
+
61
+
62
+ async def _walk_would_cycle(
63
+ db: aiosqlite.Connection, *, from_uuid: str, to_uuid: str, max_depth: int
64
+ ) -> bool:
65
+ """Whether writing `from_uuid -> to_uuid` would close a cycle, walking `to_uuid`'s own chain.
66
+
67
+ `schema.md`: "Before writing A→B, walk B's chain; if it reaches A, reject." The walk is
68
+ capped at `max_depth`, and reaching the cap without resolving is itself an error — a corrupt
69
+ store must not hang a query — reported by the caller via `DEPTH_CAP_HIT`, distinct from an
70
+ actual cycle so an operator is told which one happened.
71
+
72
+ Raises:
73
+ ZikaronError: `BAD_SUPERSESSION` with `reason=DEPTH_CAP_HIT` if the walk exhausts
74
+ `max_depth` steps without reaching either `from_uuid` or a row with no successor.
75
+ """
76
+ current = to_uuid
77
+ for _ in range(max_depth):
78
+ if current == from_uuid:
79
+ return True
80
+ row = await _load_target(db, current)
81
+ # The target's own existence was already checked before this walk runs; a superseded_by
82
+ # pointing at a since-deleted row would violate the FK `ON DELETE RESTRICT` this table
83
+ # declares, so `row` being `None` here would mean the store's own foreign key failed to
84
+ # hold — not a case this walk needs to model as reachable.
85
+ if row is None or row.superseded_by is None:
86
+ return False
87
+ current = row.superseded_by
88
+ raise ZikaronError(
89
+ ErrorCode.BAD_SUPERSESSION,
90
+ uuid=from_uuid,
91
+ target=to_uuid,
92
+ reason=BadSupersessionReason.DEPTH_CAP_HIT,
93
+ )
94
+
95
+
96
+ async def validate_new_edge(
97
+ db: aiosqlite.Connection, *, from_uuid: str, to_uuid: str, max_depth: int
98
+ ) -> None:
99
+ """Check every write-time *legality* precondition for a new `from_uuid.superseded_by = to_uuid`
100
+ edge — rung 5 of the ladder. Existence of `to_uuid` is rung 2 and must already have been
101
+ checked before this runs (`memory.py`'s `_authorize_mutation` takes `to_uuid` as one of its
102
+ `other_named_uuids` and checks it there, ahead of `from_uuid`'s own version and receipt);
103
+ this function re-loads the row anyway, because the legality checks need its
104
+ `active`/`superseded_by` values and a second `SELECT` inside one transaction is cheaper than
105
+ threading the row through as a parameter that would then let the two functions' read of it
106
+ silently diverge if one caller skipped the first.
107
+
108
+ Runs inside the caller's own transaction, before the `UPDATE` that actually writes the edge —
109
+ `schema.md` requires the checks and the write to share one transaction, and this function
110
+ only reads, so the caller commits both once this returns without raising.
111
+
112
+ Args:
113
+ from_uuid: the row being retired — `retire`'s `uuid`.
114
+ to_uuid: the replacement — `retire`'s `superseded_by`. Assumed to already exist.
115
+ max_depth: `supersession_max_depth`, the cycle walk's cap.
116
+
117
+ Raises:
118
+ ZikaronError: `BAD_SUPERSESSION`, `reason` naming which precondition failed —
119
+ `SELF_EDGE` (`from_uuid == to_uuid`), `TARGET_RETIRED_OUTRIGHT` (`to_uuid` is already
120
+ `active=0 AND superseded_by IS NULL` at this instant), or `CYCLE` (writing the edge
121
+ would close one).
122
+ """
123
+ if from_uuid == to_uuid:
124
+ raise ZikaronError(
125
+ ErrorCode.BAD_SUPERSESSION,
126
+ uuid=from_uuid,
127
+ target=to_uuid,
128
+ reason=BadSupersessionReason.SELF_EDGE,
129
+ )
130
+ target = await _load_target(db, to_uuid)
131
+ if target is None:
132
+ # The ladder's own existence rung runs strictly before this legality rung and is the
133
+ # actual guarantee that `target` is not `None` here; a caller that skipped it is the
134
+ # real bug. Raising `NOT_FOUND` anyway — rather than assuming a non-`None` result and
135
+ # letting a caller's own mistake surface as an `AttributeError` three lines down — keeps
136
+ # a skipped-rung bug loud and Zikaron-shaped either way.
137
+ raise ZikaronError(ErrorCode.NOT_FOUND, uuid=to_uuid)
138
+ if not target.active and target.superseded_by is None:
139
+ raise ZikaronError(
140
+ ErrorCode.BAD_SUPERSESSION,
141
+ uuid=from_uuid,
142
+ target=to_uuid,
143
+ reason=BadSupersessionReason.TARGET_RETIRED_OUTRIGHT,
144
+ )
145
+ if await _walk_would_cycle(db, from_uuid=from_uuid, to_uuid=to_uuid, max_depth=max_depth):
146
+ raise ZikaronError(
147
+ ErrorCode.BAD_SUPERSESSION,
148
+ uuid=from_uuid,
149
+ target=to_uuid,
150
+ reason=BadSupersessionReason.CYCLE,
151
+ )
152
+
153
+
154
+ class RootState(StrEnum):
155
+ """A supersession chain's root, as `fetch` reports it in `superseded_by_latest_state`.
156
+
157
+ Only two of `RowState`'s three members are reachable here: the walk always terminates at a
158
+ root — a row with `superseded_by IS NULL` — and a root is by definition either live
159
+ (`active=1`) or a terminal component (`active=0 AND superseded_by IS NULL`, invariant 6's own
160
+ "a root is live or terminal, and both are legal"). `superseded` describes a *non*-root node,
161
+ so it can never be the state a walk to the root reports.
162
+ """
163
+
164
+ LIVE = "live"
165
+ RETIRED = "retired"
166
+
167
+
168
+ @dataclass(frozen=True, slots=True)
169
+ class ResolvedHead:
170
+ """The result of walking a chain to its root: invariant 7's `superseded_by_latest` pair."""
171
+
172
+ uuid: str
173
+ state: RootState
174
+
175
+
176
+ async def resolve_latest(
177
+ db: aiosqlite.Connection, *, start_uuid: str, max_depth: int
178
+ ) -> ResolvedHead | None:
179
+ """Walk `start_uuid.superseded_by` to the component root, and report the root's own state.
180
+
181
+ Invariant 7: "the component root, reached by walking `superseded_by` to a row with
182
+ `superseded_by IS NULL`, capped as above." The walk is unambiguous because out-degree is
183
+ ≤ 1, which is the property that makes "latest" well defined even though in-degree is not
184
+ bounded.
185
+
186
+ Returns:
187
+ `None` if `start_uuid` is itself a root (`superseded_by IS NULL`) — `fetch` and the
188
+ conflict payload only populate `superseded_by_latest` when `superseded_by` itself is
189
+ non-null, so a caller of *this* function is expected to check that first rather than
190
+ have it silently return the start row as its own "latest".
191
+ Otherwise the root's uuid and whether that root is `live` or `retired`.
192
+
193
+ Raises:
194
+ ZikaronError: `BAD_SUPERSESSION` with `reason=DEPTH_CAP_HIT` if the walk exceeds
195
+ `max_depth` steps — the same cap and the same failure shape as write-time cycle
196
+ detection, since an unbounded walk on a read path is exactly the hang invariant 6's
197
+ cap exists to prevent, and a corrupt graph is no less corrupt for being read rather
198
+ than written.
199
+ """
200
+ start = await _load_target(db, start_uuid)
201
+ if start is None or start.superseded_by is None:
202
+ return None
203
+ current = start.superseded_by
204
+ for _ in range(max_depth):
205
+ row = await _load_target(db, current)
206
+ if row is None:
207
+ # `superseded_by`'s own FK is `ON DELETE RESTRICT`, so a row this walk is currently
208
+ # standing on cannot have vanished between being pointed at and being read — nothing
209
+ # can construct a store where this branch fires without deleting a
210
+ # row out from under a live foreign key, which SQLite itself refuses to allow.
211
+ return None
212
+ if row.superseded_by is None:
213
+ state = RootState.LIVE if row.active else RootState.RETIRED
214
+ return ResolvedHead(uuid=row.uuid, state=state)
215
+ current = row.superseded_by
216
+ raise ZikaronError(
217
+ ErrorCode.BAD_SUPERSESSION,
218
+ uuid=start_uuid,
219
+ target=current,
220
+ reason=BadSupersessionReason.DEPTH_CAP_HIT,
221
+ )
@@ -0,0 +1 @@
1
+ """Retrieval: two arms, one fusion, one eligibility predicate, and the injected block."""