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/schema.py ADDED
@@ -0,0 +1,386 @@
1
+ """Core record types for the owned decision store.
2
+
3
+ Six record types live in an owned, append-only store (see
4
+ ``docs/concepts/data-model.md``) — the founding three:
5
+
6
+ - :class:`Entity` — internal identity + engine mapping (created lazily).
7
+ - :class:`Decision` — the ADR / lesson / gotcha, with temporal validity.
8
+ - :class:`AnchorBinding` — a tiered, status-tracked link between a record and an entity.
9
+
10
+ Plus :class:`Initiative`, a flat container that groups decisions (Tier-0 anchoring),
11
+ :class:`Domain`, an owned, append-only named abstraction with a paired abstract entity
12
+ (see ``docs/concepts/mind-model.md``), and :class:`Fact`, a compact, falsifiable piece of
13
+ non-derivable knowledge that informed one or more decisions (see
14
+ ``design/superpowers/specs/2026-07-10-facts-layer-design.md``).
15
+
16
+ These are **never** written into the engine's regenerated ``graph.json``; the store is a
17
+ separate, repo-committed sidecar. The invariants documented on each model are enforced on
18
+ write by :mod:`sidegraph.store`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from enum import StrEnum
25
+ from typing import Literal
26
+
27
+ from pydantic import AwareDatetime, BaseModel, Field, field_validator, model_validator
28
+ from ulid import ULID
29
+
30
+ # The store format is a public contract from day one. Migration tooling is deferred beyond
31
+ # one forward step; this field is not (see CLAUDE.md invariant #3). Bumped 0.2.0 -> 0.3.0 for
32
+ # the mind-model layer (Domain record, Decision.layer, AnchorBinding.relation — see
33
+ # docs/concepts/mind-model.md). Bumped 0.3.0 -> 0.4.0 for the git-native store rewrite
34
+ # (file-per-record canonical layout + derived local index — see
35
+ # docs/reference/store-format.md): a repo-committed single SQLite file cannot be merged by
36
+ # git, and sync was rewriting the committed db on every graph rebuild. Legacy 0.2.x/0.3.x
37
+ # single-file stores are migrated forward on ``Store.__init__`` (eagerly, not deferred to
38
+ # first write — see store.py's ``_migrate_legacy``).
39
+ #
40
+ # NOT bumped for ``Domain.seed_anchors`` (durable domain membership, §2a amendment —
41
+ # design/superpowers/specs/2026-07-08-domain-onboarding-design.md): purely additive,
42
+ # defaulted (``[]``) field on an existing record. An existing ``domains/<id>.json`` with no
43
+ # ``seed_anchors`` key loads unchanged (Pydantic fills the default); a new file WITH the key
44
+ # is read fine by nothing-but-old code paths too, since nothing reads it except the new
45
+ # sync logic added alongside it. No migration semantics change either direction — bumping
46
+ # would only make ``Store._refresh_freshness``'s exact-match ``schema_version`` gate hard-
47
+ # reject every teammate's already-fresh local ``index.db`` on next open, for zero actual
48
+ # incompatibility.
49
+ #
50
+ # Bumped 0.5.0 -> 0.6.0 for derived community bindings (see
51
+ # design/superpowers/specs/2026-07-10-derived-community-bindings-design.md): community
52
+ # labels are snapshot labels, not identities, so ``community:*`` abstract entities and any
53
+ # Tier-1 binding pointing at one are now fully DERIVED — index-only, never written to a
54
+ # canonical file (neither at capture nor at sync/repointing time). A 0.5.0 store's
55
+ # canonical files may still contain community entities/bindings written by the old code;
56
+ # they load into the index unchanged (tolerant reload) and decay lazily off a record's
57
+ # committed file on that record's next legitimate (non-community) canonical rewrite.
58
+ SCHEMA_VERSION = "0.6.0"
59
+
60
+ _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
61
+
62
+
63
+ def _new_id() -> str:
64
+ """Mint a fresh, sortable, immutable ULID string."""
65
+ return str(ULID())
66
+
67
+
68
+ def canonicalize(name: str) -> str:
69
+ """Normalize an entity name for matching: lowercase, strip a leading ``.`` and any
70
+ call decoration. Graphify renders functions ``f()`` and methods ``.m()``; decisions
71
+ reference bare symbols. Canonicalizing both sides makes name matching robust.
72
+ """
73
+ return name.strip().lstrip(".").split("(", 1)[0].strip().lower()
74
+
75
+
76
+ _SLUGIFY_STRIP_RE = re.compile(r"[^a-z0-9-]")
77
+ _SLUGIFY_COLLAPSE_RE = re.compile(r"-+")
78
+
79
+
80
+ def slugify(text: str) -> str:
81
+ """Kebab-case a free-form label for a ``tag:<slug>`` (or manually-typed domain slug)
82
+ abstract entity: lowercase, spaces -> ``-``, drop everything outside ``[a-z0-9-]``,
83
+ collapse repeats, strip leading/trailing ``-``. May return ``""`` for input with no
84
+ ``[a-z0-9]`` content — callers must skip an empty slug rather than mint a nameless tag
85
+ (see ``docs/concepts/mind-model.md``).
86
+ """
87
+ s = text.strip().lower().replace(" ", "-")
88
+ s = _SLUGIFY_STRIP_RE.sub("", s)
89
+ return _SLUGIFY_COLLAPSE_RE.sub("-", s).strip("-")
90
+
91
+
92
+ def matches_path_prefix(file_path: str, prefix: str) -> bool:
93
+ """Boundary-safe ``Domain.path_prefixes`` match: true iff ``file_path`` equals
94
+ ``prefix`` exactly, or sits under it as a true subdirectory (``prefix`` + ``"/"``).
95
+
96
+ A bare ``file_path.startswith(prefix)`` false-positives on sibling paths that merely
97
+ share a text prefix — ``"payments"`` would loosely match ``"payments_v2/x.py"``, a
98
+ different top-level directory entirely. ``prefix`` may itself already carry a trailing
99
+ ``"/"`` (both authoring paths allow it); that's normalized away before comparing. The
100
+ one place this rule is encoded — shared by ``domains._matches_any_prefix`` (bootstrap
101
+ ``--paths`` filtering) and ``sync._recompute_domain_communities`` (post-rebuild
102
+ refresh) so the two authoring/refresh paths can never drift apart on the boundary rule.
103
+ """
104
+ prefix = prefix.rstrip("/")
105
+ return file_path == prefix or file_path.startswith(prefix + "/")
106
+
107
+
108
+ class EntityKind(StrEnum):
109
+ CONCRETE = "concrete" # a real code entity (module, class, function)
110
+ ABSTRACT = "abstract" # a semantic/domain concept (Tier-0)
111
+
112
+
113
+ class DecisionKind(StrEnum):
114
+ ADR = "adr"
115
+ LESSON = "lesson"
116
+ CONSTRAINT = "constraint"
117
+ GOTCHA = "gotcha"
118
+
119
+
120
+ class DecisionStatus(StrEnum):
121
+ PROPOSED = "proposed"
122
+ ACCEPTED = "accepted"
123
+ SUPERSEDED = "superseded"
124
+ REJECTED = "rejected"
125
+ DEPRECATED = "deprecated"
126
+
127
+
128
+ class Scope(StrEnum):
129
+ REPO = "repo"
130
+ MODULE = "module"
131
+ GLOBAL = "global"
132
+
133
+
134
+ class DomainStatus(StrEnum):
135
+ PROPOSED = "proposed"
136
+ ACCEPTED = "accepted"
137
+ SUPERSEDED = "superseded"
138
+ DROPPED = "dropped"
139
+
140
+
141
+ # Shared with capture.py's per-anchor override (AnchorDraft.relation) so the two stay in
142
+ # lockstep by construction rather than by two hand-synced Literal lists.
143
+ Relation = Literal["creates", "modifies", "affects", "deprecates", "considered"]
144
+
145
+
146
+ class Descriptor(BaseModel):
147
+ """What :meth:`GraphifyReader.resolve` needs to find an entity's current node.
148
+
149
+ Graphify 0.9.6 emits no signatures/qual-names/enclosing-module, so identity is
150
+ ``name`` + optional ``file_path`` (see ``docs/integrations/graphify.md``).
151
+ """
152
+
153
+ name: str
154
+ file_path: str | None = None
155
+
156
+ @field_validator("file_path", mode="after")
157
+ @classmethod
158
+ def _blank_path_is_no_path(cls, v: str | None) -> str | None:
159
+ """``""`` (and whitespace) means "no path", and is folded here so it can never be
160
+ STORED as one.
161
+
162
+ Two sources produce it: Graphify emits ``source_file: ""`` for a path-less node, and
163
+ an agent filling an MCP anchor writes ``"file_path": ""`` for "I don't know" as
164
+ readily as it omits the key. Without this fold the two spellings diverge exactly
165
+ where it hurts — a ``""`` descriptor minted before the real carrier exists makes
166
+ ``_adopt_path_carrying_entity`` see two "paths" (``""`` and the real one), read that
167
+ as ambiguity, and go back to minting the path-less twin this schema change exists to
168
+ prevent (external review, round 3, reproduced). Folding at the boundary keeps the
169
+ one-descriptor-one-entity rule true by construction instead of by three agreeing
170
+ predicates."""
171
+ if v is None:
172
+ return None
173
+ return v.strip() or None
174
+
175
+
176
+ class Entity(BaseModel):
177
+ """Internal identity + engine mapping. Minted once, ``entity_id`` never changes.
178
+
179
+ Created **lazily** — only when a decision first references it — to stay bounded.
180
+ ``last_seen_node_id`` maps our durable id onto Graphify's shifting, path-derived node
181
+ id and is refreshed on each engine rebuild; ``last_seen_community`` records the observed
182
+ Leiden community as of that same rebuild — communities are snapshot labels Leiden
183
+ renumbers every rebuild, so this baseline is what sync uses to re-point Tier-1 bindings
184
+ (see ``docs/concepts/data-model.md``).
185
+ """
186
+
187
+ entity_id: str = Field(default_factory=_new_id)
188
+ canonical_name: str
189
+ kind: EntityKind = EntityKind.CONCRETE
190
+ descriptor: Descriptor | None = None
191
+ last_seen_node_id: str | None = None
192
+ last_seen_graph_version: str | None = None
193
+ last_seen_community: str | None = None
194
+
195
+
196
+ class Provenance(BaseModel):
197
+ """Where a decision came from. Always present on write."""
198
+
199
+ source: str # commit | pr | manual | agent
200
+ ref: str | None = None
201
+ author: str | None = None
202
+ session_id: str | None = None
203
+ graph_version: str | None = None
204
+ # Best-effort `git rev-parse HEAD` at capture time (design/superpowers/specs/
205
+ # 2026-07-30-staleness-machinery-design.md, D1) — additive, no SCHEMA_VERSION bump
206
+ # (same `seed_anchors` precedent this module documents above): purely additive,
207
+ # defaulted, old files load, new files tolerated. Stamped on the propose path
208
+ # (capture._propose_one) and the supersede_decision path (D6); NEVER backfilled —
209
+ # provenance is immutable after write (verify.DECISION_MUTABLE_FIELDS), so a pre-wave
210
+ # record simply lacks this field and is skipped, not corrected, by doctor's
211
+ # `code-drift` check (D5).
212
+ commit: str | None = None
213
+
214
+
215
+ class Domain(BaseModel):
216
+ """The owned abstraction: a named area of the system with WHY-IT-EXISTS prose.
217
+
218
+ Paired with an abstract :class:`Entity` (``canonical_name=f"domain:{slug}"``) minted
219
+ at acceptance, so :class:`AnchorBinding` machinery works unchanged (Tier-1 decisions
220
+ bind to the domain entity). Append-only, like :class:`Decision`: editing a summary is
221
+ a new ``Domain`` row with ``supersedes`` set; the slug (and its paired entity) is
222
+ stable across that revision (see
223
+ ``docs/concepts/mind-model.md#domain-lifecycle``).
224
+ """
225
+
226
+ domain_id: str = Field(default_factory=_new_id)
227
+ slug: str
228
+ title: str
229
+ summary: str # the WHY-IT-EXISTS prose — 1-3 sentences, REQUIRED
230
+ parent_id: str | None = None # optional self-reference; acyclic, enforced on write
231
+ communities: list[str] = Field(default_factory=list) # last-seen engine community ids
232
+ path_prefixes: list[str] = Field(default_factory=list) # stabilizer/bootstrap rule
233
+ # COMMITTED, durable authoring intent (§2a amendment): "this domain includes the
234
+ # communities these entities currently live in." Same Descriptor (name + optional
235
+ # file_path) a decision's own anchor resolves via GraphifyReader.resolve — unlike
236
+ # `communities` (a volatile, last-seen snapshot popped from the canonical file by
237
+ # `_domain_canonical_payload`), `seed_anchors` survives a fresh clone/rebuild because it
238
+ # anchors to durable entities, not renumberable Leiden ids. `sync._recompute_domain_
239
+ # communities` resolves each anchor to its CURRENT community every pass (see sync.py).
240
+ seed_anchors: list[Descriptor] = Field(default_factory=list)
241
+
242
+ # Ratifier stamp (2026-08-04 proposal-lifecycle design D4): additive-defaulted — no
243
+ # SCHEMA_VERSION bump (the `capture_commit`/`seed_anchors` precedent; old files load,
244
+ # old readers tolerate). Set ONLY by the store's ratify transitions; None means a
245
+ # pre-change record or unavailable identity — never guessed. Git history remains the
246
+ # tamper-evidence layer; this puts the who/when in the record itself (practitioner
247
+ # panel, security reviewer objection 7).
248
+ ratified_by: str | None = None
249
+ ratified_at: AwareDatetime | None = None
250
+ status: DomainStatus = DomainStatus.PROPOSED
251
+ supersedes: str | None = None # id of the Domain this one replaces
252
+ provenance: Provenance
253
+
254
+ @field_validator("slug")
255
+ @classmethod
256
+ def _check_slug(cls, v: str) -> str:
257
+ if not _SLUG_RE.match(v):
258
+ raise ValueError(f"slug must be kebab-case (^[a-z0-9][a-z0-9-]*$): {v!r}")
259
+ return v
260
+
261
+ @field_validator("title")
262
+ @classmethod
263
+ def _check_title_non_empty(cls, v: str) -> str:
264
+ if not v.strip():
265
+ raise ValueError("title must be non-empty")
266
+ return v
267
+
268
+ @field_validator("summary")
269
+ @classmethod
270
+ def _check_summary_non_empty(cls, v: str) -> str:
271
+ if not v.strip():
272
+ raise ValueError("summary must be non-empty")
273
+ return v
274
+
275
+
276
+ class Decision(BaseModel):
277
+ """The memory: an ADR, lesson, constraint, or gotcha with temporal validity.
278
+
279
+ Append-only; never hard-deleted. A reversal sets ``valid_to`` on the old record and
280
+ creates a new one whose ``supersedes`` points at it. ``rejected`` captures what was tried
281
+ — what was tried and abandoned — which is exactly what retrieval surfaces first.
282
+ """
283
+
284
+ id: str = Field(default_factory=_new_id)
285
+ title: str
286
+ kind: DecisionKind
287
+ status: DecisionStatus = DecisionStatus.PROPOSED
288
+
289
+ context: str
290
+ choice: str
291
+ rejected: str | None = None # tried/considered and abandoned, and why
292
+ consequences: str | None = None
293
+
294
+ valid_from: AwareDatetime
295
+ valid_to: AwareDatetime | None = None
296
+ supersedes: str | None = None # id of the Decision this one replaces
297
+
298
+ scope: Scope = Scope.REPO
299
+ layer: Literal["business", "technical"] | None = None # filter axis; obvious at capture
300
+ provenance: Provenance
301
+
302
+ # Ratifier stamp (2026-08-04 proposal-lifecycle design D4): additive-defaulted — no
303
+ # SCHEMA_VERSION bump (the `capture_commit`/`seed_anchors` precedent; old files load,
304
+ # old readers tolerate). Set ONLY by the store's ratify transitions; None means a
305
+ # pre-change record or unavailable identity — never guessed. Git history remains the
306
+ # tamper-evidence layer; this puts the who/when in the record itself (practitioner
307
+ # panel, security reviewer objection 7).
308
+ ratified_by: str | None = None
309
+ ratified_at: AwareDatetime | None = None
310
+
311
+ @model_validator(mode="after")
312
+ def _check_validity_window(self) -> Decision:
313
+ if self.valid_to is not None and self.valid_to < self.valid_from:
314
+ raise ValueError("valid_to must be >= valid_from")
315
+ return self
316
+
317
+
318
+ class Fact(BaseModel):
319
+ """A compact, non-derivable piece of knowledge that informed decisions.
320
+
321
+ Only facts the code graph cannot derive belong here: empirics (benchmarks, observed
322
+ behavior), external constraints (API limits, library capabilities), trial-learned
323
+ knowledge. "The code does X" is the engine's job — never store it. Falsifiable:
324
+ reversal is supersession, never deletion.
325
+ # see design/superpowers/specs/2026-07-10-facts-layer-design.md
326
+ """
327
+
328
+ id: str = Field(default_factory=_new_id)
329
+ statement: str # the fact itself, 1-2 sentences — hard-compact
330
+ source: str # epistemics: how we know ("benchmark run 2026-07-09", "httpx docs")
331
+ supports: list[str] = Field(default_factory=list) # decision ids this fact informed
332
+
333
+ # Ratifier stamp (2026-08-04 proposal-lifecycle design D4): additive-defaulted — no
334
+ # SCHEMA_VERSION bump (the `capture_commit`/`seed_anchors` precedent; old files load,
335
+ # old readers tolerate). Set ONLY by the store's ratify transitions; None means a
336
+ # pre-change record or unavailable identity — never guessed. Git history remains the
337
+ # tamper-evidence layer; this puts the who/when in the record itself (practitioner
338
+ # panel, security reviewer objection 7).
339
+ ratified_by: str | None = None
340
+ ratified_at: AwareDatetime | None = None
341
+ status: DecisionStatus = DecisionStatus.PROPOSED # DEPRECATED unused for facts
342
+
343
+ valid_from: AwareDatetime
344
+ valid_to: AwareDatetime | None = None
345
+ supersedes: str | None = None # id of the Fact this one replaces
346
+
347
+ provenance: Provenance
348
+
349
+ @model_validator(mode="after")
350
+ def _check_validity_window(self) -> Fact:
351
+ if self.valid_to is not None and self.valid_to < self.valid_from:
352
+ raise ValueError("valid_to must be >= valid_from")
353
+ return self
354
+
355
+ @model_validator(mode="after")
356
+ def _check_nonempty(self) -> Fact:
357
+ if not self.statement.strip():
358
+ raise ValueError("statement must be non-empty")
359
+ if not self.source.strip():
360
+ raise ValueError("source must be non-empty")
361
+ return self
362
+
363
+
364
+ class AnchorBinding(BaseModel):
365
+ """A tiered, status-tracked link from a record (decision or fact) to an entity.
366
+
367
+ One decision may have several bindings (multi-anchor). Retrieval resolves the best
368
+ ``live`` binding; degradation is recorded here (status flips), never by deleting.
369
+ """
370
+
371
+ record_id: str
372
+ entity_id: str
373
+ tier: int = Field(ge=0, le=2) # 0 semantic/abstract | 1 group | 2 leaf
374
+ weight: float = Field(default=1.0, ge=0.0, le=1.0)
375
+ status: str = "live" # live | degraded | orphaned
376
+ # additive; defaulted to avoid capture friction — rendered only when non-default
377
+ relation: Relation = "affects"
378
+
379
+
380
+ class Initiative(BaseModel):
381
+ """A flat container grouping related decisions (the Tier-0 owning anchor)."""
382
+
383
+ id: str = Field(default_factory=_new_id)
384
+ name: str
385
+ description: str | None = None
386
+ tags: list[str] = Field(default_factory=list)