zikaron 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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."""
|