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/sync.py ADDED
@@ -0,0 +1,885 @@
1
+ """Sync / rebinding job (Stage 6): memory that stays anchored as code evolves.
2
+
3
+ After a Graphify rebuild, node ids shift. ``sync`` re-resolves every tracked concrete
4
+ entity with a deterministic ladder — exact (name+file) -> moved (unique name-only AND the
5
+ old path confirmed gone from disk; the descriptor follows the file) -> ambiguous ->
6
+ orphaned — healing or degrading tier-2 leaf bindings (never deleting) and refreshing the
7
+ durable->engine mapping. Gated on
8
+ ``graph_version`` vs the store's ``last_synced_graph_version`` meta stamp, so the lazy
9
+ read-path invocation is a cheap no-op in the common case. No LLM, no fuzzy matching
10
+ (deferred). See docs/guides/surviving-refactors.md for the rebinding mechanics.
11
+
12
+ Mind-model layer (see docs/concepts/mind-model.md): the same pass also refreshes each
13
+ accepted ``Domain``'s ``communities`` mapping from BOTH its ``path_prefixes`` (a directory
14
+ sweep) and its ``seed_anchors`` (durable entity anchors resolved via ``reader.resolve`` —
15
+ §2a amendment, design/superpowers/specs/2026-07-08-domain-onboarding-design.md: a curated
16
+ domain anchors to entities, not volatile Leiden community ids, so membership survives a
17
+ fresh clone/rebuild), conservative and abstain-on-ambiguity throughout (same style as
18
+ ``_observed_community_for_orphan``), flags domains that recomputed to empty, caps and flags
19
+ a claim that would newly cover more than 20% of all current communities rather than
20
+ writing it (Gate-5 blocker — see
21
+ ``_DOMAIN_CLAIM_CAP``/``_recompute_domain_communities``), and precomputes the SessionStart
22
+ TOC cache. ``refresh_domain_communities_now`` resolves a single domain immediately
23
+ (ratify-time, bypassing the ``graph_version`` gate) so a newly-accepted domain's membership
24
+ shows up without waiting for the next graph rebuild.
25
+
26
+ Git-native store wave (see docs/reference/store-format.md): the report also surfaces
27
+ ``Store.domain_slug_conflicts()`` — cross-branch domain-slug races — as
28
+ ``SyncReport.slug_conflicts``. Sync neither detects nor caches these itself, only calls
29
+ the store's method, which recomputes live on every call (a single indexed query).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ from dataclasses import dataclass
36
+ from datetime import UTC, datetime
37
+ from pathlib import Path, PurePosixPath
38
+
39
+ from pydantic import BaseModel
40
+
41
+ from .doctor import scan_code_drift
42
+ from .engine.reader import ANCHORABLE_FILE_TYPES, GraphifyReader, NodeRef
43
+ from .retrieval import DRIFT_CACHE_KEY, TOC_CACHE_KEY, build_toc, drifted_record_ids
44
+ from .schema import (
45
+ AnchorBinding,
46
+ DecisionStatus,
47
+ Descriptor,
48
+ Domain,
49
+ DomainStatus,
50
+ Entity,
51
+ matches_path_prefix,
52
+ )
53
+ from .store import VOLATILE_STALE_KEY, Store
54
+ from .verify import _run_git
55
+
56
+ LAST_SYNCED_KEY = "last_synced_graph_version"
57
+
58
+
59
+ class RebindOutcome(BaseModel):
60
+ entity_id: str
61
+ canonical_name: str
62
+ status: str # unchanged | rebound | moved | ambiguous | orphaned | error
63
+ node_id: str | None = None
64
+ detail: str | None = None
65
+ repointed: int = 0
66
+
67
+
68
+ def _set_leaf_status(entity_id: str, store: Store, status: str) -> None:
69
+ """Transition all tier-2 (leaf) bindings on an entity. Status change only, never delete."""
70
+ for b in store.bindings_for_entity(entity_id):
71
+ if b.tier == 2 and b.status != status:
72
+ b.status = status
73
+ store.add_binding(b)
74
+
75
+
76
+ def _repoint_communities(entity: Entity, new_community: str | None, store: Store) -> int:
77
+ """Re-point Tier-1 bindings when Leiden renumbered this entity's community.
78
+
79
+ Community ids are snapshot labels, not identities — every rebuild may renumber them
80
+ (dogfood: 149 -> 19). For each decision leaf-bound to this entity: ensure a live Tier-1
81
+ binding to the current community and flip the binding to the old baseline community to
82
+ orphaned (status-only; append-only preserved). Returns the number of decisions re-pointed.
83
+ """
84
+ old = entity.last_seen_community
85
+ if new_community is None or new_community == old:
86
+ return 0
87
+ old_entity = store.find_abstract_entity(f"community:{old}") if old is not None else None
88
+ new_entity = store.get_or_create_abstract_entity(f"community:{new_community}")
89
+
90
+ repointed = 0
91
+ for b in store.bindings_for_entity(entity.entity_id):
92
+ if b.tier != 2:
93
+ continue
94
+ repointed += 1
95
+ store.add_binding(
96
+ AnchorBinding(
97
+ record_id=b.record_id,
98
+ entity_id=new_entity.entity_id,
99
+ tier=1,
100
+ status="live",
101
+ )
102
+ )
103
+ if old_entity is not None:
104
+ for tb in store.bindings_for_record(b.record_id):
105
+ if (
106
+ tb.tier == 1
107
+ and tb.entity_id == old_entity.entity_id
108
+ and tb.status != "orphaned"
109
+ ):
110
+ tb.status = "orphaned"
111
+ store.add_binding(tb)
112
+ return repointed
113
+
114
+
115
+ def _observed_community_for_orphan(desc: Descriptor, reader: GraphifyReader) -> str | None:
116
+ """The community of an orphaned entity's surviving file, when unambiguous.
117
+
118
+ A symbol rename orphans the entity but usually leaves its file in place — and the same
119
+ rebuild may renumber every community (dogfood: 149 -> 19 -> 256 across three rebuilds).
120
+ The file's cluster is a deterministic, no-guessing locus for re-pointing: we are not
121
+ guessing which node the entity became, only where its code lives now.
122
+ """
123
+ if desc.file_path is None:
124
+ return None
125
+ communities = {
126
+ n.community for n in reader.nodes_in_file(desc.file_path) if n.community is not None
127
+ }
128
+ return communities.pop() if len(communities) == 1 else None
129
+
130
+
131
+ def _repoint_off_path(entity: Entity, community: str | None, store: Store) -> int:
132
+ """Re-point on a non-adopted rung: bindings + baseline only; the node mapping is
133
+ NEVER touched here (never guess the entity)."""
134
+ if community is None:
135
+ return 0
136
+ repointed = _repoint_communities(entity, community, store)
137
+ if entity.last_seen_community != community:
138
+ entity.last_seen_community = community
139
+ store.upsert_entity(entity)
140
+ return repointed
141
+
142
+
143
+ def _adopt(entity: Entity, node_id: str, version: str, store: Store, community: str | None) -> int:
144
+ repointed = _repoint_communities(entity, community, store)
145
+ entity.last_seen_node_id = node_id
146
+ entity.last_seen_graph_version = version
147
+ entity.last_seen_community = community
148
+ store.upsert_entity(entity)
149
+ _set_leaf_status(entity.entity_id, store, "live")
150
+ return repointed
151
+
152
+
153
+ def _resolve_repo_root(reader: GraphifyReader) -> Path | None:
154
+ """Best-effort git worktree root containing ``reader``'s ``graph.json``, used ONLY by
155
+ the "moved" rung below (see ``rebind_entity``) to check whether an entity's OLD
156
+ ``file_path`` is still sitting on disk before trusting a name-only hit as a move. Same
157
+ ``git rev-parse --show-toplevel`` call ``verify._find_repo_root``/
158
+ ``doctor.scan_code_drift`` already use to answer this same class of question ("is this
159
+ repo-relative path real"), but never raises: a graph outside any git working tree, or
160
+ git being unavailable, degrades to ``None`` (mirrors ``doctor.py``'s own
161
+ ``repo_root_failed`` tolerance) -- see the fail-closed consequence of that below.
162
+
163
+ Deliberately keyed off the READER's location, not the store's: entity ``file_path``
164
+ descriptors are relative to the checkout the graph was built from, and the store (a
165
+ directory of small JSON files) is routinely copied elsewhere for safe inspection --
166
+ e.g. to sync a copy against the real, uncopied graph without touching the committed
167
+ store -- which would make a store-rooted lookup report "not a git repo" and silently
168
+ fall back to treating every candidate as unverifiable even when the real checkout is
169
+ right there. Called once per real (non-skipped) sync pass, not per entity -- one
170
+ subprocess call, not O(entities).
171
+
172
+ A ``None`` return means the moved rung CANNOT verify anything for this whole pass --
173
+ every candidate that would otherwise adopt instead fails closed to orphaned (see the
174
+ rung's own comment for why unverifiable is treated as "not gone", not "gone"). This
175
+ matters for non-git corpora: ``engine/reader.py``'s own ``graph_version()`` already
176
+ treats "no ``built_at_commit``" as an ordinary, supported case (falls back to a content
177
+ hash), so a document corpus with no ``.git`` at all is expected to reach this code path
178
+ routinely, not just on a misconfigured git checkout.
179
+ """
180
+ try:
181
+ result = _run_git(["rev-parse", "--show-toplevel"], cwd=reader.path.parent, timeout=5.0)
182
+ except ValueError:
183
+ return None
184
+ if result.returncode != 0:
185
+ return None
186
+ return Path(result.stdout.strip()).resolve()
187
+
188
+
189
+ def rebind_entity(
190
+ entity: Entity, store: Store, reader: GraphifyReader, repo_root: Path | None = None
191
+ ) -> RebindOutcome:
192
+ """One entity through the deterministic rebind ladder.
193
+
194
+ ``repo_root`` backs the "moved" rung's file-still-on-disk guard (see the comment at
195
+ that rung) -- ``sync()`` resolves it once per pass via ``_resolve_repo_root`` and
196
+ threads it through every call. Direct callers (tests, one-off scripts) that omit it
197
+ get the FAIL-CLOSED default: the moved rung cannot verify the old path is gone, so it
198
+ never adopts, regardless of how unique or same-suffix the name-only hit is -- see the
199
+ rung's own comment for why "can't tell" and "confirmed gone" must not be conflated.
200
+ """
201
+ version = reader.graph_version()
202
+ # concrete entities always carry a descriptor — see Store.iter_concrete_entities, the
203
+ # only real-world source of entities passed here (tests construct the same invariant by
204
+ # hand). Narrowing once here, rather than at every `entity.descriptor` use below, keeps
205
+ # the ladder's control flow readable.
206
+ assert entity.descriptor is not None
207
+ desc = entity.descriptor
208
+ base = {"entity_id": entity.entity_id, "canonical_name": entity.canonical_name}
209
+
210
+ exact = reader.resolve(desc)
211
+ if exact.status == "resolved":
212
+ # GraphifyReader.resolve() only returns status="resolved" with node_id set (see
213
+ # engine/reader.py) — never both "resolved" and node_id=None.
214
+ assert exact.node_id is not None
215
+ unchanged = exact.node_id == entity.last_seen_node_id
216
+ repointed = _adopt(entity, exact.node_id, version, store, exact.community)
217
+ return RebindOutcome(
218
+ **base,
219
+ status="unchanged" if unchanged else "rebound",
220
+ node_id=exact.node_id,
221
+ repointed=repointed,
222
+ )
223
+ if exact.status == "ambiguous":
224
+ _set_leaf_status(entity.entity_id, store, "degraded")
225
+ repointed = _repoint_off_path(entity, exact.community, store)
226
+ return RebindOutcome(
227
+ **base,
228
+ status="ambiguous",
229
+ detail=f"{len(exact.candidates)} candidates",
230
+ repointed=repointed,
231
+ )
232
+
233
+ # Exact miss: the file may have moved — retry name-only and follow a unique hit,
234
+ # but only when BOTH hold:
235
+ # - same file type (a unique cross-suffix hit, e.g. a vanished code symbol colliding
236
+ # with a doc heading, is a collision, not a move, and is never adopted), and
237
+ # - the OLD path is CONFIRMED gone from disk. An exact miss has two distinct causes,
238
+ # not one: the file genuinely moved (old path gone, the name now lives elsewhere),
239
+ # or the old path was never in the graph's scope at all (e.g. a directory Graphify
240
+ # doesn't index) and the miss is permanent and has nothing to do with a move. Both
241
+ # look IDENTICAL to the graph — it has no nodes at the old path either way — so the
242
+ # graph alone cannot tell them apart; only the filesystem can (bug, observed live on
243
+ # this repo: design/whitepaper/figures/{render.py,README.md} — out of Graphify's
244
+ # scope, never moved — got silently re-anchored onto unrelated same-named files
245
+ # elsewhere in the repo).
246
+ #
247
+ # "Confirmed gone" requires repo_root AND the path's absence there — it is NOT the
248
+ # same as "unknown". When repo_root is unknown (direct rebind_entity callers with no
249
+ # repo_root, or a non-git store — see _resolve_repo_root), this rung FAILS CLOSED:
250
+ # it never adopts, same as a cross-suffix collision. Conflating "can't verify" with
251
+ # "verified gone" is exactly the bug this rung exists to fix, just one level up —
252
+ # the asymmetry is deliberate: an unadopted real move degrades to orphaned, which is
253
+ # visible (doctor/heal-anchors surfaces it) and repairable; a wrong adoption is
254
+ # silent and makes a record describe code it has nothing to do with. Same
255
+ # abstain-over-guess discipline this codebase already applies elsewhere (e.g.
256
+ # doc_import.py's ambiguous-anchor handling, the community-derivation cap above).
257
+ if desc.file_path is not None:
258
+ loose = reader.resolve(Descriptor(name=desc.name))
259
+ if loose.status == "resolved":
260
+ # same resolve() invariant as the exact-match branch above.
261
+ assert loose.node_id is not None
262
+ node = reader.get_node(loose.node_id)
263
+ new_file = node.file_path if node is not None else None
264
+ same_suffix = (
265
+ new_file is not None
266
+ and PurePosixPath(new_file).suffix == PurePosixPath(desc.file_path).suffix
267
+ )
268
+ old_confirmed_gone = repo_root is not None and not (repo_root / desc.file_path).exists()
269
+ if same_suffix and old_confirmed_gone:
270
+ entity.descriptor = Descriptor(name=desc.name, file_path=new_file)
271
+ repointed = _adopt(entity, loose.node_id, version, store, loose.community)
272
+ return RebindOutcome(
273
+ **base,
274
+ status="moved",
275
+ node_id=loose.node_id,
276
+ detail=f"{desc.file_path} -> {new_file}",
277
+ repointed=repointed,
278
+ )
279
+ # fall through to orphan: a cross-suffix hit (collision, not a move), the old
280
+ # path is still on disk (out-of-scope file that never moved), or repo_root is
281
+ # unknown and the old path's fate can't be verified either way (fail closed)
282
+ if loose.status == "ambiguous":
283
+ _set_leaf_status(entity.entity_id, store, "degraded")
284
+ repointed = _repoint_off_path(entity, loose.community, store)
285
+ return RebindOutcome(
286
+ **base,
287
+ status="ambiguous",
288
+ detail=f"{len(loose.candidates)} candidates",
289
+ repointed=repointed,
290
+ )
291
+
292
+ observed = _observed_community_for_orphan(desc, reader)
293
+ repointed = _repoint_off_path(entity, observed, store)
294
+ _set_leaf_status(entity.entity_id, store, "orphaned")
295
+ return RebindOutcome(**base, status="orphaned", repointed=repointed)
296
+
297
+
298
+ # Refresh claim cap (Gate-5 blocker): a path-derived candidate community set that would
299
+ # newly claim more than this fraction of ALL current communities is never written — see
300
+ # _recompute_domain_communities. Same 20% figure AND, since Option A (owner-approved
301
+ # 2026-09-14), the same ratio by construction as domains._PREFIX_BREADTH_CAP (the
302
+ # bootstrap-time derivation guard): both count the claimed set with the candidate's own
303
+ # community included, over the same total-community denominator. Applied here on the
304
+ # refresh side as defense in depth: bootstrap's guard keeps an AUTO-DERIVED rule from ever
305
+ # producing a candidate this cap would reject AGAINST THE GRAPH IT WAS DERIVED FROM — that
306
+ # guarantee says nothing about a LATER rebuild (a directory one community owns exclusively
307
+ # today can be shared by five tomorrow, and this cap rejecting the rule then is the design
308
+ # working, not a contradiction of bootstrap's own guard). A human can still hand-author
309
+ # (`sidegraph-domains add --path`) or hand-edit a rule that is overbroad from the start,
310
+ # and this cap must never trust ANY rule's blast radius at face value regardless of who
311
+ # wrote it or when — see the
312
+ # docstring below and docs/guides/naming-your-domains.md.
313
+ _DOMAIN_CLAIM_CAP = 0.2
314
+
315
+
316
+ def _recompute_domain_communities(
317
+ domain: Domain, nodes: list[NodeRef], current_community_ids: set[str], reader: GraphifyReader
318
+ ) -> tuple[list[str], int, dict[str, int] | None]:
319
+ """One domain's recomputed ``communities`` + the count of current anchorable nodes its
320
+ ``path_prefixes`` matched, + an overbroad-claim marker (§6, conservative
321
+ abstain-on-ambiguity style mirroring ``_observed_community_for_orphan``).
322
+
323
+ Two INDEPENDENT sources of direct evidence feed the final candidate set (§2a
324
+ amendment — design/superpowers/specs/2026-07-08-domain-onboarding-design.md):
325
+
326
+ - ``path_prefixes``: the communities that >= 1 current anchorable node under one of
327
+ those prefixes sits in (unchanged from before this amendment).
328
+ - ``seed_anchors``: each :class:`Descriptor` resolved via ``reader.resolve(desc)`` —
329
+ only a ``"resolved"`` (unambiguous) hit contributes its ``.community``; an
330
+ ``"unresolved"`` or ``"ambiguous"`` anchor is skipped outright (never guess which
331
+ community an ambiguous or vanished entity belongs to — same abstain discipline
332
+ ``rebind_entity`` uses for a decision's own leaf anchors).
333
+
334
+ ``_DOMAIN_CLAIM_CAP`` (20% of all current communities) applies to the ``path_prefixes``
335
+ contribution ONLY, never to ``seed_anchors`` (fix, found dogfooding on a big C++
336
+ monorepo: a domain with a broad path AND precise seed_anchors — e.g. `path_prefixes=
337
+ ["src"]` sweeping 385 of 899 communities alongside a handful of hand-picked anchors —
338
+ used to have its ENTIRE membership zeroed, including the anchors, because the old code
339
+ unioned both contributions first and capped the union. The cap exists to stop a FUZZY
340
+ directory sweep from silently claiming half the graph (Gate-5 blocker: a path rule that
341
+ legitimately-per-the-old-code expanded a domain from its own community to 146 of 304 —
342
+ silent, append-only-permanent corruption of every decision anchored to it since);
343
+ ``seed_anchors`` are deliberate, per-entity authoring — as trustworthy as a hand-picked
344
+ list — and must never be discarded just because an accompanying path rule turned out to
345
+ be too broad):
346
+
347
+ - If the ``path_prefixes`` candidate alone would newly claim more than
348
+ ``_DOMAIN_CLAIM_CAP`` of all current communities, that contribution is REJECTED and
349
+ the caller is told via the third tuple element (``{"matched": <path candidate
350
+ community count>, "total": <all current communities>}``, surfaced through
351
+ ``SyncReport.overbroad_domains`` — "your path rule is too broad", regardless of
352
+ whether anchors happened to rescue membership this pass) — but ``seed_anchors``'
353
+ resolved communities are ALWAYS still included.
354
+ - The final candidate is (the path contribution, if under the cap) UNION (the anchor
355
+ contribution, always). When non-empty, it is deterministic, direct evidence and fully
356
+ REPLACES the recorded set (never a blind union with stale ids) — the anchors/
357
+ stabilizer rules win over memory.
358
+ - When the final candidate is empty (no path_prefixes/seed_anchors, none of them match/
359
+ resolve anything today, or the path was capped and there are no anchors to rescue it),
360
+ fall back to survivors: the subset of the domain's previously recorded community ids
361
+ that still exist somewhere in the current graph — "add nothing speculative", never
362
+ guessing a NEW community without direct evidence. A capped path with no anchors keeps
363
+ the domain's previous mapping exactly as before this fix.
364
+
365
+ This cap is unconditional over the ``path_prefixes`` contribution — it applies
366
+ regardless of how that domain was authored (bootstrap-derived, ``add --path``,
367
+ ``propose_domains``, or hand-edited). Bootstrap's own derivation guard
368
+ (``domains._derive_path_prefixes``) already keeps an auto-derived rule from ever being
369
+ this broad against the graph it was derived from — a later rebuild can still grow a
370
+ directory's share past the cap, and this refresh guard capping the rule then is that
371
+ design working, not bootstrap's guarantee failing. A manually-authored rule is
372
+ explicit human intent and is never blocked at write time (see ``domains.py``'s module
373
+ docstring / ``docs/guides/naming-your-domains.md``) — refresh is the one place that
374
+ must still protect against a rule (of ANY provenance) whose blast radius turns out to
375
+ be this large once it meets the live graph, since a human authoring `--path tests` has
376
+ no way to know in advance
377
+ how many communities that will resolve to on a 300-community repo.
378
+
379
+ Single-community floor: a PATH candidate that resolves to exactly ONE community is
380
+ NEVER capped, regardless of ratio — one community cannot possibly "swallow" anything
381
+ else, so the cap has nothing to protect against there. Without this floor there is a
382
+ dead zone on small graphs: with few total communities, `1/total` can legitimately
383
+ exceed 20% for a perfectly ordinary single-community claim (e.g. 1 of 3), and capping
384
+ it would freeze the domain's stale pre-renumber mapping forever with no way to ever
385
+ heal (every future recompute re-derives the same single-community candidate and gets
386
+ capped again). On a tiny graph, a genuine MULTI-community PATH claim can still
387
+ legitimately trip the cap — when it does, the warning means re-scope the domain
388
+ (supersede) or narrow `path_prefixes`, not a signal that the cap itself is wrong for
389
+ that graph size. ``seed_anchors`` have no floor to worry about — they are never capped
390
+ at all, of any size.
391
+ """
392
+ matched_count = 0
393
+ prefix_candidate: set[str] = set()
394
+ if domain.path_prefixes:
395
+ matched = [
396
+ n
397
+ for n in nodes
398
+ if n.file_type in ANCHORABLE_FILE_TYPES
399
+ and n.file_path is not None
400
+ and any(matches_path_prefix(n.file_path, p) for p in domain.path_prefixes)
401
+ ]
402
+ matched_count = len(matched)
403
+ prefix_candidate = {n.community for n in matched if n.community is not None}
404
+
405
+ anchor_candidate: set[str] = set()
406
+ for desc in domain.seed_anchors:
407
+ result = reader.resolve(desc)
408
+ if result.status == "resolved" and result.community is not None:
409
+ anchor_candidate.add(result.community)
410
+
411
+ # Cap the PATH contribution alone (never the anchors): an overbroad sweep is rejected
412
+ # and flagged, but seed_anchors — precise, hand-picked evidence — are never discarded
413
+ # just because an accompanying path rule turned out to be too wide.
414
+ total = len(current_community_ids)
415
+ cap_hit: dict[str, int] | None = None
416
+ if (
417
+ prefix_candidate
418
+ and total > 0
419
+ and len(prefix_candidate) > 1
420
+ and len(prefix_candidate) / total > _DOMAIN_CLAIM_CAP
421
+ ):
422
+ cap_hit = {"matched": len(prefix_candidate), "total": total}
423
+ prefix_candidate = set() # rejected; anchor_candidate still counts below
424
+
425
+ candidate = sorted(prefix_candidate | anchor_candidate)
426
+ if candidate:
427
+ return candidate, matched_count, cap_hit
428
+
429
+ if cap_hit is not None:
430
+ # Path was overbroad and no seed_anchors rescued it: keep the previous mapping
431
+ # unchanged (never zero it, never trust an over-claim just because refresh
432
+ # computed it) — same never-guess outcome as before this fix.
433
+ return sorted(domain.communities), matched_count, cap_hit
434
+
435
+ survivors = sorted(cid for cid in domain.communities if cid in current_community_ids)
436
+ return survivors, matched_count, None
437
+
438
+
439
+ def _refresh_domains(
440
+ store: Store, reader: GraphifyReader
441
+ ) -> tuple[int, list[dict], list[dict], list[dict]]:
442
+ """Refresh ``communities`` on every ACCEPTED domain; return (refreshed count, empty
443
+ domains, overbroad domains, failures). A domain is flagged empty when its recomputed
444
+ set is empty AND its ``path_prefixes`` matched zero current nodes — a domain whose
445
+ stabilizer still points at live code is never flagged even if none of those nodes
446
+ carry a community label. A domain whose ``path_prefixes`` claim tripped the refresh
447
+ cap (see ``_recompute_domain_communities``/``_DOMAIN_CLAIM_CAP``) is always flagged
448
+ overbroad — "your path rule is too broad" — regardless of outcome: if
449
+ ``seed_anchors`` rescued the domain, its (anchor-only) communities are still written
450
+ and counted as refreshed; if nothing rescued it, its previous mapping was kept, not
451
+ zeroed, so it is never also flagged empty. Domains never orphan (they're owned); both
452
+ empty and overbroad are routed to a human via the report, status is never
453
+ auto-changed (never guess). A domain whose refresh raises is isolated (see the loop
454
+ below) and reported in ``failures`` instead of aborting the pass.
455
+ """
456
+ nodes = reader.list_nodes()
457
+ current_community_ids = {n.community for n in nodes if n.community is not None}
458
+
459
+ refreshed = 0
460
+ empty: list[dict] = []
461
+ overbroad: list[dict] = []
462
+ failures: list[dict] = []
463
+ # `reader.list_nodes()` above stays outside any try on purpose: a reader that
464
+ # constructed has already parsed its graph, and list_nodes is a list copy.
465
+ for domain in store.iter_domains(status=DomainStatus.ACCEPTED):
466
+ # The try encloses the WRITE as well as the recompute. refresh_domain_communities
467
+ # raises ValueError if the domain vanished mid-pass (concurrent supersede — it
468
+ # re-fetches by id) and sqlite3.OperationalError under cross-process lock
469
+ # contention, which the widened gate makes ordinary: a SessionStart hook and the
470
+ # MCP server now both heal after the same pull. Wrapping only the recompute lets
471
+ # those escape, abort the pass, skip every remaining domain, and appear in no
472
+ # report — the exact invisibility this task exists to kill.
473
+ try:
474
+ communities, matched, cap_hit = _recompute_domain_communities(
475
+ domain, nodes, current_community_ids, reader
476
+ )
477
+ if cap_hit is not None:
478
+ overbroad.append({"slug": domain.slug, "title": domain.title, **cap_hit})
479
+ if communities != sorted(domain.communities):
480
+ store.refresh_domain_communities(domain.domain_id, communities)
481
+ refreshed += 1
482
+ if not communities and matched == 0:
483
+ empty.append({"slug": domain.slug, "title": domain.title})
484
+ except Exception as exc: # one bad domain must not cost the others their heal
485
+ failures.append(
486
+ {
487
+ "slug": domain.slug,
488
+ "title": domain.title,
489
+ "error": f"{type(exc).__name__}: {exc}",
490
+ }
491
+ )
492
+ continue
493
+ return refreshed, empty, overbroad, failures
494
+
495
+
496
+ def refresh_domain_communities_now(
497
+ domain: Domain, store: Store, reader: GraphifyReader
498
+ ) -> tuple[list[str], dict | None]:
499
+ """Resolve ONE domain's ``communities`` immediately, bypassing sync's ``graph_version``
500
+ gate (§2a amendment) — called right after a domain is ACCEPTED (see
501
+ ``server._ratify_impl``) so ``seed_anchors``/``path_prefixes`` membership shows up in
502
+ ``drill_down`` the instant a human picks a set, instead of waiting for the next
503
+ graph-rebuild-triggered ``sync`` pass. Reuses ``_recompute_domain_communities`` — same
504
+ conservative, abstain-on-ambiguity resolution and the same ``_DOMAIN_CLAIM_CAP`` guard
505
+ as the full pass, capping only the ``path_prefixes`` contribution: an overbroad path
506
+ claim is rejected here exactly like it would be during a real sync, never written just
507
+ because this call is immediate — but a domain's ``seed_anchors`` resolve and get
508
+ written immediately regardless of the path cap, same as ``_refresh_domains``. No-op (no
509
+ write) when the recomputed value doesn't change anything, same convention as
510
+ ``Store.refresh_domain_communities``. Returns ``(communities, overbroad)`` — the
511
+ (possibly unchanged) communities list, and ``{"slug", "title", "matched", "total"}`` (or
512
+ ``None``) when the ``path_prefixes`` claim tripped ``_DOMAIN_CLAIM_CAP``. Callers wanting
513
+ the flag no longer need the full ``sync`` pass's ``SyncReport.overbroad_domains`` — this
514
+ immediate path returns it too, so the caller who just accepted the domain can tell the
515
+ author their path rule was rejected right away.
516
+ """
517
+ nodes = reader.list_nodes()
518
+ current_community_ids = {n.community for n in nodes if n.community is not None}
519
+ communities, _matched, cap_hit = _recompute_domain_communities(
520
+ domain, nodes, current_community_ids, reader
521
+ )
522
+ overbroad = (
523
+ {"slug": domain.slug, "title": domain.title, **cap_hit} if cap_hit is not None else None
524
+ )
525
+ if communities == sorted(domain.communities):
526
+ return sorted(domain.communities), overbroad
527
+ store.refresh_domain_communities(domain.domain_id, communities)
528
+ return communities, overbroad
529
+
530
+
531
+ @dataclass(frozen=True)
532
+ class DomainActivation:
533
+ """Result of :func:`activate_accepted_domain` (design D2's shared per-domain
534
+ post-accept step). ``resolved`` is the caller's cue for the pre-existing
535
+ never-fail-a-ratify heal flag (``VOLATILE_STALE_KEY``, already set by this function
536
+ when it applies — a caller never needs to set it itself); ``overbroad`` is
537
+ ``refresh_domain_communities_now``'s own cap-hit dict (or ``None``); ``error`` is the
538
+ exception text when the refresh raised, else ``None``. Deliberately renders NOTHING —
539
+ the "path rule too broad" sentence is a literal trigger phrase for the
540
+ ``sidegraph:heal-anchors`` skill and stays in the two human callers (``server.py``,
541
+ ``cli.py``), which build their own message from ``overbroad``.
542
+ """
543
+
544
+ resolved: bool
545
+ overbroad: dict | None
546
+ error: str | None
547
+
548
+
549
+ def activate_accepted_domain(
550
+ domain: Domain, store: Store, reader: GraphifyReader | None
551
+ ) -> DomainActivation:
552
+ """Resolve ONE just-accepted domain's membership immediately (design D2) — the shared
553
+ helper extracted, behavior-preserving, from ``server._ratify_impl``/``cli.ratify_main``
554
+ so MCP, CLI, and the two auto callers (capture's domain propose path,
555
+ ``domains.bootstrap_domains``) all resolve membership through the same code. Contract,
556
+ pinned so the extraction is provably pure:
557
+
558
+ - one domain per call, so per-domain failure isolation survives (a caller loops over
559
+ its own accepted-this-batch domain ids);
560
+ - ``reader is None`` -> ``resolved=False, error=None``, and schedules the heal
561
+ (``VOLATILE_STALE_KEY="1"``) without inventing a new human-facing error where there
562
+ was none before (the pre-existing "no graph to resolve against" branch);
563
+ - a raising ``refresh_domain_communities_now`` sets the SAME flag and returns the
564
+ exception text as ``error`` — the pre-existing never-fail-a-ratify rule: membership
565
+ could not be resolved, but the ratify/accept itself must never fail because of it;
566
+ - never rebuilds the ``TOC_CACHE_KEY`` cache — the once-per-batch rebuild stays at the
567
+ caller (``server.py``, ``cli.py``, and both auto callers rebuild once at the end of
568
+ their own batch), so accepting N domains in one pass never pays N ``build_toc`` calls.
569
+ # see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D2
570
+ """
571
+ if reader is None:
572
+ store.set_meta(VOLATILE_STALE_KEY, "1")
573
+ return DomainActivation(resolved=False, overbroad=None, error=None)
574
+ try:
575
+ _communities, overbroad = refresh_domain_communities_now(domain, store, reader)
576
+ except Exception as e: # never fail a ratify because membership could not be resolved
577
+ store.set_meta(VOLATILE_STALE_KEY, "1")
578
+ return DomainActivation(resolved=False, overbroad=None, error=str(e))
579
+ return DomainActivation(resolved=True, overbroad=overbroad, error=None)
580
+
581
+
582
+ class SyncReport(BaseModel):
583
+ from_version: str | None
584
+ to_version: str
585
+ skipped: bool = False
586
+ outcomes: list[RebindOutcome] = []
587
+ stale_decisions: list[dict] = [] # {"id": ..., "title": ...}
588
+ domains_refreshed: int = 0
589
+ empty_domains: list[dict] = [] # {"slug": ..., "title": ...}
590
+ overbroad_domains: list[dict] = [] # {"slug", "title", "matched", "total"} — see
591
+ # _DOMAIN_CLAIM_CAP; the path_prefixes contribution was dropped (any seed_anchors
592
+ # still applied, so a rescued domain IS written); the path is flagged as noise
593
+ slug_conflicts: list[dict] = [] # {"slug": ..., "domain_ids": [...]} — design §6's
594
+ # cross-branch slug race; see
595
+ # Store.domain_slug_conflicts (computed live on
596
+ # every call, not cached)
597
+ domain_failures: list[dict] = [] # {"slug", "title", "error"} — a domain whose refresh
598
+ # raised. Isolated per domain so one malformed seed cannot cost every other domain its
599
+ # heal, and reported because both lazy-sync callers suppress exceptions: a failure that
600
+ # is not in this list is invisible, and the store silently never heals.
601
+
602
+ def counts(self) -> dict[str, int]:
603
+ out: dict[str, int] = {}
604
+ for o in self.outcomes:
605
+ out[o.status] = out.get(o.status, 0) + 1
606
+ return out
607
+
608
+
609
+ def sync(store: Store, reader: GraphifyReader, force: bool = False) -> SyncReport:
610
+ """Full rebind pass, gated on graph_version AND the volatile-reload flag. Stamps
611
+ last_synced, and clears the reload flag, only on completion.
612
+
613
+ Entities that error during rebind are not retried until the next graph-version change
614
+ (or force=True) — the stamp records a completed visit, not universal success. The gate
615
+ below skips the rebind ladder itself when the version already matches, the store's
616
+ volatile state isn't cold (`VOLATILE_STALE_KEY`), and `force` wasn't passed — but the
617
+ gate does NOT skip the TOC cache refresh: a skipped pass still rewrites `toc_cache` when
618
+ at least one accepted domain exists, since that cache can go stale from content-only
619
+ changes the graph never sees (see the gate's own comment below).
620
+ """
621
+ to_version = reader.graph_version()
622
+ from_version = store.get_meta(LAST_SYNCED_KEY)
623
+ # A canonical reload (pull, merge, branch switch, hand edit — even a bare touch, since
624
+ # the digest hashes mtime_ns) resets every derived table but deliberately PRESERVES the
625
+ # meta table, so graph_version alone cannot see that volatile state went cold. This is
626
+ # the flag git-native design §3 introduced for exactly that event ("the next
627
+ # sidegraph-sync / lazy sync rebuilds volatile state") — set on every reload since, and
628
+ # read here for the first time.
629
+ volatile_stale = store.get_meta(VOLATILE_STALE_KEY) == "1"
630
+ if to_version == from_version and not volatile_stale and not force:
631
+ # TOC cache is volatile state (§5/§6), and `sync` is the store's one volatile-refresh
632
+ # verb (see docs/reference/store-format.md) — a content-only change (e.g. `add_decision`
633
+ # bound to an already-accepted domain, via capture or import) never moves
634
+ # graph_version, so the version gate above must not ALSO gate the cache refresh: doing
635
+ # so left the cache stale until an unrelated domain accept/drop happened to rebuild it
636
+ # (cli.py/server.py's own explicit ratify-time refresh), which real sessions hit (fix-
637
+ # wave B, B6). `build_toc` is store-only (no reader dependency for its fields today) and
638
+ # cheap, so it's safe to recompute on every sync call; gated on "at least one accepted
639
+ # domain exists" purely to avoid a pointless write when there is nothing to refresh.
640
+ if next(store.iter_domains(status=DomainStatus.ACCEPTED), None) is not None:
641
+ store.set_meta(TOC_CACHE_KEY, json.dumps(build_toc(store, reader)))
642
+ return SyncReport(from_version=from_version, to_version=to_version, skipped=True)
643
+
644
+ outcomes: list[RebindOutcome] = []
645
+ # Resolved once for the whole pass (one subprocess call, not one per entity) and
646
+ # threaded through every rebind_entity call — see _resolve_repo_root / the moved
647
+ # rung's own comment for what this guards against and how it degrades when unknown.
648
+ repo_root = _resolve_repo_root(reader)
649
+ for entity in store.iter_concrete_entities():
650
+ try:
651
+ outcomes.append(rebind_entity(entity, store, reader, repo_root))
652
+ except Exception as e: # one bad entity must not abort the pass
653
+ outcomes.append(
654
+ RebindOutcome(
655
+ entity_id=entity.entity_id,
656
+ canonical_name=entity.canonical_name,
657
+ status="error",
658
+ detail=str(e),
659
+ )
660
+ )
661
+
662
+ domains_refreshed, empty_domains, overbroad_domains, domain_failures = _refresh_domains(
663
+ store, reader
664
+ )
665
+
666
+ stale: list[dict] = []
667
+ for d in store.iter_decisions():
668
+ if d.status in (DecisionStatus.SUPERSEDED, DecisionStatus.REJECTED):
669
+ continue
670
+ # Leaf-based: surviving Tier-1/Tier-0 bindings keep the decision retrievable
671
+ # (the escalation), but do not vouch for the claim — the concrete code is gone.
672
+ leaves = [b for b in store.bindings_for_record(d.id) if b.tier == 2]
673
+ if leaves and all(b.status == "orphaned" for b in leaves):
674
+ stale.append({"id": d.id, "title": d.title})
675
+
676
+ store.set_meta(LAST_SYNCED_KEY, to_version)
677
+ # Cleared HERE, beside the version stamp and BEFORE build_toc: volatile state has been
678
+ # rebuilt by this point (the ladder, _refresh_domains and the stale scan all ran), and
679
+ # the TOC is a render of that state, not part of it. Clearing after build_toc instead
680
+ # let a persistent TOC failure leave the half-state "version stamped but flag set",
681
+ # which the gate below reads as "heal me" on every single retrieval call.
682
+ store.set_meta(VOLATILE_STALE_KEY, "0")
683
+ # TOC precompute (§5/§6): recomputed AFTER the domain-community refresh above and the
684
+ # last_synced stamp, so it reflects this pass's own updates; written on every completed
685
+ # (non-skipped) pass, fresh or forced — a skipped pass returns above and never reaches
686
+ # here, leaving the cache untouched.
687
+ store.set_meta(TOC_CACHE_KEY, json.dumps(build_toc(store, reader)))
688
+ return SyncReport(
689
+ from_version=from_version,
690
+ to_version=to_version,
691
+ outcomes=outcomes,
692
+ stale_decisions=stale,
693
+ domains_refreshed=domains_refreshed,
694
+ empty_domains=empty_domains,
695
+ overbroad_domains=overbroad_domains,
696
+ slug_conflicts=store.domain_slug_conflicts(),
697
+ domain_failures=domain_failures,
698
+ )
699
+
700
+
701
+ # Outcome statuses worth a human's attention -- "unchanged" (nothing happened) and
702
+ # "rebound" (same name+file_path descriptor match as last sync, but the resolved node id
703
+ # changed -- the mapping just heals itself) are noise; everything else is a heads-up. One
704
+ # filter, two consumers: ``report_as_dict``'s "outcomes" field below (shared by both
705
+ # ``sidegraph-sync --json`` and the ``sync_anchors`` MCP tool) and ``sidegraph-sync``'s own
706
+ # prose printer (``cli.sync_main``).
707
+ _SYNC_OUTCOME_NOTEWORTHY = ("moved", "orphaned", "ambiguous", "error")
708
+
709
+ # The subset of ``_SYNC_OUTCOME_NOTEWORTHY`` that makes ``--check``/``report_has_findings``
710
+ # fail (design/superpowers/specs/2026-07-11-ci-live-findings-design.md ruling 1). "moved" is
711
+ # noteworthy (surfaced in "outcomes") but not itself a finding -- the mapping healed itself,
712
+ # nothing needs a human's hand. "orphaned"/"ambiguous" are ALSO informational-only, not a
713
+ # finding: a live GitHub Actions experiment showed a legitimate rename+heal (triage adds a
714
+ # live anchor to the decision) leaves the renamed-away entity's own leaf orphaned for good --
715
+ # an append-only store has no retirement path -- so gating on the outcome kept the healing
716
+ # PR, and the default branch after it merged, red forever. When an orphaned/ambiguous anchor
717
+ # actually costs reachability (it was a decision's ONLY live tier-2 leaf), the decision goes
718
+ # stale and ``stale_decisions`` already fires -- the failure signal is redundant where it
719
+ # matters and harmful (permanently red) where it doesn't. Only "error" (the rebind ladder
720
+ # itself raised on an entity) stays a genuine failing outcome.
721
+ _FINDING_OUTCOME_STATUSES = ("error",)
722
+
723
+
724
+ def report_as_dict(report: SyncReport) -> dict:
725
+ """The shared report shape both ``sidegraph-sync --json`` (``cli.sync_main``) and the
726
+ ``sync_anchors`` MCP tool (``server._sync_anchors_impl``) return -- one dict shape, two
727
+ callers (design/superpowers/specs/2026-07-11-ci-integrity-design.md ruling 1).
728
+
729
+ ``synced`` is ``False`` when the pass was skipped outright (``graph_version``
730
+ unchanged, no ``force``) -- every OTHER field is then an EMPTY default (``outcomes:
731
+ []``, ``counts: ""``, ``repointed: 0``, ``stale_decisions: []``, ``empty_domains:
732
+ []``, ``overbroad_domains: []``, ``slug_conflicts: []``, ``domains_refreshed: 0``,
733
+ ``domain_failures: []``) from a fresh, un-run ``SyncReport(skipped=True)`` -- NOT a
734
+ prior (possibly stale) report --
735
+ so a caller must never read a skipped pass as "everything's clean" on its own;
736
+ ``report_has_findings`` below happens to still read a skipped dict as clean (nothing to
737
+ find), which is exactly the "version-skip is exit 0" contract ``--check`` wants.
738
+
739
+ ``outcomes`` carries only entities worth a human's attention -- moved/orphaned/
740
+ ambiguous/error -- filtered via ``_SYNC_OUTCOME_NOTEWORTHY``, never the "unchanged"/
741
+ "rebound" majority, same filter ``sidegraph-sync``'s own printer applies. ``counts`` is
742
+ ``report.counts()`` rendered as a string (e.g. ``"{'unchanged': 3}"``), ``""`` when
743
+ nothing is tracked yet. ``repointed`` sums EVERY outcome's ``repointed``, not just the
744
+ filtered ones.
745
+ """
746
+ outcomes = [
747
+ {"status": o.status, "canonical_name": o.canonical_name, "detail": o.detail}
748
+ for o in report.outcomes
749
+ if o.status in _SYNC_OUTCOME_NOTEWORTHY
750
+ ]
751
+ counts = report.counts()
752
+ return {
753
+ "synced": not report.skipped,
754
+ "from_version": report.from_version,
755
+ "to_version": report.to_version,
756
+ "counts": str(counts) if counts else "",
757
+ "repointed": sum(o.repointed for o in report.outcomes),
758
+ "outcomes": outcomes,
759
+ "stale_decisions": report.stale_decisions,
760
+ "empty_domains": report.empty_domains,
761
+ "overbroad_domains": report.overbroad_domains,
762
+ "slug_conflicts": report.slug_conflicts,
763
+ "domains_refreshed": report.domains_refreshed,
764
+ "domain_failures": report.domain_failures,
765
+ }
766
+
767
+
768
+ def report_has_findings(d: dict) -> bool:
769
+ """True iff a ``report_as_dict`` dict has an attention finding -- design/superpowers/
770
+ specs/2026-07-11-ci-live-findings-design.md ruling 1: an ``error`` outcome, OR a
771
+ non-empty ``stale_decisions``, OR a non-empty ``slug_conflicts``, OR a non-empty
772
+ ``domain_failures``. ``orphaned``/``ambiguous`` outcomes and ``empty_domains``/
773
+ ``overbroad_domains`` are deliberately excluded -- informational, never fail the check
774
+ on their own. The orphaned/ambiguous exclusion is the live-experiment refinement: a
775
+ legitimate rename+heal leaves the renamed-away entity's leaf orphaned for good (no
776
+ retirement path in an append-only store), and that residue must not red-flag a healing
777
+ PR -- or the default branch after it merges -- forever; when an orphaned/ambiguous
778
+ anchor actually costs reachability (a decision's only live anchor), the decision goes
779
+ stale and ``stale_decisions`` already fires, so the failure signal is covered there
780
+ instead. Drives ``sidegraph-sync --check``'s exit code (2 on True, 0 on False); a
781
+ skipped-pass dict (``synced: False``, everything else empty) always reads False, so a
782
+ version-skip is exit 0, same as a clean report.
783
+ """
784
+ if any(o["status"] in _FINDING_OUTCOME_STATUSES for o in d["outcomes"]):
785
+ return True
786
+ # domain_failures joins the finding set while orphaned/ambiguous stay out, and the
787
+ # 2026-07-11 exclusion is the reason why: those leave PERMANENT residue in an
788
+ # append-only store (a renamed-away leaf has no retirement path), so gating on them
789
+ # keeps CI red forever on a healthy repo. A domain_failure is the opposite shape — it
790
+ # exists only while a refresh actually raises. But unlike an ordinary per-entity retry,
791
+ # it does NOT reliably disappear once the seed/domain/graph is fixed: a completed pass
792
+ # (this one) clears VOLATILE_STALE_KEY and stamps last_synced regardless of whether this
793
+ # domain's own refresh succeeded, so the NEXT pass is gated on graph_version again and
794
+ # never re-attempts an unfixed domain until the graph version moves or a caller passes
795
+ # `--force`/`force=True`. Same class as an `error` outcome for `--check` purposes; fix
796
+ # the cause, then re-run with `--force` to confirm it cleared.
797
+ return bool(d["stale_decisions"] or d["slug_conflicts"] or d["domain_failures"])
798
+
799
+
800
+ def maybe_sync(store: Store, reader: GraphifyReader | None) -> SyncReport | None:
801
+ """Lazy read-path trigger: no reader -> None; else sync (self-skips on version match).
802
+
803
+ Callers wrap this best-effort — a sync failure must never break retrieval or startup.
804
+ """
805
+ if reader is None:
806
+ return None
807
+ return sync(store, reader)
808
+
809
+
810
+ def refresh_code_drift_cache(
811
+ store: Store, *, repo_root: Path | None = None, deadline: float = 10.0
812
+ ) -> int | None:
813
+ """Rescan code drift and merge the result into the :data:`~sidegraph.retrieval.
814
+ DRIFT_CACHE_KEY` meta cache — the write half of the drift→supersede affordance (D2).
815
+
816
+ Placed in this module because hooks already import it and it writes derived meta
817
+ today (the TOC cache) — by-convention placement; ``sync()`` deliberately does NOT
818
+ call this (the SessionStart hook runs ``maybe_sync`` first, and a second scan there
819
+ would double the git cost for nothing).
820
+
821
+ ALWAYS rescans — no head-stamp no-op: "same HEAD ⇒ no new drift" is false under
822
+ ``add_anchors`` (bindings-only write), sync rebinds flipping ``orphaned → live``,
823
+ and backward HEAD movement. Cost is bounded by ``deadline`` (a TOTAL scan budget,
824
+ see ``scan_code_drift``).
825
+
826
+ Merge semantics, per capture commit (spec round-2 N1 — a partial git failure must
827
+ neither erase real markers nor freeze the cache forever):
828
+
829
+ - inactive scan (nothing commit-stamped and anchored) → return ``0``, write nothing;
830
+ - unscanned (repo root resolution failed — no batch ran) → leave any existing cache
831
+ byte-untouched, return ``None``. A failed HEAD resolution alone does NOT discard a
832
+ scan whose batches ran (review M-2: ``head`` is provenance only and gates nothing
833
+ — the cache is written with ``"head": null``);
834
+ - otherwise: a successful batch REPLACES its commit's entry (an empty list is a
835
+ normal write — that is how markers clear once a repair supersede plus its commit
836
+ land); a failed batch RETAINS the previous cache's entry for its commit, if any;
837
+ commits absent from the scan's batch set drop out entirely (their records left
838
+ the live+stamped+anchored join).
839
+
840
+ Returns the live-filtered flattened count (``drifted_record_ids`` — the one filter
841
+ definition, shared with the render side). Never raises: any unexpected failure
842
+ returns ``None`` with the cache untouched (hook-caller contract).
843
+ # see design/superpowers/specs/2026-07-30-drift-supersede-affordance-design.md (D2)
844
+ """
845
+ try:
846
+ scan = scan_code_drift(store.path, repo_root, deadline=deadline)
847
+ if not scan.active:
848
+ return 0
849
+ if scan.repo_root_failed:
850
+ return None
851
+
852
+ prev_by_commit: dict[str, list[str]] = {}
853
+ raw = store.get_meta(DRIFT_CACHE_KEY)
854
+ if raw is not None:
855
+ try:
856
+ prev = json.loads(raw)
857
+ if isinstance(prev, dict) and isinstance(prev.get("by_commit"), dict):
858
+ prev_by_commit = {
859
+ c: [r for r in ids if isinstance(r, str)]
860
+ for c, ids in prev["by_commit"].items()
861
+ if isinstance(ids, list)
862
+ }
863
+ except json.JSONDecodeError:
864
+ pass
865
+
866
+ by_commit: dict[str, list[str]] = {}
867
+ for batch in scan.batches:
868
+ if batch.failed:
869
+ if batch.commit in prev_by_commit:
870
+ by_commit[batch.commit] = prev_by_commit[batch.commit]
871
+ else:
872
+ by_commit[batch.commit] = sorted({e.record_id for e in batch.entries})
873
+ store.set_meta(
874
+ DRIFT_CACHE_KEY,
875
+ json.dumps(
876
+ {
877
+ "head": scan.head,
878
+ "computed_at": datetime.now(UTC).isoformat(),
879
+ "by_commit": by_commit,
880
+ }
881
+ ),
882
+ )
883
+ return len(drifted_record_ids(store))
884
+ except Exception:
885
+ return None