sidegraph 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.
sidegraph/verify.py ADDED
@@ -0,0 +1,1040 @@
1
+ """Integrity lint for the decision store — ``sidegraph-verify``.
2
+
3
+ See design/superpowers/specs/2026-07-11-ci-integrity-design.md ruling 2. Two layers:
4
+
5
+ - **Snapshot layer** (:func:`verify_snapshot`, always runs): every hot/archived file
6
+ parses against its schema and the store's referential invariants — see this section's
7
+ docstring below.
8
+ - **Transition layer** (:func:`verify_against`, ``--against <git-ref>``, CI mode): for
9
+ each store file that CHANGED vs a git ref, classify the modification against the
10
+ store's OWN write rules — append-only as a checkable contract, not a blanket "committed
11
+ files never change" lint (which would be wrong: supersede legally closes a predecessor,
12
+ ratify legally flips statuses, sync legally refreshes engine-mapping fields, compact
13
+ legally deletes a hot file once archived). See the "transition layer" section near the
14
+ bottom of this module for the mutable-field tables (derived from ``store.py``'s write
15
+ methods, not invented) and :func:`classify_transition`/:func:`verify_against`.
16
+
17
+ **PURE READ.** :func:`verify_snapshot` opens the canonical files under a store directory
18
+ directly (``json.loads`` + ``Model.model_validate``) and never constructs a
19
+ :class:`sidegraph.store.Store`. Opening a ``Store`` is NOT a read-only operation — its
20
+ ``__init__`` runs ``_refresh_freshness``, which can WRITE: a stale/missing digest triggers
21
+ ``_reload_index_from_canonical`` (rewrites ``index.db``'s digest + ``schema_version``
22
+ meta), and an unmigrated legacy single-file store gets migrated in place. A lint command
23
+ must never mutate the very thing it is inspecting.
24
+
25
+ Reading the canonical files directly (rather than the derived ``index.db``) is also *the
26
+ point*, not just a side constraint: the index's own reload path is deliberately tolerant of
27
+ several on-disk shapes this module exists specifically to flag —
28
+ :func:`~sidegraph.store.Store._reload_index_from_canonical` silently treats a hot file that
29
+ duplicates an already-archived record as harmless "crash-window debris" whenever the two
30
+ copies are byte-identical (see
31
+ ``docs/reference/store-format.md#archive-segments-sidegraph-compact``), and only warns (never
32
+ fails, never surfaces in any report) on a genuine hot/archive mismatch. ``verify_snapshot`` is
33
+ stricter on purpose, with exactly ONE sanctioned exception (design amendment, Task-2 review):
34
+ two or more archive segments carrying byte-IDENTICAL payloads for the same id are the store's
35
+ own tolerated cross-branch merge shape (independent ``sidegraph-compact`` runs on two
36
+ branches, later merged — see ``store._archived_records``'s "guaranteed byte-identical by
37
+ design") and are exempt from :data:`DUPLICATE_ULID`. Every other multi-location shape is
38
+ flagged: two+ hot files sharing an internal id (the store's file-per-record model allows at
39
+ most one), a hot file coexisting with ANY archive copy, or archive segments whose payloads for
40
+ the same id actually differ. A hot file whose *filename* doesn't match its own internal id is
41
+ a separate finding, :data:`FILENAME_ID_MISMATCH` — the store always writes
42
+ ``<record's own id>.json``, so a mismatch is never legitimate.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import subprocess
49
+ from dataclasses import dataclass, replace
50
+ from pathlib import Path
51
+
52
+ from pydantic import BaseModel, ValidationError
53
+
54
+ from .schema import SCHEMA_VERSION, AnchorBinding, Decision, Domain, Entity, Fact, Initiative
55
+ from .store import (
56
+ _FORMAT_MARKER_NAME,
57
+ _FORMAT_MARKER_PREFIX,
58
+ _MIGRATABLE_SCHEMA_VERSIONS,
59
+ _RELOADABLE_SCHEMA_VERSIONS,
60
+ )
61
+
62
+ # Violation codes — pinned constants (design ruling 2). The CLI's --json output and every
63
+ # test assert against these exact strings, never a free-form message.
64
+ PARSE_ERROR = "parse-error"
65
+ UNKNOWN_SCHEMA_VERSION = "unknown-schema-version"
66
+ BAD_VALIDITY_WINDOW = "bad-validity-window"
67
+ SUPERSEDED_WITHOUT_SUCCESSOR = "superseded-without-successor"
68
+ DANGLING_SUPERSEDES = "dangling-supersedes"
69
+ DANGLING_BINDING_ENTITY = "dangling-binding-entity"
70
+ DANGLING_FACT_SUPPORT = "dangling-fact-support"
71
+ DUPLICATE_ULID = "duplicate-ulid"
72
+ BAD_ARCHIVE_SEGMENT = "bad-archive-segment"
73
+ FILENAME_ID_MISMATCH = "filename-id-mismatch"
74
+
75
+ # Transition-layer codes (design ruling 2, "Transition layer" — see that section below).
76
+ ILLEGAL_FIELD_CHANGE = "illegal-field-change"
77
+ ILLEGAL_STATUS_JUMP = "illegal-status-jump"
78
+ VALID_TO_UNSET = "valid-to-unset"
79
+ VALID_TO_CHANGED = "valid-to-changed"
80
+ ILLEGAL_DELETION = "illegal-deletion"
81
+
82
+ # A store's committed ``format`` marker (see ``store._ensure_format_marker``) is only ever
83
+ # stamped with the running code's CURRENT ``SCHEMA_VERSION`` at write time — it is never
84
+ # rewritten by a mere reload. A store this code can actually OPEN (see
85
+ # ``store._RELOADABLE_SCHEMA_VERSIONS``/``_MIGRATABLE_SCHEMA_VERSIONS``) may therefore still
86
+ # carry an older marker; all three sets together are exactly "known to this running code",
87
+ # mirroring what ``Store.__init__`` itself tolerates without raising.
88
+ _KNOWN_SCHEMA_VERSIONS = frozenset(
89
+ {SCHEMA_VERSION} | _RELOADABLE_SCHEMA_VERSIONS | _MIGRATABLE_SCHEMA_VERSIONS
90
+ )
91
+
92
+ # Substring pydantic's wrapped ``ValueError`` carries for the ``Decision``/``Fact``
93
+ # validity-window ``model_validator`` (see schema.py) — see ``_check_temporal_dir``.
94
+ _VALIDITY_WINDOW_MSG = "valid_to must be >= valid_from"
95
+
96
+ # Both DecisionStatus.SUPERSEDED and DomainStatus.SUPERSEDED serialize to this same literal
97
+ # (see schema.py) — the supersedes-chain check is generic across decisions/facts/domains, so
98
+ # it compares against the raw string rather than importing either enum.
99
+ _SUPERSEDED = "superseded"
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class Violation:
104
+ """One integrity finding. ``path`` is always a string (a file path, or a synthetic
105
+ ``dir/<id>.json`` for an id that only exists inside an archive segment)."""
106
+
107
+ code: str
108
+ path: str
109
+ detail: str
110
+
111
+
112
+ def _violation(code: str, path: Path | str, detail: str) -> Violation:
113
+ return Violation(code=code, path=str(path), detail=detail)
114
+
115
+
116
+ # -- generic file loading -----------------------------------------------------------------
117
+
118
+
119
+ def _iter_json_files(dir_path: Path) -> list[Path]:
120
+ if not dir_path.is_dir():
121
+ return []
122
+ return sorted(dir_path.glob("*.json"))
123
+
124
+
125
+ def _load_raw_json(path: Path) -> tuple[object | None, Violation | None]:
126
+ """Read+parse one file as JSON. Never raises — a decode/read failure becomes a
127
+ :data:`PARSE_ERROR` violation instead, so one corrupt file never aborts the whole
128
+ snapshot pass."""
129
+ try:
130
+ text = path.read_text(encoding="utf-8")
131
+ except OSError as e:
132
+ return None, _violation(PARSE_ERROR, path, f"unreadable: {e}")
133
+ try:
134
+ return json.loads(text), None
135
+ except json.JSONDecodeError as e:
136
+ return None, _violation(PARSE_ERROR, path, f"invalid JSON: {e}")
137
+
138
+
139
+ def _check_filename_matches_id(path: Path, rid: str) -> Violation | None:
140
+ """The store always names a canonical record file after its OWN internal id
141
+ (``_write_decision_canonical`` et al. — ``<id>.json``, never anything else). A mismatch
142
+ is never legitimate: either a hand-rename, or a hand-copy that also needs its own
143
+ :data:`DUPLICATE_ULID` finding (see :func:`_check_duplicate_ulids`) — the two codes are
144
+ independent and can both fire for the same file."""
145
+ if path.stem != rid:
146
+ return _violation(
147
+ FILENAME_ID_MISMATCH, path, f"filename {path.stem!r} does not match record id {rid!r}"
148
+ )
149
+ return None
150
+
151
+
152
+ # -- per-directory checks ------------------------------------------------------------------
153
+
154
+
155
+ def _check_temporal_dir(
156
+ dir_path: Path, model: type[BaseModel]
157
+ ) -> tuple[dict[str, dict], dict[str, Path], dict[str, list[Path]], list[Violation]]:
158
+ """``decisions/`` or ``facts/``: both carry the ``valid_to >= valid_from``
159
+ ``model_validator`` (schema.py), which this function reports under its OWN code
160
+ (:data:`BAD_VALIDITY_WINDOW`) rather than lumping it into :data:`PARSE_ERROR` — pydantic
161
+ wraps that check into a ``ValidationError`` indistinguishable, by error TYPE, from a
162
+ missing/mistyped field, so the error MESSAGE is what disambiguates the two: a hand-edited
163
+ ``valid_from``/``valid_to`` produces exactly one error, and only that one, from a
164
+ model-level (not field-level) validator.
165
+
166
+ Returns ``(raw_by_id, path_by_id, all_paths_by_id, violations)``. ``path_by_id`` keeps
167
+ only the FIRST hot file seen for a given internal id — used by the cross-reference checks,
168
+ which need exactly one representative payload per id. ``all_paths_by_id`` keeps EVERY hot
169
+ file carrying that id, because a second hot file for the same id is itself a finding
170
+ (:data:`DUPLICATE_ULID` — see :func:`_check_duplicate_ulids`), never something to silently
171
+ overwrite. A record stays in ``raw_by_id`` even when it fails full model validation (either
172
+ code) as long as its JSON decodes and carries an ``id`` — its
173
+ ``status``/``supersedes``/``supports`` are still real, checkable data; only a file that
174
+ fails to decode at all contributes no id anywhere.
175
+ """
176
+ raw_by_id: dict[str, dict] = {}
177
+ path_by_id: dict[str, Path] = {}
178
+ all_paths_by_id: dict[str, list[Path]] = {}
179
+ violations: list[Violation] = []
180
+ for path in _iter_json_files(dir_path):
181
+ raw, err = _load_raw_json(path)
182
+ if err is not None:
183
+ violations.append(err)
184
+ continue
185
+ if not isinstance(raw, dict) or "id" not in raw:
186
+ violations.append(_violation(PARSE_ERROR, path, "not a record object (missing 'id')"))
187
+ continue
188
+ rid = raw["id"]
189
+ all_paths_by_id.setdefault(rid, []).append(path)
190
+ mismatch = _check_filename_matches_id(path, rid)
191
+ if mismatch is not None:
192
+ violations.append(mismatch)
193
+ if rid not in raw_by_id:
194
+ raw_by_id[rid] = raw
195
+ path_by_id[rid] = path
196
+ try:
197
+ model.model_validate(raw)
198
+ except ValidationError as e:
199
+ errors = e.errors()
200
+ if len(errors) == 1 and _VALIDITY_WINDOW_MSG in errors[0]["msg"]:
201
+ violations.append(_violation(BAD_VALIDITY_WINDOW, path, errors[0]["msg"]))
202
+ else:
203
+ violations.append(_violation(PARSE_ERROR, path, str(e)))
204
+ return raw_by_id, path_by_id, all_paths_by_id, violations
205
+
206
+
207
+ def _check_plain_dir(
208
+ dir_path: Path, model: type[BaseModel], id_field: str
209
+ ) -> tuple[dict[str, dict], dict[str, Path], dict[str, list[Path]], list[Violation]]:
210
+ """``domains/``, ``entities/``, ``initiatives/`` — no validity window, straightforward
211
+ schema validation. ``id_field`` is ``"domain_id"``/``"entity_id"``/``"id"``. See
212
+ :func:`_check_temporal_dir` for the ``path_by_id`` vs. ``all_paths_by_id`` split."""
213
+ raw_by_id: dict[str, dict] = {}
214
+ path_by_id: dict[str, Path] = {}
215
+ all_paths_by_id: dict[str, list[Path]] = {}
216
+ violations: list[Violation] = []
217
+ for path in _iter_json_files(dir_path):
218
+ raw, err = _load_raw_json(path)
219
+ if err is not None:
220
+ violations.append(err)
221
+ continue
222
+ if not isinstance(raw, dict) or id_field not in raw:
223
+ violations.append(
224
+ _violation(PARSE_ERROR, path, f"not a record object (missing {id_field!r})")
225
+ )
226
+ continue
227
+ rid = raw[id_field]
228
+ all_paths_by_id.setdefault(rid, []).append(path)
229
+ mismatch = _check_filename_matches_id(path, rid)
230
+ if mismatch is not None:
231
+ violations.append(mismatch)
232
+ if rid not in raw_by_id:
233
+ raw_by_id[rid] = raw
234
+ path_by_id[rid] = path
235
+ try:
236
+ model.model_validate(raw)
237
+ except ValidationError as e:
238
+ violations.append(_violation(PARSE_ERROR, path, str(e)))
239
+ return raw_by_id, path_by_id, all_paths_by_id, violations
240
+
241
+
242
+ def _check_bindings_dir(
243
+ dir_path: Path,
244
+ ) -> tuple[dict[str, list[dict]], dict[str, Path], list[Violation]]:
245
+ """``bindings/<record_id>.json``: a JSON list of ``{entity_id, tier, relation, weight}``
246
+ (see ``store._binding_identity_payload``) — ``record_id`` itself is implied by the
247
+ filename, not a key inside each item, so there is no internal id to compare it against
248
+ (no :data:`FILENAME_ID_MISMATCH` check here)."""
249
+ by_record: dict[str, list[dict]] = {}
250
+ path_by_record: dict[str, Path] = {}
251
+ violations: list[Violation] = []
252
+ for path in _iter_json_files(dir_path):
253
+ raw, err = _load_raw_json(path)
254
+ if err is not None:
255
+ violations.append(err)
256
+ continue
257
+ if not isinstance(raw, list):
258
+ violations.append(_violation(PARSE_ERROR, path, "bindings file must be a JSON list"))
259
+ continue
260
+ record_id = path.stem
261
+ items: list[dict] = []
262
+ for i, item in enumerate(raw):
263
+ payload = item if isinstance(item, dict) else {}
264
+ try:
265
+ AnchorBinding.model_validate({**payload, "record_id": record_id})
266
+ except ValidationError as e:
267
+ violations.append(_violation(PARSE_ERROR, f"{path}#{i}", str(e)))
268
+ continue
269
+ items.append(payload)
270
+ by_record[record_id] = items
271
+ path_by_record[record_id] = path
272
+ return by_record, path_by_record, violations
273
+
274
+
275
+ @dataclass
276
+ class _ArchiveIndex:
277
+ """Everything read out of ``archive/*.jsonl``. ``*_entries`` keeps EVERY occurrence
278
+ (path + payload) an id was seen in (unlike ``store._archived_records``'s dedup-to-first),
279
+ because :func:`_check_duplicate_ulids` needs to compare payloads across occurrences, not
280
+ just count them — see that function's docstring for the byte-identical exemption."""
281
+
282
+ decisions: dict[str, dict]
283
+ domains: dict[str, dict]
284
+ decision_entries: dict[str, list[tuple[Path, dict]]]
285
+ domain_entries: dict[str, list[tuple[Path, dict]]]
286
+ violations: list[Violation]
287
+
288
+
289
+ def _check_archive_dir(dir_path: Path) -> _ArchiveIndex:
290
+ """``archive/*.jsonl`` — one JSON object per line (see
291
+ ``docs/reference/store-format.md#archive-segments-sidegraph-compact`` and
292
+ ``store._archive_record_line``). A line that fails to parse is
293
+ :data:`BAD_ARCHIVE_SEGMENT`, scoped separately from :data:`PARSE_ERROR` (which is
294
+ reserved for the one-file-per-record canonical directories) since "this JSONL segment has
295
+ a bad line" and "this record file is corrupt" are different failure shapes worth
296
+ distinguishing in a report."""
297
+ decisions: dict[str, dict] = {}
298
+ domains: dict[str, dict] = {}
299
+ decision_entries: dict[str, list[tuple[Path, dict]]] = {}
300
+ domain_entries: dict[str, list[tuple[Path, dict]]] = {}
301
+ violations: list[Violation] = []
302
+ if not dir_path.is_dir():
303
+ return _ArchiveIndex(decisions, domains, decision_entries, domain_entries, violations)
304
+ for path in sorted(dir_path.glob("*.jsonl")):
305
+ try:
306
+ text = path.read_text(encoding="utf-8")
307
+ except OSError as e:
308
+ violations.append(_violation(BAD_ARCHIVE_SEGMENT, path, f"unreadable: {e}"))
309
+ continue
310
+ for lineno, raw_line in enumerate(text.splitlines(), start=1):
311
+ line = raw_line.strip()
312
+ if not line:
313
+ continue
314
+ try:
315
+ obj = json.loads(line)
316
+ except json.JSONDecodeError as e:
317
+ violations.append(_violation(BAD_ARCHIVE_SEGMENT, path, f"line {lineno}: {e}"))
318
+ continue
319
+ if not isinstance(obj, dict):
320
+ violations.append(
321
+ _violation(BAD_ARCHIVE_SEGMENT, path, f"line {lineno}: not a JSON object")
322
+ )
323
+ continue
324
+ record_type = obj.get("record_type")
325
+ payload = {k: v for k, v in obj.items() if k != "record_type"}
326
+ if record_type == "decision" and "id" in payload:
327
+ decisions.setdefault(payload["id"], payload)
328
+ decision_entries.setdefault(payload["id"], []).append((path, payload))
329
+ elif record_type == "domain" and "domain_id" in payload:
330
+ domains.setdefault(payload["domain_id"], payload)
331
+ domain_entries.setdefault(payload["domain_id"], []).append((path, payload))
332
+ # An unrecognized record_type is silently skipped -- forward-compat with a future
333
+ # record kind this version doesn't know how to load, mirroring
334
+ # store.py's own ``_archived_records`` tolerance. The line still parsed as JSON,
335
+ # so it is not a bad-archive-segment finding.
336
+ return _ArchiveIndex(decisions, domains, decision_entries, domain_entries, violations)
337
+
338
+
339
+ def _check_format_marker(store_dir: Path) -> list[Violation]:
340
+ """``<store_dir>/format`` — a single committed line, ``sidegraph-store <version>`` (see
341
+ ``store._ensure_format_marker``). Missing, malformed, or an unrecognized version are all
342
+ reported the same way: :data:`UNKNOWN_SCHEMA_VERSION`."""
343
+ marker = store_dir / _FORMAT_MARKER_NAME
344
+ if not marker.is_file():
345
+ return [_violation(UNKNOWN_SCHEMA_VERSION, marker, "format marker missing")]
346
+ try:
347
+ text = marker.read_text(encoding="utf-8").strip()
348
+ except OSError as e:
349
+ return [_violation(UNKNOWN_SCHEMA_VERSION, marker, f"unreadable: {e}")]
350
+ if not text.startswith(_FORMAT_MARKER_PREFIX):
351
+ return [_violation(UNKNOWN_SCHEMA_VERSION, marker, f"unrecognized format marker {text!r}")]
352
+ version = text[len(_FORMAT_MARKER_PREFIX) :]
353
+ if version not in _KNOWN_SCHEMA_VERSIONS:
354
+ return [_violation(UNKNOWN_SCHEMA_VERSION, marker, f"unknown schema_version {version!r}")]
355
+ return []
356
+
357
+
358
+ # -- cross-reference checks -----------------------------------------------------------------
359
+
360
+
361
+ def _check_supersedes(combined: dict[str, dict], path_by_id: dict[str, Path]) -> list[Violation]:
362
+ """Generic across decisions/facts/domains — mirrors the real write-path rule
363
+ (``store.add_decision``/``add_fact``/``supersede_domain``): a successor's ``supersedes``
364
+ must reference a record that exists (:data:`DANGLING_SUPERSEDES`), and a record whose
365
+ ``status`` is ``superseded`` must be the target of some OTHER record's ``supersedes``
366
+ (:data:`SUPERSEDED_WITHOUT_SUCCESSOR`) — the same "close + link, atomically" guarantee
367
+ ``add_decision``'s docstring describes. ``combined`` is the union of hot and archived
368
+ payloads for one record type (a superseded record's successor may itself have since been
369
+ compacted, so both pools count as "exists")."""
370
+ violations: list[Violation] = []
371
+ successors = {
372
+ payload["supersedes"] for payload in combined.values() if payload.get("supersedes")
373
+ }
374
+ for rid, payload in combined.items():
375
+ supersedes = payload.get("supersedes")
376
+ if supersedes and supersedes not in combined:
377
+ violations.append(
378
+ _violation(
379
+ DANGLING_SUPERSEDES,
380
+ path_by_id.get(rid, rid),
381
+ f"supersedes unknown record {supersedes!r}",
382
+ )
383
+ )
384
+ if payload.get("status") == _SUPERSEDED and rid not in successors:
385
+ violations.append(
386
+ _violation(
387
+ SUPERSEDED_WITHOUT_SUCCESSOR,
388
+ path_by_id.get(rid, rid),
389
+ f"status is superseded but no record's supersedes references {rid!r}",
390
+ )
391
+ )
392
+ return violations
393
+
394
+
395
+ def _check_binding_entities(
396
+ bindings_by_record: dict[str, list[dict]],
397
+ path_by_record: dict[str, Path],
398
+ entity_ids: set[str],
399
+ ) -> list[Violation]:
400
+ """Every ``bindings/<record_id>.json`` entry's ``entity_id`` must resolve to a real
401
+ ``entities/<id>.json`` (mirrors ``store.add_binding``'s own existence check)."""
402
+ violations: list[Violation] = []
403
+ for record_id, items in bindings_by_record.items():
404
+ path = path_by_record[record_id]
405
+ for item in items:
406
+ entity_id = item.get("entity_id")
407
+ if entity_id not in entity_ids:
408
+ violations.append(
409
+ _violation(
410
+ DANGLING_BINDING_ENTITY,
411
+ path,
412
+ f"binding references unknown entity {entity_id!r}",
413
+ )
414
+ )
415
+ return violations
416
+
417
+
418
+ def _check_fact_supports(
419
+ facts_raw: dict[str, dict], path_by_id: dict[str, Path], decision_ids: set[str]
420
+ ) -> list[Violation]:
421
+ """Every id in a ``Fact.supports`` list must resolve to a real decision (hot or archived)
422
+ — mirrors ``store.add_fact``'s own existence check."""
423
+ violations: list[Violation] = []
424
+ for fid, payload in facts_raw.items():
425
+ for did in payload.get("supports") or []:
426
+ if did not in decision_ids:
427
+ violations.append(
428
+ _violation(
429
+ DANGLING_FACT_SUPPORT, path_by_id[fid], f"supports unknown decision {did!r}"
430
+ )
431
+ )
432
+ return violations
433
+
434
+
435
+ def _check_duplicate_ulids(
436
+ hot_paths_by_id: dict[str, list[Path]],
437
+ archive_entries_by_id: dict[str, list[tuple[Path, dict]]],
438
+ ) -> list[Violation]:
439
+ """An id's canonical locations must be unique, with exactly ONE sanctioned exception
440
+ (design amendment, Task-2 review): two or more archive segments carrying byte-IDENTICAL
441
+ payloads for the same id are the store's own tolerated cross-branch merge shape
442
+ (independent ``sidegraph-compact`` runs on two branches, later merged — see
443
+ ``store._archived_records``'s "guaranteed byte-identical by design") and are exempt.
444
+ Every other multi-location shape is :data:`DUPLICATE_ULID`: two+ hot files sharing an id
445
+ (the store's file-per-record model allows at most one — ``hot_paths_by_id`` tracks every
446
+ occurrence precisely so this can't be masked by a silent last-write-wins dict merge), a
447
+ hot file coexisting with ANY archive copy, or archive segments whose payloads for the same
448
+ id actually DIFFER (corruption-shaped, not a legitimate merge, regardless of how it arose).
449
+ The exemption is byte-equality (payload-dict equality, mirroring
450
+ ``store._reload_index_from_canonical``'s own ``json.loads(...) != payload`` comparison),
451
+ never id-equality alone.
452
+ """
453
+ violations: list[Violation] = []
454
+ all_ids = set(hot_paths_by_id) | set(archive_entries_by_id)
455
+ for rid in sorted(all_ids):
456
+ hot_locs = hot_paths_by_id.get(rid, [])
457
+ archive_locs = archive_entries_by_id.get(rid, [])
458
+ if len(hot_locs) + len(archive_locs) <= 1:
459
+ continue
460
+ if not hot_locs:
461
+ payloads = [payload for _, payload in archive_locs]
462
+ if all(payload == payloads[0] for payload in payloads):
463
+ continue # sanctioned cross-branch archive merge -- exempt
464
+ locations = list(hot_locs) + [path for path, _ in archive_locs]
465
+ detail = "present in " + "; ".join(str(p) for p in locations)
466
+ violations.append(_violation(DUPLICATE_ULID, locations[0], detail))
467
+ return violations
468
+
469
+
470
+ # -- entry point ------------------------------------------------------------------------
471
+
472
+
473
+ def verify_snapshot(store_dir: str | Path) -> list[Violation]:
474
+ """Lint every canonical file under ``store_dir`` against the invariants ``store.py``'s
475
+ write API enforces at write time (see this module's docstring for why this reads the
476
+ files directly rather than opening a ``Store``). Returns ``[]`` for a clean store.
477
+
478
+ Raises ``NotADirectoryError`` if ``store_dir`` does not exist or is not a directory —
479
+ the CLI (``verify_main``) treats that as the operational-error exit path (1), distinct
480
+ from a violation (exit 2): an unreadable store dir is not itself an integrity finding
481
+ about the store's contents.
482
+ """
483
+ store_dir = Path(store_dir)
484
+ if not store_dir.is_dir():
485
+ raise NotADirectoryError(f"store directory not found: {store_dir}")
486
+
487
+ violations: list[Violation] = []
488
+ violations += _check_format_marker(store_dir)
489
+
490
+ decisions_raw, decision_paths, decision_all_paths, v = _check_temporal_dir(
491
+ store_dir / "decisions", Decision
492
+ )
493
+ violations += v
494
+ facts_raw, fact_paths, fact_all_paths, v = _check_temporal_dir(store_dir / "facts", Fact)
495
+ violations += v
496
+ domains_raw, domain_paths, domain_all_paths, v = _check_plain_dir(
497
+ store_dir / "domains", Domain, "domain_id"
498
+ )
499
+ violations += v
500
+ entities_raw, _entity_paths, entity_all_paths, v = _check_plain_dir(
501
+ store_dir / "entities", Entity, "entity_id"
502
+ )
503
+ violations += v
504
+ _initiatives_raw, _initiative_paths, initiative_all_paths, v = _check_plain_dir(
505
+ store_dir / "initiatives", Initiative, "id"
506
+ )
507
+ violations += v
508
+ bindings_by_record, binding_paths, v = _check_bindings_dir(store_dir / "bindings")
509
+ violations += v
510
+ archive = _check_archive_dir(store_dir / "archive")
511
+ violations += archive.violations
512
+
513
+ entity_ids = set(entities_raw)
514
+
515
+ # Decisions/domains: a superseded record's successor (or the dangling-supersedes target
516
+ # itself) may live only in the archive, so the cross-reference checks run against the
517
+ # union of hot + archived payloads; hot wins on an id present in both (mirrors
518
+ # ``store._reload_index_from_canonical``'s own hot-always-wins precedent) -- the
519
+ # id itself is ALSO reported separately as ``duplicate-ulid`` below.
520
+ combined_decisions = {**archive.decisions, **decisions_raw}
521
+ decision_path_by_id = {
522
+ **{did: entries[0][0] for did, entries in archive.decision_entries.items()},
523
+ **decision_paths,
524
+ }
525
+ violations += _check_supersedes(combined_decisions, decision_path_by_id)
526
+
527
+ # Facts are never archived (see docs/reference/store-format.md) -- hot only.
528
+ violations += _check_supersedes(facts_raw, fact_paths)
529
+
530
+ combined_domains = {**archive.domains, **domains_raw}
531
+ domain_path_by_id = {
532
+ **{dmid: entries[0][0] for dmid, entries in archive.domain_entries.items()},
533
+ **domain_paths,
534
+ }
535
+ violations += _check_supersedes(combined_domains, domain_path_by_id)
536
+
537
+ violations += _check_binding_entities(bindings_by_record, binding_paths, entity_ids)
538
+ violations += _check_fact_supports(facts_raw, fact_paths, set(combined_decisions))
539
+
540
+ # duplicate-ulid: decisions/domains compare hot against BOTH archive pools (the only two
541
+ # archived record types); facts/entities/initiatives are never archived, so only hot-hot
542
+ # duplication is possible for them.
543
+ violations += _check_duplicate_ulids(decision_all_paths, archive.decision_entries)
544
+ violations += _check_duplicate_ulids(domain_all_paths, archive.domain_entries)
545
+ violations += _check_duplicate_ulids(fact_all_paths, {})
546
+ violations += _check_duplicate_ulids(entity_all_paths, {})
547
+ violations += _check_duplicate_ulids(initiative_all_paths, {})
548
+
549
+ return violations
550
+
551
+
552
+ # -- transition layer (--against <git-ref>) ------------------------------------------------
553
+ #
554
+ # design/superpowers/specs/2026-07-11-ci-integrity-design.md ruling 2, "Transition layer":
555
+ # for each store file changed vs a git ref, classify the modification against the STORE'S
556
+ # OWN write rules rather than a blanket "committed files never change" lint (which would be
557
+ # wrong -- supersede legally closes a predecessor, ratify legally flips statuses, sync
558
+ # legally refreshes entity/domain engine-mapping fields, compact legally deletes a hot file
559
+ # once its content is durably archived). The mutable-field tables below are DERIVED from
560
+ # store.py's write methods, not invented -- see each table's own comment for exactly which
561
+ # write path was read. Two disagreements surfaced between the design ruling's plan and the
562
+ # actual code while deriving these tables (code wins, per the task brief):
563
+ #
564
+ # 1. ``Domain`` has NO ``valid_to`` field at all (see schema.py's ``Domain`` -- only
565
+ # ``Decision``/``Fact`` carry temporal validity). The design ruling's domain table lists
566
+ # ``valid_to`` as mutable; dropped here -- there is no such key to ever diff.
567
+ # 2. The design ruling's domain table names ``parent_slug`` as an immutable field; the
568
+ # actual schema field is ``parent_id`` (``parent_slug`` is an MCP-tool PARAMETER name in
569
+ # ``server.supersede_domain``/``_add_domain_impl``, resolved to ``parent_id`` before a
570
+ # ``Domain`` is ever constructed). Corrected here — immutable by omission either way
571
+ # (this module classifies via a mutable allow-list, so the exact immutable spelling only
572
+ # matters for this comment, not for behavior), but worth flagging as a real inaccuracy.
573
+ #
574
+ # Two further store.py facts worth flagging even though they don't change the tables below:
575
+ #
576
+ # - ``Entity.last_seen_node_id``/``last_seen_graph_version``/``last_seen_community`` and
577
+ # ``Domain.communities`` are *index-only* — ``_entity_identity_payload``/
578
+ # ``_domain_canonical_payload`` (store.py) strip them before a canonical file is ever
579
+ # written, so sync's routine refresh of these fields never touches a committed file at all
580
+ # (design §1: "sync never writes a committed file again" — confirmed by inspection: a
581
+ # freshly upserted entity's ``entities/<id>.json`` contains only ``entity_id``/
582
+ # ``canonical_name``/``kind``/``descriptor``; a domain's ``domains/<id>.json`` never
583
+ # contains ``communities``). They stay in the MUTABLE tables below anyway, matching the
584
+ # design ruling's intent (these are engine-mapping, never content, so a hand-edit touching
585
+ # them should be tolerated, not flagged) — but in practice no LEGITIMATE diff ever
586
+ # exercises that branch: a real committed file never carries these keys to begin with, so
587
+ # classifying them mutable is inert bookkeeping, not a load-bearing behavior.
588
+ # - ``Initiative`` has NO append-only guard at all (``Store.upsert_initiative`` is a bare,
589
+ # unconditional upsert — see store.py) — so field-level mutation is never checked for
590
+ # initiatives here (:func:`classify_transition` has no ``"initiative"`` kind;
591
+ # :func:`verify_against` handles the type directly, deletion-only — see its docstring).
592
+
593
+ # Decisions/facts (store.py ``add_decision`` :1261-1289, ``add_fact`` :1405-1435, ``ratify``
594
+ # :1962-1987, ``drop`` :1998-2014, ``ratify_fact``/``drop_fact`` :2026-2047): the only fields
595
+ # any write path ever changes on an EXISTING record are ``status`` (along a real transition)
596
+ # and ``valid_to`` (null -> a value, once, when a record closes). Everything else is written
597
+ # once, at creation, and never touched again by any of those methods.
598
+ _DECISION_STATUS_TRANSITIONS = frozenset(
599
+ {
600
+ ("proposed", "accepted"), # ratify
601
+ ("proposed", "rejected"), # drop
602
+ ("proposed", "superseded"), # add_decision/ratify closing a still-proposed predecessor
603
+ ("accepted", "superseded"), # add_decision/ratify closing an accepted predecessor
604
+ # No live write path sets DEPRECATED today -- store.py's own comment on
605
+ # _TERMINAL_DECISION_STATUSES calls it a "dead enum value ... included for
606
+ # forward-compat rather than silently left hot forever the day something does start
607
+ # setting it." Allowed here for that same documented forward-compat reason, not a
608
+ # gap this module papers over.
609
+ ("accepted", "deprecated"),
610
+ }
611
+ )
612
+ _FACT_STATUS_TRANSITIONS = frozenset(
613
+ {
614
+ ("proposed", "accepted"), # ratify_fact, or ratify's decision-supports cascade
615
+ ("proposed", "rejected"), # drop_fact, or drop's all-supporters-rejected cascade
616
+ ("proposed", "superseded"), # add_fact closing a still-proposed predecessor
617
+ ("accepted", "superseded"), # add_fact closing an accepted predecessor
618
+ # schema.py: "DEPRECATED unused for facts" -- no forward-compat carve-out here,
619
+ # unlike decisions above.
620
+ }
621
+ )
622
+ DECISION_MUTABLE_FIELDS = frozenset({"status", "valid_to"})
623
+ FACT_MUTABLE_FIELDS = frozenset({"status", "valid_to"})
624
+
625
+ # Domains (store.py ``add_domain`` :1626-1632 [creation only], ``supersede_domain``
626
+ # :1659-1678, ``ratify_domains`` :1880-1910, ``refresh_domain_communities`` :1822-1831):
627
+ # status moves along ratify_domains'/supersede_domain's transitions; ``communities`` is
628
+ # sync's engine-mapping refresh (see the module-level note above -- never actually present
629
+ # in the canonical file).
630
+ _DOMAIN_STATUS_TRANSITIONS = frozenset(
631
+ {
632
+ ("proposed", "accepted"), # ratify_domains(accept=...)
633
+ ("proposed", "dropped"), # ratify_domains(drop=...)
634
+ ("accepted", "dropped"), # ratify_domains(drop=...) -- extended beyond proposed-only
635
+ # (review round 3, design §6: cross-branch slug-conflict resolution)
636
+ ("proposed", "superseded"), # supersede_domain
637
+ ("accepted", "superseded"), # supersede_domain
638
+ # Fix-round review (Important-1): supersede_domain (store.py :1661-1674) flips
639
+ # old.status to SUPERSEDED with NO gate on old's current status at all -- unlike
640
+ # ratify_domains, it never checks old is proposed/accepted first. find_domain_by_slug
641
+ # deliberately keeps a DROPPED domain resolvable by slug ("accepted > proposed >
642
+ # dropped" preference order, store.py's own docstring) specifically so a dropped
643
+ # domain can still be superseded/revived later. Reviewer reproduced dropped ->
644
+ # superseded via the real MCP entry points (ratify drop, then supersede_domain) --
645
+ # code wins over my earlier {proposed,accepted}-only restriction.
646
+ ("dropped", "superseded"), # supersede_domain, from a previously-dropped domain
647
+ }
648
+ )
649
+ DOMAIN_MUTABLE_FIELDS = frozenset({"status", "communities"})
650
+
651
+ # Entities (store.py ``upsert_entity`` :1123-1150, ``_entity_identity_payload`` :295-303):
652
+ # the canonical file holds ONLY entity_id/canonical_name/kind/descriptor -- upsert_entity
653
+ # rewrites the file solely when the IDENTITY payload changes (i.e. ``descriptor`` -- a
654
+ # "moved" rebind, see ``sync._adopt``). ``entity_id``/``canonical_name``/``kind`` are minted
655
+ # once (or, for ``kind``, fixed at construction) and never rewritten by any write path.
656
+ ENTITY_MUTABLE_FIELDS = frozenset(
657
+ {"descriptor", "last_seen_node_id", "last_seen_graph_version", "last_seen_community"}
658
+ )
659
+
660
+ _KIND_DIRS = {
661
+ "decision": "decisions",
662
+ "fact": "facts",
663
+ "domain": "domains",
664
+ "entity": "entities",
665
+ "binding": "bindings",
666
+ }
667
+ _KIND_ID_FIELDS = {"decision": "id", "fact": "id", "domain": "domain_id", "entity": "entity_id"}
668
+
669
+
670
+ def _synthetic_path(kind: str, payload: dict) -> str:
671
+ """A best-guess ``<dir>/<id>.json`` path built from the payload alone —
672
+ :func:`classify_transition` is pure and never receives the real filesystem/git path.
673
+ In every legitimate case this matches the real path exactly (the store always names a
674
+ file after its own id — see :data:`FILENAME_ID_MISMATCH`); :func:`verify_against`
675
+ overwrites it with the actual changed git path regardless, so a mismatch here is never
676
+ user-visible."""
677
+ rid = payload.get(_KIND_ID_FIELDS[kind], "?")
678
+ return f"{_KIND_DIRS[kind]}/{rid}.json"
679
+
680
+
681
+ def _check_status_transition(
682
+ old: dict, new: dict, legal: frozenset[tuple[str, str]], path: str
683
+ ) -> list[Violation]:
684
+ old_status, new_status = old.get("status"), new.get("status")
685
+ if old_status == new_status or (old_status, new_status) in legal:
686
+ return []
687
+ return [
688
+ _violation(
689
+ ILLEGAL_STATUS_JUMP,
690
+ path,
691
+ f"status {old_status!r} -> {new_status!r} is not a real transition",
692
+ )
693
+ ]
694
+
695
+
696
+ def _check_valid_to_transition(old: dict, new: dict, path: str) -> list[Violation]:
697
+ """``valid_to``: null -> a value, exactly once, is the only legal move — mirrors every
698
+ ``add_decision``/``add_fact``/``ratify``/``drop`` call site's own
699
+ ``predecessor.valid_to = predecessor.valid_to or max(...)`` idiom (store.py): it is
700
+ ALWAYS an or-assignment onto a currently-``None`` field, never onto an already-set one.
701
+ """
702
+ old_vt, new_vt = old.get("valid_to"), new.get("valid_to")
703
+ if old_vt == new_vt:
704
+ return []
705
+ if old_vt is None:
706
+ return [] # null -> value: legal
707
+ if new_vt is None:
708
+ return [_violation(VALID_TO_UNSET, path, "valid_to reverted from a value to null")]
709
+ return [_violation(VALID_TO_CHANGED, path, f"valid_to changed {old_vt!r} -> {new_vt!r}")]
710
+
711
+
712
+ def _check_immutable_fields(
713
+ old: dict, new: dict, mutable: frozenset[str], path: str
714
+ ) -> list[Violation]:
715
+ """Every key present in ``old`` or ``new`` but not in ``mutable`` must be identical
716
+ across the two — covers a changed value, a field that disappeared, AND a field that
717
+ newly appeared (all three are "this immutable field changed"). One violation per
718
+ offending field (design ruling 2: "detail names the field"), in sorted key order for a
719
+ deterministic report."""
720
+ violations: list[Violation] = []
721
+ for key in sorted(set(old) | set(new)):
722
+ if key in mutable:
723
+ continue
724
+ if old.get(key) != new.get(key):
725
+ violations.append(_violation(ILLEGAL_FIELD_CHANGE, path, f"field {key!r} changed"))
726
+ return violations
727
+
728
+
729
+ def classify_transition(kind: str, old: dict | None, new: dict | None) -> list[Violation]:
730
+ """Classify one record's before/after payload against the store's OWN write-path rules
731
+ (design ruling 2, "Transition layer"). Pure: no filesystem/git access, so every case is
732
+ directly unit-testable without a git repo — :func:`verify_against` is the thin,
733
+ I/O-doing wrapper that feeds this real ``(old, new)`` pairs pulled from ``git show``/the
734
+ working tree.
735
+
736
+ ``kind`` is one of ``{"decision", "fact", "domain", "entity", "binding"}``.
737
+
738
+ ``old=None`` means the file was ADDED — always legal (a brand-new record can carry any
739
+ content; every write-path invariant on ITS shape is the snapshot layer's job, not this
740
+ one's).
741
+
742
+ ``new=None`` means the file was DELETED:
743
+
744
+ - ``kind == "binding"``: always legal, unconditionally (see the ``kind == "binding"``
745
+ branch below — bindings are never classified here at all, added/modified/deleted
746
+ alike; referential integrity of the new state is the snapshot layer's job).
747
+ - every other kind: this function ALONE always reports :data:`ILLEGAL_DELETION` — it
748
+ has no I/O access to check whether the id is safely archived. The one sanctioned
749
+ exception (a decision/domain compacted into an archive segment present in the new
750
+ tree) is applied by :func:`verify_against` as a post-hoc filter over this function's
751
+ raw output, never by this function itself. A pure test exercising ``new=None``
752
+ directly is therefore always the "id absent from any archive" scenario, by
753
+ construction.
754
+
755
+ Neither payload is ever a real filesystem/git path, so a returned :class:`Violation`'s
756
+ ``path`` is a best-effort ``<dir>/<id>.json`` guess (:func:`_synthetic_path`) that
757
+ :func:`verify_against` overwrites with the actual changed path.
758
+ """
759
+ if kind not in _KIND_DIRS:
760
+ raise ValueError(f"unknown kind {kind!r}")
761
+
762
+ if kind == "binding":
763
+ # Referential-only, machine-managed payload (design ruling 2): a dropped entry or a
764
+ # deleted bindings/<record_id>.json file is derived-community decay or record
765
+ # compaction, never flagged here regardless of shape.
766
+ return []
767
+
768
+ if old is None:
769
+ return [] # added -- always legal
770
+
771
+ if new is None:
772
+ return [
773
+ _violation(
774
+ ILLEGAL_DELETION,
775
+ _synthetic_path(kind, old),
776
+ "record deleted (no archive segment consulted at this pure layer)",
777
+ )
778
+ ]
779
+
780
+ path = _synthetic_path(kind, new)
781
+ if kind in ("decision", "fact"):
782
+ legal = _DECISION_STATUS_TRANSITIONS if kind == "decision" else _FACT_STATUS_TRANSITIONS
783
+ mutable = DECISION_MUTABLE_FIELDS if kind == "decision" else FACT_MUTABLE_FIELDS
784
+ return (
785
+ _check_status_transition(old, new, legal, path)
786
+ + _check_valid_to_transition(old, new, path)
787
+ + _check_immutable_fields(old, new, mutable, path)
788
+ )
789
+ if kind == "domain":
790
+ return _check_status_transition(
791
+ old, new, _DOMAIN_STATUS_TRANSITIONS, path
792
+ ) + _check_immutable_fields(old, new, DOMAIN_MUTABLE_FIELDS, path)
793
+ # entity
794
+ return _check_immutable_fields(old, new, ENTITY_MUTABLE_FIELDS, path)
795
+
796
+
797
+ class _UnparsableContent(Exception):
798
+ """One side of a changed file's diff failed to parse as a JSON object. Caught by
799
+ :func:`verify_against` to skip just that one file's transition check — the ALWAYS-ON
800
+ snapshot layer already reports a corrupt hot file as :data:`PARSE_ERROR`; the
801
+ transition layer piling on a second, less specific finding for the same root cause
802
+ would be noise, and one unparsable file must never abort the whole ``--against`` pass."""
803
+
804
+
805
+ def _run_git(
806
+ args: list[str], cwd: Path, *, timeout: float | None = None
807
+ ) -> subprocess.CompletedProcess[str]:
808
+ """``timeout`` (drift→supersede D1) bounds the subprocess for hook-path callers; the
809
+ default ``None`` is byte-for-byte today's behavior for every existing call site. A
810
+ ``TimeoutExpired`` maps onto the same ``ValueError`` contract as ``OSError`` — every
811
+ caller already treats that as "git unavailable"."""
812
+ try:
813
+ return subprocess.run(
814
+ ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout
815
+ )
816
+ except (OSError, subprocess.TimeoutExpired) as e:
817
+ raise ValueError(f"could not run git ({' '.join(args)}): {e}") from e
818
+
819
+
820
+ def _find_repo_root(store_dir: Path) -> Path:
821
+ """The git repository containing ``store_dir`` — may be an ancestor of it, not
822
+ ``store_dir`` itself. Raises ``ValueError`` (the CLI's operational-error exit path, 1 —
823
+ never a violation, design ruling 2: "Not-a-git-repo ... -> operational error") when
824
+ ``store_dir`` isn't inside any git working tree."""
825
+ result = _run_git(["rev-parse", "--show-toplevel"], cwd=store_dir)
826
+ if result.returncode != 0:
827
+ raise ValueError(f"{store_dir} is not inside a git repository: {result.stderr.strip()}")
828
+ return Path(result.stdout.strip()).resolve()
829
+
830
+
831
+ def _git_diff_name_status(repo_root: Path, ref: str, store_dir: Path) -> list[tuple[str, str]]:
832
+ """``git diff --no-renames --name-status <ref> -- <store_dir>``, run from ``repo_root``
833
+ (design ruling 2: "git plumbing ... ``git diff --name-status <ref> -- <store-dir>``").
834
+
835
+ ``--no-renames`` is deliberate: a renamed-but-identical record file would otherwise
836
+ report as one ``R100`` line instead of a delete+add pair — but the store's own
837
+ invariant is that a file is always named after its own id (:data:`FILENAME_ID_MISMATCH`),
838
+ so a genuine rename is never legitimate, and surfacing it as a deletion (illegal, absent
839
+ from any archive) alongside an unrelated addition is the MORE informative shape, not a
840
+ loss of information.
841
+
842
+ Compares ``ref`` against the CURRENT WORKING TREE (git's default for a single-ref
843
+ ``diff`` — no second ref, no ``--cached``), matching the CI use case this exists for:
844
+ ``origin/main`` vs. whatever the PR branch has checked out right now. Untracked files
845
+ never appear in ``git diff`` output at all (git diff semantics, not a bug here) — a
846
+ normal CI checkout has none.
847
+ """
848
+ result = _run_git(
849
+ ["diff", "--no-renames", "--name-status", ref, "--", str(store_dir)], cwd=repo_root
850
+ )
851
+ if result.returncode != 0:
852
+ raise ValueError(f"git diff against {ref!r} failed: {result.stderr.strip()}")
853
+ changed: list[tuple[str, str]] = []
854
+ for line in result.stdout.splitlines():
855
+ if not line.strip():
856
+ continue
857
+ status, _, path = line.partition("\t")
858
+ changed.append((status[:1], path))
859
+ return changed
860
+
861
+
862
+ def _git_show_json(repo_root: Path, ref: str, repo_path: str) -> dict:
863
+ result = _run_git(["show", f"{ref}:{repo_path}"], cwd=repo_root)
864
+ if result.returncode != 0:
865
+ raise ValueError(f"git show {ref}:{repo_path} failed: {result.stderr.strip()}")
866
+ try:
867
+ obj = json.loads(result.stdout)
868
+ except json.JSONDecodeError as e:
869
+ raise _UnparsableContent(str(e)) from e
870
+ if not isinstance(obj, dict):
871
+ raise _UnparsableContent(f"{repo_path}@{ref} is not a JSON object")
872
+ return obj
873
+
874
+
875
+ def _read_json_object(path: Path) -> dict:
876
+ try:
877
+ obj = json.loads(path.read_text(encoding="utf-8"))
878
+ except (OSError, json.JSONDecodeError) as e:
879
+ raise _UnparsableContent(str(e)) from e
880
+ if not isinstance(obj, dict):
881
+ raise _UnparsableContent(f"{path} is not a JSON object")
882
+ return obj
883
+
884
+
885
+ _DIR_TO_KIND = {
886
+ "decisions": "decision",
887
+ "facts": "fact",
888
+ "domains": "domain",
889
+ "entities": "entity",
890
+ "bindings": "binding",
891
+ "initiatives": "initiative",
892
+ }
893
+
894
+
895
+ def _classify_store_relative_path(store_relative: str) -> str | None:
896
+ """``<dir>/<id>.json`` (a path already relative to the store dir) -> a
897
+ :func:`classify_transition` ``kind``, or one of two kinds :func:`verify_against` handles
898
+ directly rather than through :func:`classify_transition` (see its docstring):
899
+ ``"initiative"`` (no append-only guard in store.py at all -- deletion-only check) and
900
+ ``"archive"`` (``archive/*.jsonl`` segments — write-once by design, see
901
+ ``store._write_archive_segment``'s docstring "Segments are write-once ... and this
902
+ always creates a brand-new file" and ``docs/reference/store-format.md``'s "immutable
903
+ segments"; a segment is a multi-record file, not a one-record-per-file canonical shape,
904
+ so it is never diffed field-by-field like a decision/fact/domain/entity — any change to
905
+ an already-published segment's bytes at all is illegal, full stop).
906
+
907
+ ``None`` for anything else changed under the store dir this layer doesn't classify: the
908
+ ``format`` marker, the gitignored ``index.db``, or any unexpected nesting."""
909
+ parts = store_relative.split("/")
910
+ if len(parts) == 2 and parts[0] == "archive" and parts[1].endswith(".jsonl"):
911
+ return "archive"
912
+ if len(parts) != 2 or not parts[1].endswith(".json"):
913
+ return None
914
+ return _DIR_TO_KIND.get(parts[0])
915
+
916
+
917
+ def verify_against(store_dir: str | Path, ref: str) -> list[Violation]:
918
+ """Classify every store file that differs between ``ref`` and the current working tree
919
+ against the store's OWN write-path rules (design ruling 2, "Transition layer") — the
920
+ CI-consumable half of ``sidegraph-verify --against <git-ref>``. Runs alongside
921
+ :func:`verify_snapshot` (which always runs — see ``cli.verify_main``); this function
922
+ alone never checks a single snapshot in isolation for schema/referential correctness,
923
+ only whether what CHANGED was legal.
924
+
925
+ Raises ``NotADirectoryError`` for a missing ``store_dir`` (mirrors
926
+ :func:`verify_snapshot`) and ``ValueError`` for every other operational failure —
927
+ ``store_dir`` not inside a git working tree, an unknown/unresolvable ``ref``, or ``git``
928
+ itself unavailable. All of these are the CLI's exit-1 path, never a violation (design
929
+ ruling 2: "Not-a-git-repo / unknown ref -> operational error").
930
+
931
+ Deletion legality (design ruling 2's "Deletions" bullet): :func:`classify_transition`
932
+ alone always reports a decision/domain deletion as illegal (it has no I/O access to the
933
+ archive). This function applies the one sanctioned exception — a deleted record whose id
934
+ appears in an ``archive/*.jsonl`` segment PRESENT IN THE NEW TREE (i.e. on disk right
935
+ now, under ``store_dir/archive`` — a legitimate ``sidegraph-compact``) — by dropping
936
+ that specific :data:`ILLEGAL_DELETION` finding after the fact. Entity/initiative
937
+ deletions have no such exception (no compact path exists for either kind) and stay
938
+ illegal unconditionally. Binding-file changes of any shape, including deletion, are
939
+ never classified at all here, matching :func:`classify_transition`. An already-
940
+ published ``archive/*.jsonl`` segment is write-once: a NEW segment is legal, but any
941
+ modification or deletion of an existing one is always illegal (see
942
+ :func:`_classify_store_relative_path`'s docstring) — checked directly here, not via
943
+ :func:`classify_transition` (a segment is a multi-record file, not a per-record shape).
944
+
945
+ ``Violation.path`` here is the changed file's path RELATIVE TO THE REPO ROOT (exactly as
946
+ ``git diff`` itself reports it) — not the ``<dir>/<id>.json``-relative-to-store guess
947
+ :func:`classify_transition` produces internally, and not the absolute filesystem path
948
+ :func:`verify_snapshot` uses; this function always corrects it to the real changed path.
949
+ """
950
+ store_dir = Path(store_dir)
951
+ if not store_dir.is_dir():
952
+ raise NotADirectoryError(f"store directory not found: {store_dir}")
953
+
954
+ repo_root = _find_repo_root(store_dir)
955
+ store_dir_abs = store_dir.resolve()
956
+ store_rel_path = store_dir_abs.relative_to(repo_root)
957
+ store_rel = "" if str(store_rel_path) == "." else store_rel_path.as_posix()
958
+
959
+ # Fix-round review (Important-2): the git subprocess always runs with cwd=repo_root
960
+ # (see _find_repo_root/_git_diff_name_status), NOT the caller's process cwd -- a
961
+ # pathspec built from a store_dir that was itself relative to the CALLER's cwd (e.g.
962
+ # the caller is running from a repo subdirectory and passed a relative --db) would be
963
+ # silently re-interpreted relative to repo_root instead, mis-scoping the diff to a path
964
+ # that doesn't exist and yielding an EMPTY diff -- a silently "clean" verdict over real
965
+ # tampering, the worst possible failure mode for a CI lint. git accepts an absolute
966
+ # pathspec regardless of cwd, so always pass the already-resolved absolute path here.
967
+ changed = _git_diff_name_status(repo_root, ref, store_dir_abs)
968
+
969
+ # The NEW tree's archive contents (on-disk, right now) -- what deletion legality
970
+ # consults. Only the id sets matter here; any BAD_ARCHIVE_SEGMENT-shaped finding is the
971
+ # always-on snapshot layer's job, not re-reported by this pass.
972
+ new_tree_archive = _check_archive_dir(store_dir_abs / "archive")
973
+ archived_ids = {
974
+ "decision": set(new_tree_archive.decisions),
975
+ "domain": set(new_tree_archive.domains),
976
+ }
977
+
978
+ violations: list[Violation] = []
979
+ for status, repo_path in changed:
980
+ if store_rel:
981
+ if not (repo_path == store_rel or repo_path.startswith(store_rel + "/")):
982
+ continue # defensive: git's own pathspec already scopes this
983
+ inner = repo_path[len(store_rel) + 1 :]
984
+ else:
985
+ inner = repo_path
986
+ kind = _classify_store_relative_path(inner)
987
+ if kind is None or kind == "binding":
988
+ continue
989
+
990
+ if kind == "initiative":
991
+ if status == "D":
992
+ violations.append(
993
+ _violation(
994
+ ILLEGAL_DELETION,
995
+ repo_path,
996
+ "initiative deleted (no compaction/archive path exists for initiatives)",
997
+ )
998
+ )
999
+ continue
1000
+
1001
+ if kind == "archive":
1002
+ # Segments are write-once (store._write_archive_segment: "Segments are
1003
+ # write-once ... and this always creates a brand-new file"). A new segment
1004
+ # (status "A") is the only legal shape; any other change to an already-
1005
+ # published segment's bytes is illegal -- deleted (no pruning path exists, so
1006
+ # this is never a legitimate compaction shape the way a hot record's deletion
1007
+ # can be) or modified (content diffed at the byte level, not field-by-field --
1008
+ # a segment is a multi-record file, not this layer's per-record comparison).
1009
+ if status == "D":
1010
+ violations.append(
1011
+ _violation(
1012
+ ILLEGAL_DELETION,
1013
+ repo_path,
1014
+ "archive segment deleted (write-once; no pruning path exists)",
1015
+ )
1016
+ )
1017
+ elif status != "A":
1018
+ violations.append(
1019
+ _violation(
1020
+ ILLEGAL_FIELD_CHANGE,
1021
+ repo_path,
1022
+ "write-once archive segment modified",
1023
+ )
1024
+ )
1025
+ continue
1026
+
1027
+ try:
1028
+ old = None if status == "A" else _git_show_json(repo_root, ref, repo_path)
1029
+ new = None if status == "D" else _read_json_object(repo_root / repo_path)
1030
+ except _UnparsableContent:
1031
+ continue # the always-on snapshot layer already reports this as parse-error
1032
+
1033
+ found = classify_transition(kind, old, new)
1034
+ if new is None and found and kind in ("decision", "domain"):
1035
+ rid_field = "id" if kind == "decision" else "domain_id"
1036
+ if old is not None and old.get(rid_field) in archived_ids[kind]:
1037
+ found = [] # legitimate sidegraph-compact -- archived in the new tree
1038
+ violations.extend(replace(v, path=repo_path) for v in found)
1039
+
1040
+ return violations