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,267 @@
|
|
|
1
|
+
"""Layered TOML resolution over the schema `keys.py` declares.
|
|
2
|
+
|
|
3
|
+
Three layers, later wins per key: the built-in defaults, a system-wide file, and a per-store
|
|
4
|
+
override. `architecture.md` §"Configuration" is the normative source for what follows; this
|
|
5
|
+
module is the mechanism, not a second statement of the rule.
|
|
6
|
+
|
|
7
|
+
Two checks happen at different points, deliberately. An unknown key or a value of the wrong
|
|
8
|
+
TOML type is rejected **per file, at parse time** — a file was typed by a person, so silently
|
|
9
|
+
ignoring a misspelling is how a setting goes unnoticed. Range validation runs once, on the
|
|
10
|
+
**merged** result, because what matters is the value the store will actually use, not whether
|
|
11
|
+
every layer that touched a key was independently in range.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import tomllib
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from typing import Final
|
|
19
|
+
|
|
20
|
+
from zikaron.core.config.keys import CONFIG_KEYS, CONFIG_KEYS_BY_NAME, ConfigKey
|
|
21
|
+
from zikaron.core.errors import BadConfigSource, ErrorCode, ZikaronError
|
|
22
|
+
|
|
23
|
+
#: A key's resolved value, and where it came from — a layer path, or `None` for the built-in
|
|
24
|
+
#: default. `architecture.md` requires the resolved config to be logged with a layer per value;
|
|
25
|
+
#: this is the mapping that statement is computed from.
|
|
26
|
+
type Provenance = Mapping[str, Path | None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EffectiveConfig:
|
|
30
|
+
"""Every configuration key, resolved to one typed value.
|
|
31
|
+
|
|
32
|
+
Immutable, and self-validating on construction: `__init__` checks every value against
|
|
33
|
+
`CONFIG_KEYS` itself rather than trusting that whoever called it already ran
|
|
34
|
+
`_validate_ranges`. `resolve()` is the only path that constructs one through ordinary use,
|
|
35
|
+
but "the only real caller validates first" is a fact about today's call sites, not an
|
|
36
|
+
enforced property of this type — and a value that could later change, or a value this
|
|
37
|
+
constructor let through unchecked, would let two `event` rows written in one run disagree
|
|
38
|
+
about the parameters that produced them, which is exactly what `architecture.md` reads the
|
|
39
|
+
files once at startup to prevent.
|
|
40
|
+
|
|
41
|
+
Access a key by its bare name — `effective.get("rrf_k")` — rather than by an attribute, since
|
|
42
|
+
the schema is a runtime table (`CONFIG_KEYS`) and a fixed set of attributes would need to be
|
|
43
|
+
kept in step with it by hand.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: `values` or `provenance` does not carry exactly the keys `CONFIG_KEYS`
|
|
47
|
+
declares, or a value fails its own key's type/range check.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
__slots__ = ("_provenance", "_values")
|
|
51
|
+
|
|
52
|
+
def __init__(self, values: Mapping[str, int | float | str], provenance: Provenance) -> None:
|
|
53
|
+
declared = set(CONFIG_KEYS_BY_NAME)
|
|
54
|
+
if set(values) != declared or set(provenance) != declared:
|
|
55
|
+
raise ValueError("EffectiveConfig requires exactly the keys CONFIG_KEYS declares")
|
|
56
|
+
for key in CONFIG_KEYS:
|
|
57
|
+
if not key.accepts(values[key.name]):
|
|
58
|
+
raise ValueError(f"{key.toml_path}: {values[key.name]!r} is out of range")
|
|
59
|
+
self._values: Final = MappingProxyType(dict(values))
|
|
60
|
+
self._provenance: Final = MappingProxyType(dict(provenance))
|
|
61
|
+
|
|
62
|
+
def get(self, name: str) -> int | float | str:
|
|
63
|
+
"""This key's resolved value, typed exactly as `CONFIG_KEYS_BY_NAME[name]` declares.
|
|
64
|
+
|
|
65
|
+
Prefer `get_int`/`get_float`/`get_str` at a call site that needs one specific type: this
|
|
66
|
+
method's union return type forces a narrowing check on every access, which is exactly
|
|
67
|
+
what the typed accessors below exist to avoid.
|
|
68
|
+
"""
|
|
69
|
+
return self._values[name]
|
|
70
|
+
|
|
71
|
+
def get_int(self, name: str) -> int:
|
|
72
|
+
"""This key's resolved value, which must be declared as an `int` in `CONFIG_KEYS`."""
|
|
73
|
+
value = self._values[name]
|
|
74
|
+
if type(value) is not int:
|
|
75
|
+
raise TypeError(f"{name} is declared {type(value).__name__}, not int")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
def get_float(self, name: str) -> float:
|
|
79
|
+
"""This key's resolved value, which must be declared as a `float` in `CONFIG_KEYS`.
|
|
80
|
+
|
|
81
|
+
A `float`-declared key only: an `int` is not silently widened, because a key whose declared
|
|
82
|
+
type has changed is a schema edit worth a loud failure rather than a quiet coercion.
|
|
83
|
+
"""
|
|
84
|
+
value = self._values[name]
|
|
85
|
+
if type(value) is not float:
|
|
86
|
+
raise TypeError(f"{name} is declared {type(value).__name__}, not float")
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
def get_str(self, name: str) -> str:
|
|
90
|
+
"""This key's resolved value, which must be declared as a `str` in `CONFIG_KEYS`."""
|
|
91
|
+
value = self._values[name]
|
|
92
|
+
if type(value) is not str:
|
|
93
|
+
raise TypeError(f"{name} is declared {type(value).__name__}, not str")
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
def source_of(self, name: str) -> Path | None:
|
|
97
|
+
"""Which layer this key's winning value came from, or `None` for the built-in default."""
|
|
98
|
+
return self._provenance[name]
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def provenance(self) -> Provenance:
|
|
102
|
+
"""Every key's source, for a startup log line naming the layer behind each value."""
|
|
103
|
+
return self._provenance
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_layer(path: Path) -> Mapping[str, object]:
|
|
107
|
+
"""Parse one TOML file, or treat its absence as an empty, contributing-nothing layer.
|
|
108
|
+
|
|
109
|
+
A parse failure is `bad_config` naming the file rather than a bare `tomllib` traceback,
|
|
110
|
+
since an operator's typo should read as a Zikaron error and not as a library exception with
|
|
111
|
+
no key or file attached.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
raw = path.read_bytes()
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
return {}
|
|
117
|
+
try:
|
|
118
|
+
return tomllib.loads(raw.decode("utf-8"))
|
|
119
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as error:
|
|
120
|
+
raise ZikaronError(
|
|
121
|
+
ErrorCode.BAD_CONFIG,
|
|
122
|
+
source=BadConfigSource.FILE,
|
|
123
|
+
file=str(path),
|
|
124
|
+
key="<file>",
|
|
125
|
+
value=str(error),
|
|
126
|
+
expected="valid TOML",
|
|
127
|
+
) from error
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _flatten_and_validate_shape(path: Path, sections: Mapping[str, object]) -> Mapping[str, object]:
|
|
131
|
+
"""Every scalar in a parsed file, keyed by bare name, after checking it against the schema.
|
|
132
|
+
|
|
133
|
+
Two things are rejected here, per file, before any merge happens: a key this schema does not
|
|
134
|
+
declare, and a value whose TOML type does not match what the schema declares for it. Nothing
|
|
135
|
+
that nests deeper than one section reaches here, because a key two levels below a section
|
|
136
|
+
could not be one of `CONFIG_KEYS_BY_NAME`'s bare names in the first place — it fails the
|
|
137
|
+
unknown-key check on its own terms rather than needing a separate depth check.
|
|
138
|
+
"""
|
|
139
|
+
flat: dict[str, object] = {}
|
|
140
|
+
for section_name, section_value in sections.items():
|
|
141
|
+
if not isinstance(section_value, dict):
|
|
142
|
+
raise ZikaronError(
|
|
143
|
+
ErrorCode.BAD_CONFIG,
|
|
144
|
+
source=BadConfigSource.FILE,
|
|
145
|
+
file=str(path),
|
|
146
|
+
key=section_name,
|
|
147
|
+
value=repr(section_value),
|
|
148
|
+
expected="a table (TOML section)",
|
|
149
|
+
)
|
|
150
|
+
for key_name, value in section_value.items():
|
|
151
|
+
declared = CONFIG_KEYS_BY_NAME.get(key_name)
|
|
152
|
+
if declared is None or declared.section.value != section_name:
|
|
153
|
+
raise ZikaronError(
|
|
154
|
+
ErrorCode.BAD_CONFIG,
|
|
155
|
+
source=BadConfigSource.FILE,
|
|
156
|
+
file=str(path),
|
|
157
|
+
key=f"{section_name}.{key_name}",
|
|
158
|
+
value=repr(value),
|
|
159
|
+
expected="a declared configuration key",
|
|
160
|
+
)
|
|
161
|
+
if type(value) is not declared.value_type:
|
|
162
|
+
raise ZikaronError(
|
|
163
|
+
ErrorCode.BAD_CONFIG,
|
|
164
|
+
source=BadConfigSource.FILE,
|
|
165
|
+
file=str(path),
|
|
166
|
+
key=declared.toml_path,
|
|
167
|
+
value=repr(value),
|
|
168
|
+
expected=declared.value_type.__name__,
|
|
169
|
+
)
|
|
170
|
+
flat[key_name] = value
|
|
171
|
+
return flat
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _merge_layer(
|
|
175
|
+
path: Path,
|
|
176
|
+
values: dict[str, int | float | str],
|
|
177
|
+
provenance: dict[str, Path | None],
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Apply one file's keys onto the running merge, in place — later layers overwrite earlier."""
|
|
180
|
+
parsed = _read_layer(path)
|
|
181
|
+
if not parsed:
|
|
182
|
+
return
|
|
183
|
+
for key_name, value in _flatten_and_validate_shape(path, parsed).items():
|
|
184
|
+
# `_flatten_and_validate_shape` has already checked `value`'s type against the
|
|
185
|
+
# declared key's `value_type`, which is always one of these three — but that fact is
|
|
186
|
+
# not visible to the type checker across the function boundary, so it is checked again
|
|
187
|
+
# here rather than asserted past.
|
|
188
|
+
if not isinstance(value, int | float | str):
|
|
189
|
+
raise TypeError(f"{key_name}: resolved to {type(value).__name__}, not int/float/str")
|
|
190
|
+
values[key_name] = value
|
|
191
|
+
provenance[key_name] = path
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _validate_ranges(values: Mapping[str, int | float | str], provenance: Provenance) -> None:
|
|
195
|
+
"""Range- and type-check every key's winning value, naming the file it came from.
|
|
196
|
+
|
|
197
|
+
Runs once, after every layer has been merged, because a value the system-wide layer got
|
|
198
|
+
wrong and the project override corrected is fine — what the service will use is what is
|
|
199
|
+
checked. An out-of-range built-in default cannot occur: `ConfigKey.__post_init__` already
|
|
200
|
+
refuses that at import time.
|
|
201
|
+
"""
|
|
202
|
+
for key in CONFIG_KEYS:
|
|
203
|
+
value = values[key.name]
|
|
204
|
+
if key.accepts(value):
|
|
205
|
+
continue
|
|
206
|
+
source = provenance[key.name]
|
|
207
|
+
raise ZikaronError(
|
|
208
|
+
ErrorCode.BAD_CONFIG,
|
|
209
|
+
source=BadConfigSource.FILE,
|
|
210
|
+
file=str(source) if source is not None else "<default>",
|
|
211
|
+
key=key.toml_path,
|
|
212
|
+
value=repr(value),
|
|
213
|
+
expected=f"{key.value_type.__name__} in {key.bounds}",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve(system_path: Path, project_path: Path) -> EffectiveConfig:
|
|
218
|
+
"""Resolve the effective config from the built-in defaults plus the two file layers.
|
|
219
|
+
|
|
220
|
+
Neither file has to exist, and neither has to be complete: an absent file contributes
|
|
221
|
+
nothing, and a present one states only what it changes. `system_path` is read first, so
|
|
222
|
+
`project_path` amends it per key — exactly `architecture.md`'s "later layer wins, per key."
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
ZikaronError: `BAD_CONFIG`, for an unparseable file, an unknown key, a key of the wrong
|
|
226
|
+
TOML type, or an effective value out of range — always naming the offending file
|
|
227
|
+
and key.
|
|
228
|
+
"""
|
|
229
|
+
values: dict[str, int | float | str] = {key.name: key.default for key in CONFIG_KEYS}
|
|
230
|
+
provenance: dict[str, Path | None] = {key.name: None for key in CONFIG_KEYS}
|
|
231
|
+
_merge_layer(system_path, values, provenance)
|
|
232
|
+
_merge_layer(project_path, values, provenance)
|
|
233
|
+
_validate_ranges(values, provenance)
|
|
234
|
+
return EffectiveConfig(values, provenance)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def default_system_config_path(xdg_config_home: str | None, home: Path) -> Path:
|
|
238
|
+
"""Where the system-wide layer lives, per `architecture.md`'s `${XDG_CONFIG_HOME:-~/.config}`.
|
|
239
|
+
|
|
240
|
+
A pure function of its inputs rather than one that reads `os.environ`/`Path.home()` itself,
|
|
241
|
+
so a test can state the environment it is checking instead of having to mutate the process's.
|
|
242
|
+
"""
|
|
243
|
+
base = Path(xdg_config_home) if xdg_config_home else home / ".config"
|
|
244
|
+
return base / "zikaron" / "config.toml"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def project_config_path(store_dir: Path) -> Path:
|
|
248
|
+
"""Where the per-store override lives: `config.toml` beside `memory.db`, inside `.zikaron/`."""
|
|
249
|
+
return store_dir / "config.toml"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def key_for_toml_path(toml_path: str) -> ConfigKey | None:
|
|
253
|
+
"""The declared key for a dotted `section.name` path, or `None` if no such key exists there.
|
|
254
|
+
|
|
255
|
+
Validates the **whole** path, not merely the bare name after the last dot: a key that
|
|
256
|
+
exists under a different section, or a bare name with no section at all, is not "the key
|
|
257
|
+
for" the path given — `toml_path` names both halves, and a lookup that discarded the
|
|
258
|
+
section would accept `"dedup.rrf_k"` as if it named `retrieval.rrf_k`, which is a
|
|
259
|
+
misspelled, not a matching, path.
|
|
260
|
+
"""
|
|
261
|
+
section, separator, bare_name = toml_path.rpartition(".")
|
|
262
|
+
if separator == "":
|
|
263
|
+
return None
|
|
264
|
+
key = CONFIG_KEYS_BY_NAME.get(bare_name)
|
|
265
|
+
if key is None or key.section.value != section:
|
|
266
|
+
return None
|
|
267
|
+
return key
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Consolidation: grouping, the run and group state machine, leases, and the four verbs."""
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""The consolidator validation ladder: authorization first, then existence, version, receipt, state.
|
|
2
|
+
|
|
3
|
+
`architecture.md` §"Validation precedence" is normative, and the order is part of the contract
|
|
4
|
+
because different orders return different errors — and, for the consolidator, **leak different
|
|
5
|
+
amounts of the store**.
|
|
6
|
+
|
|
7
|
+
**Why authorization must precede version, and why this is not a preference.** A `version_conflict`
|
|
8
|
+
payload returns the row's full current record and mints a receipt for it. If version were checked
|
|
9
|
+
first, a consolidator could name any uuid in the store with a deliberately wrong version and be
|
|
10
|
+
handed that record's gist, content and a licence to write it — reconstructing, one deliberate
|
|
11
|
+
conflict at a time, exactly the `fetch` D32 withholds, and leaving D7's claim that *code* selects
|
|
12
|
+
the candidates enforced by nothing. So rungs 2 and 3 answer only "is this uuid one of the ones I
|
|
13
|
+
handed you", and their payloads carry **uuids and nothing else**: no version, no state, no prose,
|
|
14
|
+
and `bad_merge_target`'s `reason` does not distinguish "does not exist" from "not authorized", so
|
|
15
|
+
the error cannot be used as an existence oracle either.
|
|
16
|
+
|
|
17
|
+
**Within the authorized set, version still precedes receipt.** A version bump deletes the row's
|
|
18
|
+
other receipts, so checking receipts first would report `no_read_receipt` for every ordinary
|
|
19
|
+
lost-update race — the consolidator *did* read, it read the version the serve delivered while the
|
|
20
|
+
row moved — and the one-round-trip retry that same invariant promises would become two.
|
|
21
|
+
|
|
22
|
+
**Every per-row rung is evaluated across all named rows before any is rejected**, so one call
|
|
23
|
+
reports every offending uuid rather than the first.
|
|
24
|
+
|
|
25
|
+
**A version conflict is returned, not raised.** For the consolidator verbs the design states
|
|
26
|
+
`{conflict: true, current: [...], remaining_uuids: [...]}` as a *response shape*, and it carries a
|
|
27
|
+
member list that only a read inside this transaction can produce. Returning it keeps that read where
|
|
28
|
+
it belongs and makes the committed audit trail ordinary rather than a carve-out. `no_read_receipt`
|
|
29
|
+
*is* raised, because the design gives it no second response shape — its events survive through
|
|
30
|
+
invariant 10's carve-out, which the transaction owner applies by inspecting the raised code.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from collections.abc import Sequence
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
|
|
36
|
+
import aiosqlite
|
|
37
|
+
|
|
38
|
+
from zikaron.core.consolidation import groups, rowstate, runs
|
|
39
|
+
from zikaron.core.consolidation.context import ConsolidationCall
|
|
40
|
+
from zikaron.core.consolidation.groups import Group, GroupStatus
|
|
41
|
+
from zikaron.core.consolidation.payload import GroupConflict, NamedRow, NamedRows
|
|
42
|
+
from zikaron.core.consolidation.runs import Run
|
|
43
|
+
from zikaron.core.errors import BadMergeTargetReason, ErrorCode, ZikaronError
|
|
44
|
+
from zikaron.core.records import memory as records
|
|
45
|
+
from zikaron.core.records import receipts
|
|
46
|
+
from zikaron.core.records.memory import ConflictRecord, Memory
|
|
47
|
+
from zikaron.core.records.receipts import ReceiptKey
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class Authorized:
|
|
52
|
+
"""Everything a verb needs once every rung has passed, and nothing it would have to re-read.
|
|
53
|
+
|
|
54
|
+
The rows are the `Memory` values the ladder already loaded and version-checked, so a verb
|
|
55
|
+
neither re-reads them nor risks acting on a different snapshot than the one it was authorized
|
|
56
|
+
against.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
run: Run
|
|
60
|
+
group: Group
|
|
61
|
+
target: Memory | None
|
|
62
|
+
absorbed: tuple[Memory, ...]
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def absorbed_uuids(self) -> tuple[str, ...]:
|
|
66
|
+
"""The absorbed rows' uuids, in the order the call named them."""
|
|
67
|
+
return tuple(row.uuid for row in self.absorbed)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reject_expired(group: Group, run: Run, *, at: str) -> ZikaronError:
|
|
71
|
+
"""`group_expired`, reporting the **stored** status and the **effective** one side by side.
|
|
72
|
+
|
|
73
|
+
Both, because they differ in the case that matters: a stored `'active'` row whose lease has
|
|
74
|
+
passed is effectively expired to every reader, and only `plan_groups` ever writes the terminal
|
|
75
|
+
status. A caller told only one of the two could not tell a lease that ran out from a run
|
|
76
|
+
somebody replanned.
|
|
77
|
+
"""
|
|
78
|
+
return ZikaronError(
|
|
79
|
+
ErrorCode.GROUP_EXPIRED,
|
|
80
|
+
group_id=group.group_id,
|
|
81
|
+
run_status=run.status.value,
|
|
82
|
+
expires_at=run.expires_at,
|
|
83
|
+
effective_status=run.effective_status(at=at).value,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _require_authorized_run(group: Group, run: Run, *, call: ConsolidationCall, at: str) -> None:
|
|
88
|
+
"""The run half of rung 2: effectively active, and owned by this caller's `(session_id, pid)`.
|
|
89
|
+
|
|
90
|
+
The **pair**, never the session alone, because every client of one kiro session shares a
|
|
91
|
+
`session_id` *and* two consolidators of that session also share `client_kind='consolidator'` —
|
|
92
|
+
so a session-only test would let a second worker mutate a group the first still holds and would
|
|
93
|
+
defeat the one-worker guarantee `next_group` establishes.
|
|
94
|
+
|
|
95
|
+
Both failures answer `group_expired` rather than distinguishing "not yours" from "lapsed", which
|
|
96
|
+
is the design's own choice: the payload names the group the caller already holds and the
|
|
97
|
+
recovery is the same call either way.
|
|
98
|
+
"""
|
|
99
|
+
if run.owner != call.owner or not run.is_effectively_active(at=at):
|
|
100
|
+
raise _reject_expired(group, run, at=at)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _require_servable(group: Group, *, absorb: Sequence[NamedRow]) -> None:
|
|
104
|
+
"""The group half of rung 2: `served`, and none of the three states that are not.
|
|
105
|
+
|
|
106
|
+
`pending` is the case the design's own list omitted, and it answers `not_in_group`: a group
|
|
107
|
+
never delivered handed out no version, so no row of it is *actionable*, which is exactly what
|
|
108
|
+
that code means. A conforming consolidator cannot reach it — it learns a `group_id` only from a
|
|
109
|
+
serve — and it is checked rather than argued away because the alternative argument is three
|
|
110
|
+
steps long.
|
|
111
|
+
"""
|
|
112
|
+
if group.status is GroupStatus.COMPLETE:
|
|
113
|
+
raise ZikaronError(ErrorCode.GROUP_COMPLETE, group_id=group.group_id)
|
|
114
|
+
if group.status is GroupStatus.DEFERRED:
|
|
115
|
+
raise ZikaronError(
|
|
116
|
+
ErrorCode.GROUP_DEFERRED, group_id=group.group_id, serve_count=group.serve_count
|
|
117
|
+
)
|
|
118
|
+
if group.status is not GroupStatus.SERVED:
|
|
119
|
+
raise ZikaronError(
|
|
120
|
+
ErrorCode.NOT_IN_GROUP,
|
|
121
|
+
group_id=group.group_id,
|
|
122
|
+
uuids=[row.uuid for row in absorb],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _require_actionable_members(
|
|
127
|
+
group: Group, *, absorb: Sequence[NamedRow], actionable: frozenset[str]
|
|
128
|
+
) -> None:
|
|
129
|
+
"""The member half of rung 2: a non-empty list, every element an undispositioned member.
|
|
130
|
+
|
|
131
|
+
An empty list is `not_in_group` too, which the error table states directly: silence must not be
|
|
132
|
+
able to complete a group, and an empty `absorb` is the closest thing to silence a call can be.
|
|
133
|
+
"""
|
|
134
|
+
offending = [row.uuid for row in absorb if row.uuid not in actionable]
|
|
135
|
+
if not absorb or offending:
|
|
136
|
+
raise ZikaronError(ErrorCode.NOT_IN_GROUP, group_id=group.group_id, uuids=offending)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _require_authorized_target(
|
|
140
|
+
db: aiosqlite.Connection, group: Group, *, target: NamedRow | None
|
|
141
|
+
) -> None:
|
|
142
|
+
"""The target half of rung 2: a `merge` target must be in this group's persisted authorization
|
|
143
|
+
set.
|
|
144
|
+
|
|
145
|
+
That table — not the `group_served` events, which are instrumentation — is the authority, and
|
|
146
|
+
it is rewritten on every serve, so a record a *previous* serve showed is no longer authorized.
|
|
147
|
+
The reason reported is `not_authorized` whether or not the uuid exists, deliberately, so the
|
|
148
|
+
two cases are indistinguishable to the caller.
|
|
149
|
+
"""
|
|
150
|
+
if target is None:
|
|
151
|
+
return
|
|
152
|
+
if target.uuid not in await groups.authorized_uuids(db, group.group_id):
|
|
153
|
+
raise ZikaronError(
|
|
154
|
+
ErrorCode.BAD_MERGE_TARGET,
|
|
155
|
+
group_id=group.group_id,
|
|
156
|
+
uuid=target.uuid,
|
|
157
|
+
reason=BadMergeTargetReason.NOT_AUTHORIZED,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _version_conflicts(
|
|
162
|
+
db: aiosqlite.Connection,
|
|
163
|
+
*,
|
|
164
|
+
rows: dict[str, Memory],
|
|
165
|
+
named: NamedRows,
|
|
166
|
+
call: ConsolidationCall,
|
|
167
|
+
verb: str,
|
|
168
|
+
) -> tuple[ConflictRecord, ...]:
|
|
169
|
+
"""Rung 4 across every named row: the current record for each whose version has moved.
|
|
170
|
+
|
|
171
|
+
Empty when every version matches, which is the pass condition. Each conflicting row gets its own
|
|
172
|
+
`conflict` receipt at the current version and its own `version_conflict` event before this
|
|
173
|
+
returns — the audit trail invariant 10's carve-out is about — and the receipt is what makes
|
|
174
|
+
D26's "re-decide in one round trip" true, since the consolidator has no `fetch` to earn one
|
|
175
|
+
with.
|
|
176
|
+
"""
|
|
177
|
+
conflicting = [row for row in named.every if rows[row.uuid].version != row.expected_version]
|
|
178
|
+
return tuple(
|
|
179
|
+
[
|
|
180
|
+
await records.record_version_conflict(
|
|
181
|
+
db,
|
|
182
|
+
current=rows[row.uuid],
|
|
183
|
+
ctx=call.ctx,
|
|
184
|
+
verb=verb,
|
|
185
|
+
presented_version=row.expected_version,
|
|
186
|
+
)
|
|
187
|
+
for row in conflicting
|
|
188
|
+
]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _require_receipts(
|
|
193
|
+
db: aiosqlite.Connection, *, named: NamedRows, call: ConsolidationCall, verb: str
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Rung 5 across every named row: a receipt for each, at the version it presented.
|
|
196
|
+
|
|
197
|
+
Raises once naming every uuid that lacks one, after logging a `no_receipt` event for each — the
|
|
198
|
+
events are what D30's version-conflict signal counts, so they have to be durable even though the
|
|
199
|
+
call changed nothing.
|
|
200
|
+
"""
|
|
201
|
+
missing = [
|
|
202
|
+
row
|
|
203
|
+
for row in named.every
|
|
204
|
+
if not await receipts.spend(
|
|
205
|
+
db,
|
|
206
|
+
key=ReceiptKey(
|
|
207
|
+
session_id=call.ctx.session_id,
|
|
208
|
+
client_kind=call.ctx.client_kind,
|
|
209
|
+
memory_uuid=row.uuid,
|
|
210
|
+
version=row.expected_version,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
]
|
|
214
|
+
if not missing:
|
|
215
|
+
return
|
|
216
|
+
for row in missing:
|
|
217
|
+
await records.record_no_receipt(
|
|
218
|
+
db, uuid=row.uuid, ctx=call.ctx, verb=verb, presented_version=row.expected_version
|
|
219
|
+
)
|
|
220
|
+
raise ZikaronError(
|
|
221
|
+
ErrorCode.NO_READ_RECEIPT,
|
|
222
|
+
uuids=[row.uuid for row in missing],
|
|
223
|
+
hint="re-read it through fetch or next_group",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def _require_legal_state(db: aiosqlite.Connection, group: Group, *, named: NamedRows) -> None:
|
|
228
|
+
"""Rung 6: the target still targetable, every absorbed row still an actionable member.
|
|
229
|
+
|
|
230
|
+
The absorbed half has exactly one producer and it is reachable: a primary agent `retire`s a
|
|
231
|
+
served member, which bumps its version and revokes the serve-minted receipt, so the
|
|
232
|
+
consolidator's call returns `version_conflict` with a fresh receipt — and the **retry**, now
|
|
233
|
+
correct at every earlier rung, arrives here holding a row that is `active=0`. Such a row is
|
|
234
|
+
*vacated in fact and not yet recorded*, failing the identical predicate the serve applies, so
|
|
235
|
+
the answer is `not_in_group` rather than a new code: one meaning, one payload, one recovery. It
|
|
236
|
+
leaks nothing, because a caller that reached this rung already passed the receipt check.
|
|
237
|
+
|
|
238
|
+
The disposition itself is deliberately **not** written here. A rejected call must write no
|
|
239
|
+
`consolidation_group*` disposition; the next serve records it, which is also what may legally
|
|
240
|
+
close the group.
|
|
241
|
+
"""
|
|
242
|
+
target = named.target
|
|
243
|
+
if target is not None and not await rowstate.is_targetable(db, target.uuid):
|
|
244
|
+
raise ZikaronError(
|
|
245
|
+
ErrorCode.BAD_MERGE_TARGET,
|
|
246
|
+
group_id=group.group_id,
|
|
247
|
+
uuid=target.uuid,
|
|
248
|
+
reason=BadMergeTargetReason.NOT_TARGETABLE,
|
|
249
|
+
)
|
|
250
|
+
left = [
|
|
251
|
+
row.uuid for row in named.absorb if not await rowstate.is_deliverable_member(db, row.uuid)
|
|
252
|
+
]
|
|
253
|
+
if left:
|
|
254
|
+
raise ZikaronError(ErrorCode.NOT_IN_GROUP, group_id=group.group_id, uuids=left)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def authorize(
|
|
258
|
+
db: aiosqlite.Connection,
|
|
259
|
+
*,
|
|
260
|
+
group_id: str,
|
|
261
|
+
named: NamedRows,
|
|
262
|
+
call: ConsolidationCall,
|
|
263
|
+
verb: str,
|
|
264
|
+
) -> Authorized | GroupConflict:
|
|
265
|
+
"""Run rungs 2-6 of the consolidator ladder, in the order the contract fixes.
|
|
266
|
+
|
|
267
|
+
Rung 1 — bounds — has already run: `NamedRows` refuses a repeated `absorb` uuid at construction,
|
|
268
|
+
and the `gist_max_tokens` bound belongs to the chunking preflight the two prose-authoring verbs
|
|
269
|
+
run before their transaction opens.
|
|
270
|
+
|
|
271
|
+
Assumes the caller's own open transaction, and stages no mutation of its own beyond the audit
|
|
272
|
+
trail a version conflict is defined to leave: a rejection from any rung leaves every `memory`
|
|
273
|
+
row, every version and every `consolidation_group*` status and disposition exactly as it found
|
|
274
|
+
them.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
`Authorized` with the rows already loaded and checked, or `GroupConflict` when at least one
|
|
278
|
+
named row's version has moved — a response shape rather than an error, carrying the current
|
|
279
|
+
record for **every** conflicting uuid so the model can re-decide in one round trip.
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ZikaronError: `GROUP_UNKNOWN`, `GROUP_EXPIRED`, `GROUP_COMPLETE`, `GROUP_DEFERRED`,
|
|
283
|
+
`NOT_IN_GROUP`, `BAD_MERGE_TARGET`, `NOT_FOUND` or `NO_READ_RECEIPT` — each from the
|
|
284
|
+
rung the design assigns it to, and each having changed nothing but its own audit events.
|
|
285
|
+
"""
|
|
286
|
+
at = runs.now()
|
|
287
|
+
group = await groups.load(db, group_id)
|
|
288
|
+
if group is None:
|
|
289
|
+
raise ZikaronError(ErrorCode.GROUP_UNKNOWN, group_id=group_id)
|
|
290
|
+
run = await runs.load(db, group.run_id)
|
|
291
|
+
if run is None:
|
|
292
|
+
# `consolidation_group.run_id`'s foreign key makes this unreachable without disabling FK
|
|
293
|
+
# enforcement, and it is refused rather than treated as "no run" because a group whose run
|
|
294
|
+
# cannot be found is not a group whose lease has lapsed — the two have opposite recoveries.
|
|
295
|
+
raise ZikaronError(ErrorCode.GROUP_UNKNOWN, group_id=group_id)
|
|
296
|
+
_require_authorized_run(group, run, call=call, at=at)
|
|
297
|
+
_require_servable(group, absorb=named.absorb)
|
|
298
|
+
actionable = frozenset(await groups.open_member_uuids(db, group_id))
|
|
299
|
+
_require_actionable_members(group, absorb=named.absorb, actionable=actionable)
|
|
300
|
+
await _require_authorized_target(db, group, target=named.target)
|
|
301
|
+
|
|
302
|
+
rows = {row.uuid: await records.require_existing(db, row.uuid) for row in named.every}
|
|
303
|
+
conflicts = await _version_conflicts(db, rows=rows, named=named, call=call, verb=verb)
|
|
304
|
+
if conflicts:
|
|
305
|
+
return GroupConflict(
|
|
306
|
+
current=conflicts,
|
|
307
|
+
remaining_uuids=await groups.open_member_uuids(db, group_id),
|
|
308
|
+
)
|
|
309
|
+
await _require_receipts(db, named=named, call=call, verb=verb)
|
|
310
|
+
await _require_legal_state(db, group, named=named)
|
|
311
|
+
return Authorized(
|
|
312
|
+
run=run,
|
|
313
|
+
group=group,
|
|
314
|
+
target=None if named.target is None else rows[named.target.uuid],
|
|
315
|
+
absorbed=tuple(rows[row.uuid] for row in named.absorb),
|
|
316
|
+
)
|