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/__init__.py +37 -0
- sidegraph/anchoring.py +246 -0
- sidegraph/bootstrap/__init__.py +49 -0
- sidegraph/bootstrap/apply.py +603 -0
- sidegraph/bootstrap/catalog.py +92 -0
- sidegraph/bootstrap/cli.py +827 -0
- sidegraph/bootstrap/integrations.py +184 -0
- sidegraph/bootstrap/model.py +277 -0
- sidegraph/bootstrap/planner.py +400 -0
- sidegraph/bootstrap/proof.py +103 -0
- sidegraph/bootstrap/review.py +331 -0
- sidegraph/bootstrap/scan.py +289 -0
- sidegraph/capture.py +1794 -0
- sidegraph/cli.py +1902 -0
- sidegraph/config.py +148 -0
- sidegraph/doc_import.py +2099 -0
- sidegraph/doctor.py +1429 -0
- sidegraph/domains.py +902 -0
- sidegraph/engine/__init__.py +7 -0
- sidegraph/engine/reader.py +353 -0
- sidegraph/gitio.py +572 -0
- sidegraph/host/__init__.py +7 -0
- sidegraph/host/hooks.py +770 -0
- sidegraph/importer.py +239 -0
- sidegraph/okf.py +471 -0
- sidegraph/profiles.py +459 -0
- sidegraph/retrieval.py +1657 -0
- sidegraph/schema.py +386 -0
- sidegraph/server.py +2608 -0
- sidegraph/store.py +3363 -0
- sidegraph/sync.py +885 -0
- sidegraph/verify.py +1040 -0
- sidegraph/viz/__init__.py +4 -0
- sidegraph/viz/assets/vis-network.min.js +33 -0
- sidegraph/viz/model.py +248 -0
- sidegraph/viz/render.py +110 -0
- sidegraph/viz/template.html +131 -0
- sidegraph-0.1.0.dist-info/METADATA +392 -0
- sidegraph-0.1.0.dist-info/RECORD +42 -0
- sidegraph-0.1.0.dist-info/WHEEL +4 -0
- sidegraph-0.1.0.dist-info/entry_points.txt +18 -0
- sidegraph-0.1.0.dist-info/licenses/LICENSE +201 -0
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)
|