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/capture.py
ADDED
|
@@ -0,0 +1,1794 @@
|
|
|
1
|
+
"""Deterministic capture pipeline (Stage 5) — no LLM key.
|
|
2
|
+
|
|
3
|
+
The generative work (distilling a session into What/Why/Where/Learned drafts, judging
|
|
4
|
+
significance) happens in the agent's session turn; this module is the pure write pipeline:
|
|
5
|
+
redact -> validate -> package -> anchor -> dedup -> write as ``status=proposed``. The human
|
|
6
|
+
ratifies via the store's ``ratify``/``drop`` (see docs/guides/capturing-decisions.md).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, Field, ValidationError, field_validator
|
|
21
|
+
|
|
22
|
+
from .anchoring import entity_summaries, orphan_reason, resolve_and_bind
|
|
23
|
+
from .config import TELEMETRY_SESSION_KEY
|
|
24
|
+
from .engine.reader import GraphifyReader
|
|
25
|
+
from .retrieval import TOC_CACHE_KEY, build_toc
|
|
26
|
+
from .schema import (
|
|
27
|
+
AnchorBinding,
|
|
28
|
+
Decision,
|
|
29
|
+
DecisionKind,
|
|
30
|
+
DecisionStatus,
|
|
31
|
+
Descriptor,
|
|
32
|
+
Domain,
|
|
33
|
+
DomainStatus,
|
|
34
|
+
Fact,
|
|
35
|
+
Provenance,
|
|
36
|
+
Relation,
|
|
37
|
+
canonicalize,
|
|
38
|
+
matches_path_prefix,
|
|
39
|
+
slugify,
|
|
40
|
+
)
|
|
41
|
+
from .store import _TERMINAL_DECISION_STATUSES, Store
|
|
42
|
+
from .sync import activate_accepted_domain
|
|
43
|
+
|
|
44
|
+
# v1 secret patterns. Redaction runs FIRST: its output is the only text that proceeds to
|
|
45
|
+
# validation/storage — mandatory for a repo-committed store.
|
|
46
|
+
_SECRET_PATTERNS: list[re.Pattern[str]] = [
|
|
47
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
|
|
48
|
+
re.compile(r"AKIA[0-9A-Z]{16}"),
|
|
49
|
+
re.compile(r"ghp_[A-Za-z0-9]{20,}"),
|
|
50
|
+
re.compile(r"github_pat_[A-Za-z0-9_]{20,}"),
|
|
51
|
+
re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"),
|
|
52
|
+
re.compile(r"(?i)bearer\s+[a-z0-9._~+/=-]{20,}"),
|
|
53
|
+
re.compile(
|
|
54
|
+
r"(?i)\b(?:[a-z0-9]+[_-])*(?:api[_-]?key|token|secret|password)"
|
|
55
|
+
r"(?:[_-][a-z0-9]+)*\s*[=:]\s*\S+"
|
|
56
|
+
),
|
|
57
|
+
# 2026-08-04 seeded-leak eval additions (design/testing/2026-08-04-redaction-seeded-leak.md):
|
|
58
|
+
# the five adjacent classes the eval showed leaking that admit low-false-positive
|
|
59
|
+
# patterns. Emails and bare hex tokens remain DOCUMENTED misses — both are too
|
|
60
|
+
# collision-prone for pattern redaction (an email is not necessarily a secret; 40+ hex
|
|
61
|
+
# collides with commit SHAs/digests) and are covered by the defense-in-depth guidance
|
|
62
|
+
# (run the org's secret scanner over the store path in CI).
|
|
63
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"), # JWT
|
|
64
|
+
re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://[^/\s:@]+):([^@\s]+)@"), # URL credential
|
|
65
|
+
re.compile(r"\bAIza[0-9A-Za-z_-]{30,}\b"), # Google API key
|
|
66
|
+
re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), # sk- style API key (OpenAI et al.)
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Candidate PAN spans: 16 digits, optionally space/dash-grouped. Regex alone would eat
|
|
70
|
+
# ULIDs' neighbors and invoice numbers — a match must ALSO pass Luhn before redaction
|
|
71
|
+
# (check-what-you-redact, not pattern-and-pray). Applied by `redact` after the pattern
|
|
72
|
+
# passes above.
|
|
73
|
+
_PAN_CANDIDATE = re.compile(r"\b(?:\d[ -]?){15}\d\b")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _luhn_ok(digits: str) -> bool:
|
|
77
|
+
total = 0
|
|
78
|
+
for i, ch in enumerate(reversed(digits)):
|
|
79
|
+
d = int(ch)
|
|
80
|
+
if i % 2 == 1:
|
|
81
|
+
d *= 2
|
|
82
|
+
if d > 9:
|
|
83
|
+
d -= 9
|
|
84
|
+
total += d
|
|
85
|
+
return total % 10 == 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def redact(text: str) -> tuple[str, int]:
|
|
89
|
+
"""Scrub secrets from text. Returns (clean_text, replacement_count)."""
|
|
90
|
+
total = 0
|
|
91
|
+
for pattern in _SECRET_PATTERNS:
|
|
92
|
+
text, n = pattern.subn("[REDACTED]", text)
|
|
93
|
+
total += n
|
|
94
|
+
|
|
95
|
+
def _pan_sub(m: re.Match[str]) -> str:
|
|
96
|
+
nonlocal total
|
|
97
|
+
digits = re.sub(r"[ -]", "", m.group(0))
|
|
98
|
+
if len(digits) == 16 and _luhn_ok(digits):
|
|
99
|
+
total += 1
|
|
100
|
+
return "[REDACTED]"
|
|
101
|
+
return m.group(0)
|
|
102
|
+
|
|
103
|
+
text = _PAN_CANDIDATE.sub(_pan_sub, text)
|
|
104
|
+
return text, total
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RatifyPolicy(StrEnum):
|
|
108
|
+
"""Auto-ratification policy for a propose/import/bootstrap batch (design D1).
|
|
109
|
+
|
|
110
|
+
``MANUAL`` (default): introduces NO new transition and leaves every existing write
|
|
111
|
+
path's semantics exactly as they are — it is not a claim that every write lands
|
|
112
|
+
``proposed``. Some paths already land ``accepted`` under their own, pre-existing
|
|
113
|
+
flags (e.g. ``importer.py``'s ``propose=False``, or the unrelated legacy
|
|
114
|
+
``SIDEGRAPH_AUTO_ACCEPT=on`` knob); that behavior is untouched and out of this
|
|
115
|
+
feature's scope. ``AUTO_LOW_RISK``/``AUTO_ALL`` gate which shapes may self-ratify at
|
|
116
|
+
write time. Resolved ONCE per CLI/MCP invocation by the outer shell from
|
|
117
|
+
``SIDEGRAPH_RATIFY_POLICY`` (via
|
|
118
|
+
``parse_ratify_policy``) and threaded down as a keyword, the same way ``auto_accept``
|
|
119
|
+
already is — this module never reads the environment itself (see the module
|
|
120
|
+
docstring).
|
|
121
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
MANUAL = "manual"
|
|
125
|
+
AUTO_LOW_RISK = "auto-low-risk"
|
|
126
|
+
AUTO_ALL = "auto-all"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_ratify_policy(raw: str | None) -> RatifyPolicy:
|
|
130
|
+
"""Pure parse of ``SIDEGRAPH_RATIFY_POLICY`` into a ``RatifyPolicy`` (design D1).
|
|
131
|
+
|
|
132
|
+
Unknown, empty, whitespace-only, or ``None`` input fails safe to
|
|
133
|
+
``RatifyPolicy.MANUAL`` — the ``_proposal_window_days`` precedent (``retrieval.py``):
|
|
134
|
+
a typo in a regulated deployment must not silently open the auto-ratify gate.
|
|
135
|
+
Surrounding whitespace is stripped before matching, same as that precedent
|
|
136
|
+
(``retrieval.py:555``'s ``raw.strip()``) — a trailing space off a ``.env`` line must
|
|
137
|
+
not silently downgrade an autonomous deployment's policy to ``manual``. Pure by
|
|
138
|
+
construction: this function does not read the environment itself — the outer CLI/MCP
|
|
139
|
+
shell reads the ``SIDEGRAPH_RATIFY_POLICY`` variable once and passes the raw value in
|
|
140
|
+
as ``raw``.
|
|
141
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1
|
|
142
|
+
"""
|
|
143
|
+
if raw is None:
|
|
144
|
+
return RatifyPolicy.MANUAL
|
|
145
|
+
try:
|
|
146
|
+
return RatifyPolicy(raw.strip())
|
|
147
|
+
except ValueError:
|
|
148
|
+
return RatifyPolicy.MANUAL
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Kinds `auto-low-risk` admits by shape (D3 gate 1): `gotcha`/`lesson` decisions and
|
|
152
|
+
# standalone facts. `adr`/`constraint` and domains are `auto-all`-only.
|
|
153
|
+
_LOW_RISK_KINDS: frozenset[str] = frozenset({"gotcha", "lesson", "fact"})
|
|
154
|
+
# The decision kinds `auto-all` additionally admits over `_LOW_RISK_KINDS` (still
|
|
155
|
+
# subject to the remaining gates). Domains are handled separately below — they have
|
|
156
|
+
# their own anchor gate (`domain_anchored`, never `live_tier12`) and are never eligible
|
|
157
|
+
# under `auto-low-risk` at all, so they do not belong in either kind set.
|
|
158
|
+
_AUTO_ALL_EXTRA_KINDS: frozenset[str] = frozenset({"adr", "constraint"})
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class AutoEligibility:
|
|
163
|
+
"""The ONE input shape every auto-ratify caller adapts its own write result to
|
|
164
|
+
(design D3). ``ProposeResult``/``ProposeFactResult``/``ProposeDomainResult`` (and
|
|
165
|
+
the importer/doc-import equivalents) each compute one of these per write and hand
|
|
166
|
+
it to ``auto_ratify_eligible`` — the adaptation happens once per call site, never
|
|
167
|
+
inside this predicate, and never the reverse (this shape never grows a
|
|
168
|
+
caller-specific field).
|
|
169
|
+
|
|
170
|
+
``live_tier12`` is ``store.bindings_for_record(id)`` filtered to
|
|
171
|
+
``status == "live"`` and ``tier in (1, 2)``, computed AFTER the anchor step —
|
|
172
|
+
never arithmetic over ``anchors_skipped``/``anchors_orphaned`` (record
|
|
173
|
+
``01KYD1WCNMGPH9964EQ98ZZFHW``: an empty ``anchors_skipped`` means "nothing was
|
|
174
|
+
ambiguous", not "everything resolved"). It is always ``0`` for domains, which carry
|
|
175
|
+
no ``AnchorBinding`` at propose time — ``domain_anchored`` is their anchor signal
|
|
176
|
+
instead (reader present + lint clean + seed/prefix present).
|
|
177
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D3
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
kind: str
|
|
181
|
+
live_tier12: int
|
|
182
|
+
ambiguous_or_orphan_only: bool
|
|
183
|
+
pipeline_clean: bool
|
|
184
|
+
has_provenance: bool
|
|
185
|
+
domain_anchored: bool
|
|
186
|
+
has_supersedes: bool
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def auto_ratify_eligible(signal: AutoEligibility, policy: RatifyPolicy) -> bool:
|
|
190
|
+
"""Conjunctive, deterministic auto-ratify gate — no LLM in the write path (design D3).
|
|
191
|
+
|
|
192
|
+
Four gates, ALL of which must hold, checked cheapest first:
|
|
193
|
+
|
|
194
|
+
1. Policy allows the draft's shape. ``auto-low-risk`` admits only ``lesson``/
|
|
195
|
+
``gotcha`` decisions and standalone facts, and additionally requires
|
|
196
|
+
``has_supersedes`` to be ``False`` for those kinds — a supersession's blast
|
|
197
|
+
radius is the PREDECESSOR's kind, so a low-risk draft must never close a
|
|
198
|
+
human-ratified record with nobody looking. ``auto-all`` additionally admits
|
|
199
|
+
``adr``/``constraint`` and domains, and admits a superseding draft by shape (D2
|
|
200
|
+
still defers the predecessor's close to a successful ``Store.ratify``). Domains
|
|
201
|
+
are eligible under ``auto-all`` only, never ``auto-low-risk``, regardless of
|
|
202
|
+
anchor state.
|
|
203
|
+
2. Anchors. Decisions/facts need ``live_tier12 >= 1`` AND
|
|
204
|
+
``not ambiguous_or_orphan_only``. Both conditions restate the SAME fact — "no
|
|
205
|
+
live Tier-1/2 binding to stand on" — rather than gating on two independent
|
|
206
|
+
signals: a record with ANY live Tier-1/2 binding is anchored, whatever happened
|
|
207
|
+
to its OTHER anchors (an ambiguous anchor is a MISSING binding, not a wrong one,
|
|
208
|
+
so it never disqualifies a binding that did resolve). The
|
|
209
|
+
``not ambiguous_or_orphan_only`` half of the conjunction is defence in depth
|
|
210
|
+
against an adapter that computes ``live_tier12`` incorrectly, not an independent
|
|
211
|
+
product rule — ``live_tier12 >= 1`` together with ``ambiguous_or_orphan_only``
|
|
212
|
+
true is a CONTRADICTORY input (the flag promises zero live bindings; a positive
|
|
213
|
+
count says otherwise), and the gate rejects that combination defensively rather
|
|
214
|
+
than trusting either field alone. Domains carry no ``AnchorBinding`` at propose
|
|
215
|
+
time, so they use ``domain_anchored`` instead and are never gated on
|
|
216
|
+
``live_tier12``/``ambiguous_or_orphan_only``.
|
|
217
|
+
3. Pipeline verdict. The write path's own result reports a clean, non-dry-run write
|
|
218
|
+
(``pipeline_clean``).
|
|
219
|
+
4. Provenance. Already a write invariant — restated here so this gate never
|
|
220
|
+
weakens it.
|
|
221
|
+
|
|
222
|
+
Total over the declared field types: for any ``signal`` whose fields match
|
|
223
|
+
``AutoEligibility``'s own annotations, every branch is an equality/membership check
|
|
224
|
+
or an ``int`` comparison, so this never raises — an unrecognized ``kind``, a
|
|
225
|
+
negative ``live_tier12``, or a contradictory flag combination all resolve to
|
|
226
|
+
``False`` rather than an exception. This is not a defensive guarantee against a
|
|
227
|
+
wrongly-typed field (``live_tier12`` is populated by a ``len(...)`` at every real
|
|
228
|
+
call site, never user input): a non-``int`` ``live_tier12`` can raise on the
|
|
229
|
+
``< 1`` comparison, and this function does not guard against that.
|
|
230
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D3
|
|
231
|
+
"""
|
|
232
|
+
if policy not in (RatifyPolicy.AUTO_LOW_RISK, RatifyPolicy.AUTO_ALL):
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
if signal.kind == "domain":
|
|
236
|
+
if policy != RatifyPolicy.AUTO_ALL:
|
|
237
|
+
return False
|
|
238
|
+
if not signal.domain_anchored:
|
|
239
|
+
return False
|
|
240
|
+
if not signal.pipeline_clean:
|
|
241
|
+
return False
|
|
242
|
+
return bool(signal.has_provenance)
|
|
243
|
+
|
|
244
|
+
if policy == RatifyPolicy.AUTO_LOW_RISK:
|
|
245
|
+
if signal.kind not in _LOW_RISK_KINDS:
|
|
246
|
+
return False
|
|
247
|
+
if signal.has_supersedes:
|
|
248
|
+
return False
|
|
249
|
+
else: # RatifyPolicy.AUTO_ALL
|
|
250
|
+
if signal.kind not in _LOW_RISK_KINDS and signal.kind not in _AUTO_ALL_EXTRA_KINDS:
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
if signal.live_tier12 < 1:
|
|
254
|
+
return False
|
|
255
|
+
if signal.ambiguous_or_orphan_only:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
if not signal.pipeline_clean:
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
return bool(signal.has_provenance)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True)
|
|
265
|
+
class AutoRatifyOutcome:
|
|
266
|
+
"""Normalized result of one :func:`_auto_ratify` transition attempt (design D2/D6).
|
|
267
|
+
|
|
268
|
+
``ratified_by`` is the ``"auto:<policy>"`` stamp when the transition fired and
|
|
269
|
+
succeeded, ``None`` otherwise. ``error`` is ``None`` on success; the ``ValueError`` text
|
|
270
|
+
for a failed ``store.ratify``/``ratify_fact`` call, or the ``"error: ..."`` outcome
|
|
271
|
+
``store.ratify_domains`` RETURNS (never raises) for a failed domain transition.
|
|
272
|
+
``cascaded_fact_ids`` carries the ids of every fact ``store.ratify``'s own cascade just
|
|
273
|
+
accepted alongside the decision — empty for standalone facts and domains, which never
|
|
274
|
+
cascade. The caller uses these ids to stamp the matching nested ``ProposeFactResult``
|
|
275
|
+
objects, so the public result and the canonical store agree (T11/T14).
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
ratified_by: str | None
|
|
279
|
+
error: str | None
|
|
280
|
+
cascaded_fact_ids: tuple[str, ...] = ()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _auto_ratify(
|
|
284
|
+
store: Store,
|
|
285
|
+
record_id: str,
|
|
286
|
+
kind: str,
|
|
287
|
+
policy: RatifyPolicy,
|
|
288
|
+
) -> AutoRatifyOutcome:
|
|
289
|
+
"""Call exactly one of the three C-2 transitions with the ``"auto:<policy>"`` stamp —
|
|
290
|
+
the SOLE place in this codebase that builds that stamp string (design D2). Routes on
|
|
291
|
+
``kind`` (an :class:`AutoEligibility`.kind value, already computed by the
|
|
292
|
+
caller for the eligibility check): ``"fact"`` -> :meth:`Store.ratify_fact`, ``"domain"``
|
|
293
|
+
-> :meth:`Store.ratify_domains`, anything else (a decision kind — ``gotcha``/``lesson``/
|
|
294
|
+
``adr``/``constraint``) -> :meth:`Store.ratify`.
|
|
295
|
+
|
|
296
|
+
Decision route / cascade guard (design D2 checkpoint-2 fix, Ruling Q, tightened by
|
|
297
|
+
Ruling T after checkpoint-2's own fix round 1): this function builds the
|
|
298
|
+
``cascade_guard`` it hands to ``Store.ratify`` ITSELF, from ``store`` and ``policy`` —
|
|
299
|
+
it is not an accepted parameter here. Every caller on the decision route (today only
|
|
300
|
+
``_propose_one``; a future importer/doc-import decision auto-block per plan Task 5)
|
|
301
|
+
therefore gets the guard automatically and CANNOT omit it or forward the wrong one —
|
|
302
|
+
closing exactly the gap an opt-in parameter would leave open (Task 5's own dispatch says
|
|
303
|
+
nothing about a guard). The guard re-runs :func:`_fact_cascade_eligible` on the cascade
|
|
304
|
+
set it is handed, reading each fact's bindings fresh via ``store`` at call time.
|
|
305
|
+
|
|
306
|
+
Catches ``Exception`` — never ``BaseException``, so ``KeyboardInterrupt``/``SystemExit``
|
|
307
|
+
still propagate out of an autonomous batch — normalizing both the anticipated
|
|
308
|
+
``ValueError`` race from ``ratify``/``ratify_fact`` (the proposal vanished or was already
|
|
309
|
+
ratified between the eligibility check and this call, or the cascade guard refused) and
|
|
310
|
+
any other unexpected failure from a returned flow into ``AutoRatifyOutcome.error``.
|
|
311
|
+
``ratify_domains`` never raises for a bad id; it RETURNS an ``"error: ..."`` outcome
|
|
312
|
+
string instead, which this function detects and normalizes the same way, so every caller
|
|
313
|
+
checks exactly one field regardless of which transition it called.
|
|
314
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D2/D6
|
|
315
|
+
"""
|
|
316
|
+
stamp = f"auto:{policy.value}"
|
|
317
|
+
try:
|
|
318
|
+
if kind == "fact":
|
|
319
|
+
store.ratify_fact(record_id, actor=stamp)
|
|
320
|
+
return AutoRatifyOutcome(ratified_by=stamp, error=None)
|
|
321
|
+
if kind == "domain":
|
|
322
|
+
outcome = store.ratify_domains(accept=[record_id], actor=stamp)[record_id]
|
|
323
|
+
if outcome.startswith("error"):
|
|
324
|
+
return AutoRatifyOutcome(ratified_by=None, error=outcome)
|
|
325
|
+
return AutoRatifyOutcome(ratified_by=stamp, error=None)
|
|
326
|
+
|
|
327
|
+
def _cascade_guard(facts: Sequence[Fact]) -> bool:
|
|
328
|
+
return all(_fact_cascade_eligible(store, fact, policy) for fact in facts)
|
|
329
|
+
|
|
330
|
+
_decision, cascaded = store.ratify(record_id, actor=stamp, cascade_guard=_cascade_guard)
|
|
331
|
+
return AutoRatifyOutcome(
|
|
332
|
+
ratified_by=stamp, error=None, cascaded_fact_ids=tuple(f.id for f in cascaded)
|
|
333
|
+
)
|
|
334
|
+
except Exception as e:
|
|
335
|
+
return AutoRatifyOutcome(ratified_by=None, error=str(e))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _anchor_signal(store: Store, record_id: str) -> tuple[int, bool]:
|
|
339
|
+
"""``(live_tier12, ambiguous_or_orphan_only)`` for ``record_id`` — design D3 gate 2,
|
|
340
|
+
computed from the SAME source for both fields (never ``anchors_skipped``/
|
|
341
|
+
``anchors_orphaned`` arithmetic, record ``01KYD1WCNMGPH9964EQ98ZZFHW``):
|
|
342
|
+
``store.bindings_for_record(record_id)`` filtered to ``status == "live"`` and
|
|
343
|
+
``tier in (1, 2)``, evaluated after the anchor step. The two fields describe the SAME
|
|
344
|
+
fact from two angles by construction — ``ambiguous_or_orphan_only`` can never disagree
|
|
345
|
+
with ``live_tier12`` because it is derived directly from it, never from a separate walk
|
|
346
|
+
over skipped/orphaned buckets.
|
|
347
|
+
"""
|
|
348
|
+
live_tier12 = sum(
|
|
349
|
+
1 for b in store.bindings_for_record(record_id) if b.status == "live" and b.tier in (1, 2)
|
|
350
|
+
)
|
|
351
|
+
return live_tier12, live_tier12 == 0
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _cascade_set(store: Store, decision_id: str) -> list[Fact]:
|
|
355
|
+
"""The exact fact set :meth:`Store.ratify`'s own cascade will flip for ``decision_id``
|
|
356
|
+
(mirrors the ``for fact in self.iter_proposed_facts(): if decision_id in fact.supports``
|
|
357
|
+
cascade loop at the end of :meth:`Store.ratify`) — re-queried from the store rather than
|
|
358
|
+
trusted from this call's own ``fact_results``, so any fact that would ride the same
|
|
359
|
+
cascade is checked, not just this call's own successful writes. In practice this IS
|
|
360
|
+
exactly the decision's step-7 attached facts (design D2): ``Store.add_fact`` rejects a
|
|
361
|
+
``supports`` id that does not exist, and ``decision_id`` is minted in this same call,
|
|
362
|
+
so no OTHER fact can already be in this set within a single process (spec rev 8, D2).
|
|
363
|
+
"""
|
|
364
|
+
return [f for f in store.iter_proposed_facts() if decision_id in f.supports]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _fact_cascade_eligible(store: Store, fact: Fact, policy: RatifyPolicy) -> bool:
|
|
368
|
+
"""Per-fact half of D2's cascade rule: does ``fact`` pass the fact half of gates 2-4
|
|
369
|
+
plus ``fact.supersedes is None`` under ``auto-low-risk`` — kind/policy admission
|
|
370
|
+
inherited from the owning decision by reusing :func:`auto_ratify_eligible` itself with
|
|
371
|
+
``kind="fact"`` and the SAME ``policy`` the decision was gated on, rather than
|
|
372
|
+
re-implementing the gate ladder here.
|
|
373
|
+
|
|
374
|
+
Shared by two callers that read anchors through the same :func:`_anchor_signal`, just at
|
|
375
|
+
different times (design D2 checkpoint-2 fix, Ruling Q/T): :func:`_cascade_eligible` (the
|
|
376
|
+
cheap pre-check, run BEFORE the store lock is taken) and the ``cascade_guard`` closure
|
|
377
|
+
:func:`_auto_ratify` builds ITSELF and hands to ``Store.ratify`` (the authoritative
|
|
378
|
+
re-check, re-run on a freshly re-queried cascade set AFTER the lock is held — never on
|
|
379
|
+
values computed before it, which is exactly the race the guard exists to close).
|
|
380
|
+
"""
|
|
381
|
+
live_tier12, ambiguous_or_orphan_only = _anchor_signal(store, fact.id)
|
|
382
|
+
signal = AutoEligibility(
|
|
383
|
+
kind="fact",
|
|
384
|
+
live_tier12=live_tier12,
|
|
385
|
+
ambiguous_or_orphan_only=ambiguous_or_orphan_only,
|
|
386
|
+
pipeline_clean=True,
|
|
387
|
+
has_provenance=True,
|
|
388
|
+
domain_anchored=False,
|
|
389
|
+
has_supersedes=fact.supersedes is not None,
|
|
390
|
+
)
|
|
391
|
+
return auto_ratify_eligible(signal, policy)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _cascade_eligible(store: Store, decision_id: str, policy: RatifyPolicy) -> bool:
|
|
395
|
+
"""D2's cascade rule: the decision's own auto-block is skipped unless EVERY fact in the
|
|
396
|
+
cascade set (:func:`_cascade_set`) is :func:`_fact_cascade_eligible`. An empty cascade
|
|
397
|
+
set (no attached facts, or none that landed ``written``) is vacuously eligible — there
|
|
398
|
+
is nothing to block on.
|
|
399
|
+
|
|
400
|
+
This is the cheap PRE-check only (design D2 checkpoint-2 fix, Ruling Q): it runs before
|
|
401
|
+
``Store.ratify`` takes its write lock, so a fact landing in the cascade set between this
|
|
402
|
+
call and the transition would not be seen here. :func:`_auto_ratify` itself builds the
|
|
403
|
+
authoritative ``cascade_guard`` closure it hands to ``Store.ratify`` (Ruling T — the
|
|
404
|
+
guard is built inside :func:`_auto_ratify` itself and is never a parameter, so no
|
|
405
|
+
caller can supply or omit it), which re-runs :func:`_fact_cascade_eligible` on a
|
|
406
|
+
freshly re-queried set INSIDE the lock — that guard, not this function, decides.
|
|
407
|
+
"""
|
|
408
|
+
return all(
|
|
409
|
+
_fact_cascade_eligible(store, fact, policy) for fact in _cascade_set(store, decision_id)
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _domain_anchored(
|
|
414
|
+
reader: GraphifyReader | None,
|
|
415
|
+
lint_warnings: list[str],
|
|
416
|
+
seed_anchors: list[Descriptor],
|
|
417
|
+
path_prefixes: list[str],
|
|
418
|
+
) -> bool:
|
|
419
|
+
"""D3's domain anchor gate: the reader must be present (domains carry no
|
|
420
|
+
``AnchorBinding`` at propose time, so this is the ONLY anchor signal they have — the
|
|
421
|
+
``if reader is not None:`` guard mirrors ``_lint_domain_path_prefixes``'s own dead-prefix
|
|
422
|
+
check, so a reader-absent domain is never eligible) AND the pipeline's own lint is clean
|
|
423
|
+
(``lint_warnings == []``) AND (at least one ``seed_anchor`` resolves to a live node OR
|
|
424
|
+
``path_prefixes`` is non-empty). "Non-empty prefixes" alone is NOT eligibility — that is
|
|
425
|
+
exactly what ``lint_warnings`` screens for (record ``01KYSFQ5D45SBQZ238NP8Z4YWH``).
|
|
426
|
+
"""
|
|
427
|
+
if reader is None or lint_warnings:
|
|
428
|
+
return False
|
|
429
|
+
if path_prefixes:
|
|
430
|
+
return True
|
|
431
|
+
return any(reader.resolve(anchor).status == "resolved" for anchor in seed_anchors)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class AnchorDraft(Descriptor):
|
|
435
|
+
"""A Where anchor with an optional per-anchor relation override (see
|
|
436
|
+
``AnchorBinding.relation``; omitted/None means the store default "affects")."""
|
|
437
|
+
|
|
438
|
+
relation: Relation | None = None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class DraftFact(BaseModel):
|
|
442
|
+
"""A compact non-derivable-knowledge draft — attached to a ``DraftDecision`` (its
|
|
443
|
+
``facts`` list) or proposed standalone via ``propose_facts``.
|
|
444
|
+
# see design/superpowers/specs/2026-07-10-facts-layer-design.md"""
|
|
445
|
+
|
|
446
|
+
statement: str
|
|
447
|
+
source: str
|
|
448
|
+
anchors: list[AnchorDraft] = Field(default_factory=list)
|
|
449
|
+
supports: list[str] = Field(default_factory=list)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class DraftDecision(BaseModel):
|
|
453
|
+
"""The What/Why/Where/Learned distillation form, mapped onto the Decision schema."""
|
|
454
|
+
|
|
455
|
+
title: str # What (short)
|
|
456
|
+
kind: DecisionKind # adr | lesson | constraint | gotcha
|
|
457
|
+
context: str # Why — incl. constraints that emerged
|
|
458
|
+
choice: str # what was decided
|
|
459
|
+
rejected: str | None = None # what was tried and abandoned
|
|
460
|
+
consequences: str | None = None # Learned / trade-offs accepted
|
|
461
|
+
anchors: list[AnchorDraft] = Field(default_factory=list) # Where
|
|
462
|
+
initiative: str | None = None # else derived from the git branch
|
|
463
|
+
supersedes: str | None = None
|
|
464
|
+
tags: list[str] = Field(default_factory=list) # free text; slugified below
|
|
465
|
+
facts: list[DraftFact] = Field(default_factory=list) # attached, non-derivable knowledge
|
|
466
|
+
|
|
467
|
+
@field_validator("tags", mode="before")
|
|
468
|
+
@classmethod
|
|
469
|
+
def _coerce_string_tags(cls, v: object) -> object:
|
|
470
|
+
"""Liberal-input: agents pass tags as a bare comma-separated string on the first
|
|
471
|
+
try — split it instead of failing the draft (mirrors server._coerce_tags)."""
|
|
472
|
+
if isinstance(v, str):
|
|
473
|
+
return [part.strip() for part in v.split(",") if part.strip()]
|
|
474
|
+
return v
|
|
475
|
+
|
|
476
|
+
layer: Literal["business", "technical"] | None = None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class ProposeFactResult(BaseModel):
|
|
480
|
+
status: str # "written" | "deduped" | "rejected"
|
|
481
|
+
fact_id: str | None = None
|
|
482
|
+
reason: str | None = None
|
|
483
|
+
redactions: int = 0
|
|
484
|
+
# Same shape/rationale as ProposeResult.anchors_skipped (Gate-5 finding S3).
|
|
485
|
+
anchors_skipped: list[dict] = Field(default_factory=list)
|
|
486
|
+
# Same shape/rationale as ProposeResult.anchors_orphaned.
|
|
487
|
+
anchors_orphaned: list[dict] = Field(default_factory=list)
|
|
488
|
+
# Auto-ratification policy (design D2/D6) — additive/defaulted, same precedent as
|
|
489
|
+
# `anchors_skipped`/`anchors_orphaned`: `None`/`None` under `manual` or when this fact
|
|
490
|
+
# was ineligible. `ratified_by` carries the `"auto:<policy>"` stamp when the transition
|
|
491
|
+
# fired and succeeded — set directly for a standalone fact's own auto-block, or by the
|
|
492
|
+
# OWNING decision's cascade when this is a nested attached-fact result (the public
|
|
493
|
+
# result must not say `ratified_by=None` for a fact whose canonical row the cascade just
|
|
494
|
+
# accepted, T11/T14). `auto_ratify_error` carries the normalized failure reason when an
|
|
495
|
+
# attempt failed; `None` when nothing was attempted or the attempt succeeded.
|
|
496
|
+
ratified_by: str | None = None
|
|
497
|
+
auto_ratify_error: str | None = None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
class ProposeResult(BaseModel):
|
|
501
|
+
status: str # "written" | "deduped" | "rejected"
|
|
502
|
+
decision_id: str | None = None
|
|
503
|
+
reason: str | None = None
|
|
504
|
+
redactions: int = 0
|
|
505
|
+
# Gate-5 finding S3: anchors resolve_and_bind couldn't pin to one leaf (name matched
|
|
506
|
+
# more than one graph node) -- [{"name", "reason": "ambiguous", "candidates"}, ...],
|
|
507
|
+
# candidates capped at 5. Empty when every anchor resolved cleanly, there were no
|
|
508
|
+
# anchors, or no graph reader was present (nothing to be ambiguous against).
|
|
509
|
+
anchors_skipped: list[dict] = Field(default_factory=list)
|
|
510
|
+
# Entity summaries [{"entity_id", "canonical_name", "tier": 2}, ...] for anchors that
|
|
511
|
+
# resolved to NOTHING. The leaf is still written -- orphaned, deliberately, never
|
|
512
|
+
# dropped -- but it is dead on arrival: retrieval, drill_down and the PreToolUse nudge
|
|
513
|
+
# all skip orphaned bindings, and no Tier-1 community fallback is created either, so the
|
|
514
|
+
# record has no delivery path through that anchor at all. Reported because the agent
|
|
515
|
+
# writing the draft is the only one who can still fix the name, and it used to get back
|
|
516
|
+
# a result indistinguishable from success. Same bucket vocabulary as add_anchors'.
|
|
517
|
+
anchors_orphaned: list[dict] = Field(default_factory=list)
|
|
518
|
+
# Attached facts (draft.facts) run through the same pipeline right after this decision
|
|
519
|
+
# writes — see _propose_one's facts loop. Defaulted to [] so every existing caller that
|
|
520
|
+
# builds/compares a bare ProposeResult (no facts) is unaffected.
|
|
521
|
+
facts: list[ProposeFactResult] = Field(default_factory=list)
|
|
522
|
+
# Live decisions already reachable via the draft's own anchors (design D2) — up to 3,
|
|
523
|
+
# deduped, newest first (see _live_neighbors); each {"id", "kind", "title", "status"}.
|
|
524
|
+
# A `deduped` result carries the existing duplicate record itself as its one neighbor
|
|
525
|
+
# (the general walk never runs on that early-return path); additive/defaulted so every
|
|
526
|
+
# existing caller comparing a bare ProposeResult is unaffected.
|
|
527
|
+
neighbors: list[dict] = Field(default_factory=list)
|
|
528
|
+
# Auto-ratification policy (design D2/D6) — same additive/defaulted contract as
|
|
529
|
+
# ProposeFactResult's own pair; see that class's docstring. Set by _propose_one's
|
|
530
|
+
# post-write auto block, AFTER the attached-facts loop above.
|
|
531
|
+
ratified_by: str | None = None
|
|
532
|
+
auto_ratify_error: str | None = None
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
_TEXT_FIELDS = ("title", "context", "choice", "rejected", "consequences")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class DraftDomain(BaseModel):
|
|
539
|
+
"""An agent-recognized Domain draft (§4.2, agent in-session path) — mirrors
|
|
540
|
+
DraftDecision's role for the decision side of capture."""
|
|
541
|
+
|
|
542
|
+
slug: str
|
|
543
|
+
title: str
|
|
544
|
+
summary: str
|
|
545
|
+
parent_slug: str | None = None
|
|
546
|
+
path_prefixes: list[str] = Field(default_factory=list)
|
|
547
|
+
# Durable, committed authoring intent (§2a amendment — replaces an earlier raw
|
|
548
|
+
# `communities` id seed, which review proved does NOT survive a fresh clone or a
|
|
549
|
+
# `graphify update` rebuild: community ids are volatile, Leiden renumbers them every
|
|
550
|
+
# build). Lets an agent-curated merge (several communities, no clean shared path) name
|
|
551
|
+
# its membership durably, by anchoring to entities instead of ids — exactly how a
|
|
552
|
+
# decision's own anchors resolve. May be given alongside path_prefixes, in place of it,
|
|
553
|
+
# or omitted (inert until a human adds a rule later). `propose_domains`/`ratify` resolve
|
|
554
|
+
# this to `communities` (see sync._recompute_domain_communities /
|
|
555
|
+
# sync.refresh_domain_communities_now) — never populated directly from the draft.
|
|
556
|
+
seed_anchors: list[Descriptor] = Field(default_factory=list)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class ProposeDomainResult(BaseModel):
|
|
560
|
+
status: str # "proposed" | "skipped" | "rejected"
|
|
561
|
+
domain_id: str | None = None
|
|
562
|
+
reason: str | None = None
|
|
563
|
+
redactions: int = 0
|
|
564
|
+
# design D7.4 (staleness-machinery wave, E8 gate checklist): deterministic lint
|
|
565
|
+
# warnings on this domain's path_prefixes -- see _lint_domain_path_prefixes. Advisory
|
|
566
|
+
# only, never blocks the write; under `auto-all` any warning keeps the draft proposed;
|
|
567
|
+
# empty when path_prefixes is empty or every prefix passes both checks.
|
|
568
|
+
# Additive/defaulted so every existing caller comparing a bare
|
|
569
|
+
# ProposeDomainResult is unaffected.
|
|
570
|
+
warnings: list[str] = Field(default_factory=list)
|
|
571
|
+
# Auto-ratification policy (design D2/D6) — same additive/defaulted contract as
|
|
572
|
+
# ProposeResult's own pair; see that class's docstring. Domains are eligible only under
|
|
573
|
+
# `auto-all` (never `auto-low-risk`). `auto_ratify_error` is prefixed `"activation: "`
|
|
574
|
+
# when the domain's own transition succeeded but `sync.activate_accepted_domain`
|
|
575
|
+
# couldn't resolve its membership — accepted-but-unhealed stays visible, never silent.
|
|
576
|
+
ratified_by: str | None = None
|
|
577
|
+
auto_ratify_error: str | None = None
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _derive_initiative() -> str | None:
|
|
581
|
+
"""feature/aaa branch -> 'feature-aaa'. Best-effort; None on main/master or any error."""
|
|
582
|
+
try:
|
|
583
|
+
out = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True)
|
|
584
|
+
branch = out.stdout.strip()
|
|
585
|
+
if out.returncode != 0 or not branch or branch in ("main", "master"):
|
|
586
|
+
return None
|
|
587
|
+
return branch.replace("/", "-")
|
|
588
|
+
except (OSError, subprocess.SubprocessError):
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _capture_commit(store: Store) -> str | None:
|
|
593
|
+
"""``git rev-parse HEAD`` -- best-effort capture-time HEAD stamp (design D1).
|
|
594
|
+
|
|
595
|
+
Runs with ``cwd=store.path`` — the STORE's own directory, never the ambient process
|
|
596
|
+
cwd (CORRECTION-2, code review) — so the commit always names the repo the store
|
|
597
|
+
actually lives in, regardless of where the calling process happens to be running
|
|
598
|
+
from. This matters concretely for D5's doctor ``code-drift`` check: it diffs a
|
|
599
|
+
stamped commit against ``HEAD`` in the repo it resolves from the STORE's directory
|
|
600
|
+
(``verify._find_repo_root``), so a commit stamped against the wrong repo would
|
|
601
|
+
silently degrade every batch to the git-unavailable note. ``None`` on any failure (no
|
|
602
|
+
repo containing the store, git missing, non-zero exit), never raising. Also used by
|
|
603
|
+
``server._supersede_decision_impl`` (D6) so both write paths that ever construct a
|
|
604
|
+
fresh ``Provenance`` stamp ``commit`` identically."""
|
|
605
|
+
try:
|
|
606
|
+
out = subprocess.run(
|
|
607
|
+
["git", "rev-parse", "HEAD"], cwd=store.path, capture_output=True, text=True
|
|
608
|
+
)
|
|
609
|
+
commit = out.stdout.strip()
|
|
610
|
+
if out.returncode != 0 or not commit:
|
|
611
|
+
return None
|
|
612
|
+
return commit
|
|
613
|
+
except (OSError, subprocess.SubprocessError):
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# Freshness window for the D7.3 session_id fallback (below) — a marker older than this is
|
|
618
|
+
# worse than no attribution at all (a long-abandoned session's id leaking onto an
|
|
619
|
+
# unrelated later capture).
|
|
620
|
+
_SESSION_ID_FALLBACK_MAX_AGE_SECONDS = 24 * 60 * 60
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _session_id_fallback(store: Store) -> str | None:
|
|
624
|
+
"""Best-effort ``provenance.session_id`` source when the caller passed none (design
|
|
625
|
+
D7.3 — E8 measured ``author=None session=None`` on Stop-channel captures). Reads
|
|
626
|
+
``TELEMETRY_SESSION_KEY`` (``config.py``), the same ``<session_id>|<iso-timestamp>``
|
|
627
|
+
marker ``host.hooks.session_start`` now stamps UNCONDITIONALLY (D7.3 generalized that
|
|
628
|
+
write off its old ``telemetry_enabled()`` gate specifically so this fallback always has
|
|
629
|
+
something to read) — used only when fresh (< :data:`_SESSION_ID_FALLBACK_MAX_AGE_SECONDS`
|
|
630
|
+
old); a stale marker from a long-abandoned session is worse than no attribution at all.
|
|
631
|
+
Never raises: an absent key, an unparseable value, a naive/malformed timestamp, or a
|
|
632
|
+
``store.get_meta`` failure itself (NIT-5, code review — the read wasn't actually
|
|
633
|
+
guarded before, despite this docstring's own claim) all return ``None``, same as
|
|
634
|
+
passing no session_id at all — this is provenance, not security, and capture must
|
|
635
|
+
never fail the caller over a best-effort attribution guess.
|
|
636
|
+
"""
|
|
637
|
+
try:
|
|
638
|
+
raw = store.get_meta(TELEMETRY_SESSION_KEY)
|
|
639
|
+
except Exception:
|
|
640
|
+
return None
|
|
641
|
+
if raw is None:
|
|
642
|
+
return None
|
|
643
|
+
session_id, _, stamp = raw.partition("|")
|
|
644
|
+
if not session_id:
|
|
645
|
+
return None
|
|
646
|
+
try:
|
|
647
|
+
written = datetime.fromisoformat(stamp)
|
|
648
|
+
except ValueError:
|
|
649
|
+
return None
|
|
650
|
+
if written.tzinfo is None:
|
|
651
|
+
return None # schema requires aware timestamps; a naive one can't be compared safely
|
|
652
|
+
age = (datetime.now(UTC) - written).total_seconds()
|
|
653
|
+
if age >= _SESSION_ID_FALLBACK_MAX_AGE_SECONDS:
|
|
654
|
+
return None
|
|
655
|
+
return session_id
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _bind_orphaned(
|
|
659
|
+
record_id: str,
|
|
660
|
+
anchor: Descriptor,
|
|
661
|
+
store: Store,
|
|
662
|
+
relation: Relation | None = None,
|
|
663
|
+
) -> None:
|
|
664
|
+
"""No engine available: record the anchor as an orphaned leaf (never silently dropped)."""
|
|
665
|
+
# Atomic get-or-create (design D3): the old find_entity + upsert_entity longhand upserted
|
|
666
|
+
# unconditionally even on a hit, which is a no-op (identity unchanged -> an index-only
|
|
667
|
+
# rewrite, see Store.upsert_entity's docstring) -- unlike anchoring.py's Tier-2 leaf, this
|
|
668
|
+
# call site has no engine mapping to refresh, so the collapse loses nothing.
|
|
669
|
+
entity = store.get_or_create_entity(anchor)
|
|
670
|
+
# Explicit kwarg rather than a splatted dict (see anchoring.resolve_and_bind for the
|
|
671
|
+
# same pattern/rationale) — AnchorBinding's own default is "affects".
|
|
672
|
+
store.add_binding(
|
|
673
|
+
AnchorBinding(
|
|
674
|
+
record_id=record_id,
|
|
675
|
+
entity_id=entity.entity_id,
|
|
676
|
+
tier=2,
|
|
677
|
+
status="orphaned",
|
|
678
|
+
relation=relation if relation is not None else "affects",
|
|
679
|
+
)
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# Neighbors cap (design D2, "Thresholds" — chosen, not measured): how many live decisions
|
|
684
|
+
# `_live_neighbors` ever returns, after dedup-by-id and ULID-descending ordering.
|
|
685
|
+
_NEIGHBORS_CAP = 3
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _live_neighbors(draft: DraftDecision, store: Store) -> list[Decision]:
|
|
689
|
+
"""Live decisions already sharing one of ``draft``'s own ``anchors`` entities (design
|
|
690
|
+
D2) — the walk ``_is_duplicate`` always performed, factored out so a new reporting step
|
|
691
|
+
(``ProposeResult.neighbors``, below) can reuse it instead of re-walking the store.
|
|
692
|
+
|
|
693
|
+
Walks ``draft.anchors`` specifically — never the record's eventual post-pipeline
|
|
694
|
+
entity set (initiative/tag bindings, attached only after the write) — for two
|
|
695
|
+
load-bearing reasons, which is also why every call site (this function's own callers)
|
|
696
|
+
must run BEFORE ``store.add_decision``: (1) self-inclusion only becomes real AFTER
|
|
697
|
+
ANCHORING (step 5, below) has bound the record to its own anchor entity — not merely
|
|
698
|
+
after the write itself (correction, code review: the record carries no binding at
|
|
699
|
+
all yet at that earlier boundary, so ``valid_decisions_for_entity`` can't yet return
|
|
700
|
+
it) — but once anchoring HAS run, that same call (excludes only SUPERSEDED/REJECTED)
|
|
701
|
+
would return the record being written as its own neighbor; (2) initiative/tag entities
|
|
702
|
+
are shared by half the store and would flood the result with noise instead of the
|
|
703
|
+
on-topic Tier-2 leaf(s) the draft actually names.
|
|
704
|
+
|
|
705
|
+
Deduped by ``d.id`` BEFORE the cap — concatenating each anchor's own walk would
|
|
706
|
+
otherwise return one decision once PER shared anchor, and capping first could deliver
|
|
707
|
+
several copies of the same record and zero breadth (the E9 probe this closes: a real
|
|
708
|
+
pair shared 5 entities, of which the signal was one Tier-2 leaf). Ordered by id
|
|
709
|
+
descending (ULIDs sort by creation time, newest first; ``valid_decisions_for_entity``
|
|
710
|
+
itself is an unsorted binding scan) and capped at :data:`_NEIGHBORS_CAP`.
|
|
711
|
+
|
|
712
|
+
Used by both ``_is_duplicate`` (the exact kind+title dedup check) and ``_propose_one``'s
|
|
713
|
+
reporting step, so the two can never disagree about which live records the draft's own
|
|
714
|
+
anchors currently reach.
|
|
715
|
+
"""
|
|
716
|
+
by_id: dict[str, Decision] = {}
|
|
717
|
+
for anchor in draft.anchors:
|
|
718
|
+
entity = store.resolve_descriptor(anchor.name, anchor.file_path)
|
|
719
|
+
if entity is None:
|
|
720
|
+
continue
|
|
721
|
+
for d in store.valid_decisions_for_entity(entity.entity_id):
|
|
722
|
+
by_id[d.id] = d
|
|
723
|
+
return sorted(by_id.values(), key=lambda d: d.id, reverse=True)[:_NEIGHBORS_CAP]
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _neighbor_dict(d: Decision) -> dict:
|
|
727
|
+
"""Render one live neighbor for ``ProposeResult.neighbors`` (design D2): just enough for
|
|
728
|
+
an agent to decide whether to ``supersede_decision`` it — the full record is a
|
|
729
|
+
``get_task_context``/``drill_down`` call away."""
|
|
730
|
+
return {"id": d.id, "kind": d.kind.value, "title": d.title, "status": d.status.value}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _is_duplicate(draft: DraftDecision, store: Store) -> str | None:
|
|
734
|
+
"""Deterministic minimal dedup: same kind + canonical title among the draft's live
|
|
735
|
+
neighbors (:func:`_live_neighbors` — shared-anchor walk, design D2)."""
|
|
736
|
+
target = canonicalize(draft.title)
|
|
737
|
+
for d in _live_neighbors(draft, store):
|
|
738
|
+
if d.kind == draft.kind and canonicalize(d.title) == target:
|
|
739
|
+
return d.id
|
|
740
|
+
return None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _is_duplicate_fact(
|
|
744
|
+
statement: str, anchors: list[AnchorDraft], supports: list[str], store: Store
|
|
745
|
+
) -> str | None:
|
|
746
|
+
"""Deterministic minimal dedup, mirrors ``_is_duplicate``: same canonicalized statement
|
|
747
|
+
on a shared SUPPORTED decision, or on a shared anchor entity -> deduped. Conservative:
|
|
748
|
+
when unsure, write (the human drops at ratify).
|
|
749
|
+
|
|
750
|
+
The anchor-entity check deliberately walks ``bindings_for_entity`` + ``get_fact``
|
|
751
|
+
(matching ``facts_for_decision``'s own ACCEPTED/PROPOSED status filter) rather than
|
|
752
|
+
``valid_facts_for_entity`` — that helper skips ``orphaned`` bindings (correct for
|
|
753
|
+
*retrieval*, which cares whether a binding currently resolves in the graph), but every
|
|
754
|
+
anchor bound without a reader (``_bind_orphaned``, the common capture-time path) is
|
|
755
|
+
``orphaned`` by construction. Entity identity (same canonical_name + file_path, per
|
|
756
|
+
``find_entity``) is independent of the binding's graph-resolution health, so dedup must
|
|
757
|
+
not miss an orphaned duplicate.
|
|
758
|
+
"""
|
|
759
|
+
canon = canonicalize(statement)
|
|
760
|
+
for sid in supports:
|
|
761
|
+
for f in store.facts_for_decision(sid):
|
|
762
|
+
if canonicalize(f.statement) == canon:
|
|
763
|
+
return f.id
|
|
764
|
+
for anchor in anchors:
|
|
765
|
+
entity = store.resolve_descriptor(anchor.name, anchor.file_path)
|
|
766
|
+
if entity is None:
|
|
767
|
+
continue
|
|
768
|
+
for b in store.bindings_for_entity(entity.entity_id):
|
|
769
|
+
existing = store.get_fact(b.record_id)
|
|
770
|
+
if existing is None or existing.status not in (
|
|
771
|
+
DecisionStatus.ACCEPTED,
|
|
772
|
+
DecisionStatus.PROPOSED,
|
|
773
|
+
):
|
|
774
|
+
continue
|
|
775
|
+
if canonicalize(existing.statement) == canon:
|
|
776
|
+
return existing.id
|
|
777
|
+
return None
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _resolves_to_live_decision(store: Store, supports: Sequence[str]) -> bool:
|
|
781
|
+
"""True iff at least one id in ``supports`` names a decision that is still LIVE
|
|
782
|
+
(``accepted``/``proposed``) — design D8's write-side half of the same "live" reachability
|
|
783
|
+
rule doctor's tightened ``dangling-record`` check applies on read (D4). A fact this gate
|
|
784
|
+
accepts is never something that check would go on to flag as unreachable.
|
|
785
|
+
|
|
786
|
+
Shared by this module's own anchorless-fact gate (below) and ``server.py``'s
|
|
787
|
+
``_add_fact_impl``/``supersede_fact`` (the same rule, at the two human-asked entry
|
|
788
|
+
points) — three write paths, one definition of "live", so they can never drift apart.
|
|
789
|
+
"""
|
|
790
|
+
return any(
|
|
791
|
+
(d := store.get_decision(sid)) is not None
|
|
792
|
+
# Derived from the terminal set, never spelled out as (ACCEPTED, PROPOSED): doctor's
|
|
793
|
+
# half of this rule computes "live" the same way (``doctor._TERMINAL_DECISION_STATUS_
|
|
794
|
+
# VALUES``), and D4 requires the two complements to match EXACTLY. A hard-coded pair
|
|
795
|
+
# here would silently drift the moment a sixth DecisionStatus is added — the write
|
|
796
|
+
# gate would keep accepting what the read check had started flagging, which is the
|
|
797
|
+
# contradiction this whole wave exists to remove (branch review, Low-1).
|
|
798
|
+
and d.status not in _TERMINAL_DECISION_STATUSES
|
|
799
|
+
for sid in supports
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _supports_a_still_proposed_decision(store: Store, supports: Sequence[str]) -> bool:
|
|
804
|
+
"""True iff at least one id in ``supports`` names a decision that is still
|
|
805
|
+
``proposed`` — the store's own nested-evidence definition
|
|
806
|
+
(``Store.pending_ratification_counts``): such a fact is covered by that decision's
|
|
807
|
+
own verdict (its accept cascade, at ``_propose_one``'s post-write block after the
|
|
808
|
+
facts loop, or its drop cascade, at ``Store.drop``) and must never certify itself
|
|
809
|
+
ahead of it, even when the fact arrives later, through its own ``propose_facts``
|
|
810
|
+
call (design D2 rev 12 erratum). A missing id cannot occur here — ``Store.add_fact``
|
|
811
|
+
rejects unknown ``supports`` ids at write time.
|
|
812
|
+
"""
|
|
813
|
+
return any(
|
|
814
|
+
(d := store.get_decision(sid)) is not None and d.status == DecisionStatus.PROPOSED
|
|
815
|
+
for sid in supports
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _propose_fact_one(
|
|
820
|
+
raw: object,
|
|
821
|
+
store: Store,
|
|
822
|
+
reader: GraphifyReader | None,
|
|
823
|
+
session_id: str | None,
|
|
824
|
+
author: str | None,
|
|
825
|
+
graph_version: str | None,
|
|
826
|
+
*,
|
|
827
|
+
attached_to: str | None = None,
|
|
828
|
+
inherited_anchors: list[AnchorDraft] | None = None,
|
|
829
|
+
auto_accept: bool = False,
|
|
830
|
+
ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
|
|
831
|
+
) -> ProposeFactResult:
|
|
832
|
+
"""One Fact draft through the deterministic pipeline — mirrors ``_propose_one``'s
|
|
833
|
+
stages exactly (see that function; each stage below cites its counterpart there).
|
|
834
|
+
|
|
835
|
+
``attached_to``/``inherited_anchors`` are set only when called from a
|
|
836
|
+
``DraftDecision.facts`` entry (see ``_propose_one``'s facts loop, below): ``supports``
|
|
837
|
+
always includes the decision being written (plus the draft's own ``supports``), and a
|
|
838
|
+
fact with no anchors of its own inherits the DECISION's anchors — but bindings are
|
|
839
|
+
always minted on the FACT's own id, so it survives the decision independently. A
|
|
840
|
+
standalone ``propose_facts`` draft passes neither, and must supply its own anchor or
|
|
841
|
+
supports id (see the reachability check below).
|
|
842
|
+
|
|
843
|
+
``auto_accept`` (default ``False``): when true (``SIDEGRAPH_AUTO_ACCEPT=on``, threaded
|
|
844
|
+
down from ``propose_facts``/``propose``'s own ``auto_accept`` — see
|
|
845
|
+
design/superpowers/specs/2026-07-10-ratification-ux-and-mcp-gaps-design.md), the fact
|
|
846
|
+
lands ``status=ACCEPTED`` instead of ``PROPOSED``, skipping the ratification queue.
|
|
847
|
+
Provenance still stamps ``source="agent"`` regardless — history never lies about who
|
|
848
|
+
authored a record, only whether a human reviewed it.
|
|
849
|
+
|
|
850
|
+
``ratify_policy`` (default ``RatifyPolicy.MANUAL``): the post-write auto-ratify block
|
|
851
|
+
below only ever runs when ``attached_to is None`` — design D2/D3's standalone-only
|
|
852
|
+
restriction. An ATTACHED fact is never independently eligible (gate 1's shape test is
|
|
853
|
+
the OWNING DECISION's, not this fact's); it rides that decision's own cascade instead
|
|
854
|
+
(see ``_propose_one``'s post-write block), which is why this parameter is accepted here
|
|
855
|
+
unconditionally but only ever consulted inside the ``attached_to is None`` branch.
|
|
856
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D2/D3
|
|
857
|
+
"""
|
|
858
|
+
# I1 (R1 improvement wave §1): the D7.3 session_id fallback (see
|
|
859
|
+
# _session_id_fallback's docstring), applied here exactly as _propose_one applies it
|
|
860
|
+
# to its own decision. _propose_one already resolves session_id BEFORE calling this
|
|
861
|
+
# function for an ATTACHED fact, so this is a no-op there (never overwrites a real
|
|
862
|
+
# id); a STANDALONE propose_facts draft never goes through _propose_one at all, so
|
|
863
|
+
# without this line here it landed session_id=None even with a fresh marker present
|
|
864
|
+
# (measured defect: both R1 facts came through this exact path).
|
|
865
|
+
if session_id is None:
|
|
866
|
+
session_id = _session_id_fallback(store)
|
|
867
|
+
|
|
868
|
+
# 1. Validate.
|
|
869
|
+
try:
|
|
870
|
+
draft = DraftFact.model_validate(raw)
|
|
871
|
+
except ValidationError as e:
|
|
872
|
+
return ProposeFactResult(status="rejected", reason=f"invalid draft: {e}")
|
|
873
|
+
|
|
874
|
+
# 2. Redact first — same gate as _propose_one's text fields; the scrubbed text is the
|
|
875
|
+
# only text that proceeds to reachability/dedup/storage.
|
|
876
|
+
statement, n1 = redact(draft.statement)
|
|
877
|
+
source, n2 = redact(draft.source)
|
|
878
|
+
redactions = n1 + n2
|
|
879
|
+
|
|
880
|
+
# 3. Reachability: own anchors override inherited ones entirely (own present -> only
|
|
881
|
+
# own, matching DraftDecision anchors being the sole anchor list, never additive with
|
|
882
|
+
# anything). A standalone fact with no anchor is rejected unless its `supports` names at
|
|
883
|
+
# least one LIVE decision (design D8) — existence alone used to be enough, but a fact
|
|
884
|
+
# whose only supports id has since gone terminal is born flagged the moment it lands
|
|
885
|
+
# (doctor's tightened dangling-record check, D4/D6). "Anchorless" is defined by the
|
|
886
|
+
# REQUEST here (this function's own `anchors`, not a resolved outcome) — D8's residual.
|
|
887
|
+
anchors = draft.anchors or (inherited_anchors or [])
|
|
888
|
+
supports = ([attached_to] if attached_to else []) + list(draft.supports)
|
|
889
|
+
if not anchors and not _resolves_to_live_decision(store, supports):
|
|
890
|
+
reason = (
|
|
891
|
+
"standalone fact needs at least one anchor or a supports id"
|
|
892
|
+
if not supports
|
|
893
|
+
else (
|
|
894
|
+
"standalone fact's supports resolve only to a superseded/rejected/"
|
|
895
|
+
"deprecated decision — add an anchor, or re-point supports at the successor"
|
|
896
|
+
)
|
|
897
|
+
)
|
|
898
|
+
return ProposeFactResult(status="rejected", reason=reason)
|
|
899
|
+
|
|
900
|
+
# 4. Dedup (conservative: when unsure, write; the human drops at ratify).
|
|
901
|
+
dup_id = _is_duplicate_fact(statement, anchors, supports, store)
|
|
902
|
+
if dup_id is not None:
|
|
903
|
+
return ProposeFactResult(
|
|
904
|
+
status="deduped", fact_id=dup_id, reason="duplicate", redactions=redactions
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
# 5. Package + validate + write (append-only; supersede closes the predecessor).
|
|
908
|
+
try:
|
|
909
|
+
fact = Fact(
|
|
910
|
+
statement=statement,
|
|
911
|
+
source=source,
|
|
912
|
+
supports=supports,
|
|
913
|
+
status=DecisionStatus.ACCEPTED if auto_accept else DecisionStatus.PROPOSED,
|
|
914
|
+
valid_from=datetime.now(UTC),
|
|
915
|
+
provenance=Provenance(
|
|
916
|
+
source="agent",
|
|
917
|
+
author=author,
|
|
918
|
+
session_id=session_id,
|
|
919
|
+
graph_version=graph_version,
|
|
920
|
+
# П0 (git-bindings design, Blocker 1): the same best-effort HEAD stamp
|
|
921
|
+
# _propose_one already applies to its own decision -- mechanically the I1
|
|
922
|
+
# twin (commit 4b1c927), closing the fact/decision mirror so П1 rule (a)
|
|
923
|
+
# and П2's provenance join can ever match a fact.
|
|
924
|
+
commit=_capture_commit(store),
|
|
925
|
+
),
|
|
926
|
+
)
|
|
927
|
+
store.add_fact(fact)
|
|
928
|
+
except (ValidationError, ValueError) as e:
|
|
929
|
+
return ProposeFactResult(status="rejected", reason=str(e), redactions=redactions)
|
|
930
|
+
|
|
931
|
+
# 6. Anchor — identical ladder to _propose_one's (see that function's step 5): anchors
|
|
932
|
+
# carry their own optional relation override; strip it before handing the bare
|
|
933
|
+
# Descriptor to resolve_and_bind/_bind_orphaned (which take the override as a separate
|
|
934
|
+
# argument). resolve_and_bind's return already carries the reader.resolve() outcome, so
|
|
935
|
+
# an ambiguous anchor is reported back (Gate-5 finding S3) without a second resolve()
|
|
936
|
+
# call. Bindings are minted on fact.id (never on attached_to) — this is what lets an
|
|
937
|
+
# attached fact survive its decision independently.
|
|
938
|
+
anchors_skipped: list[dict] = []
|
|
939
|
+
anchors_orphaned: list[dict] = []
|
|
940
|
+
if reader is not None:
|
|
941
|
+
for anchor in anchors:
|
|
942
|
+
result = resolve_and_bind(
|
|
943
|
+
fact.id,
|
|
944
|
+
Descriptor(name=anchor.name, file_path=anchor.file_path),
|
|
945
|
+
reader,
|
|
946
|
+
store,
|
|
947
|
+
relation=anchor.relation,
|
|
948
|
+
)
|
|
949
|
+
if result.status == "ambiguous":
|
|
950
|
+
anchors_skipped.append(
|
|
951
|
+
{
|
|
952
|
+
"name": anchor.name,
|
|
953
|
+
"reason": "ambiguous",
|
|
954
|
+
"candidates": result.candidates[:5],
|
|
955
|
+
}
|
|
956
|
+
)
|
|
957
|
+
elif result.status == "unresolved":
|
|
958
|
+
reason = orphan_reason(
|
|
959
|
+
Descriptor(name=anchor.name, file_path=anchor.file_path), reader
|
|
960
|
+
)
|
|
961
|
+
anchors_orphaned.extend(
|
|
962
|
+
{**s, "reason": reason}
|
|
963
|
+
for s in entity_summaries(store, [b for b in result if b.tier == 2])
|
|
964
|
+
)
|
|
965
|
+
else:
|
|
966
|
+
for anchor in anchors:
|
|
967
|
+
before = {b.entity_id for b in store.bindings_for_record(fact.id)}
|
|
968
|
+
_bind_orphaned(
|
|
969
|
+
fact.id,
|
|
970
|
+
Descriptor(name=anchor.name, file_path=anchor.file_path),
|
|
971
|
+
store,
|
|
972
|
+
relation=anchor.relation,
|
|
973
|
+
)
|
|
974
|
+
anchors_orphaned.extend(
|
|
975
|
+
{**s, "reason": "no-graph"}
|
|
976
|
+
for s in entity_summaries(
|
|
977
|
+
store,
|
|
978
|
+
[
|
|
979
|
+
b
|
|
980
|
+
for b in store.bindings_for_record(fact.id)
|
|
981
|
+
if b.tier == 2 and b.entity_id not in before
|
|
982
|
+
],
|
|
983
|
+
)
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
# 7. Auto-ratify (design D2/D3) — standalone facts ONLY: a fact is a standalone
|
|
987
|
+
# candidate iff `attached_to is None` (not created inside a `DraftDecision.facts`
|
|
988
|
+
# entry — an attached fact is never independently eligible, it rides its decision's
|
|
989
|
+
# cascade instead, see _propose_one's own post-write block, after its facts loop) AND
|
|
990
|
+
# none of its `supports` ids resolves to a still-proposed decision (rev 12 erratum:
|
|
991
|
+
# such a fact rides THAT decision's verdict too, even when it arrives later through
|
|
992
|
+
# its own propose_facts call — see _supports_a_still_proposed_decision). Skipped
|
|
993
|
+
# outright when `auto_accept` is true — the fact already landed ACCEPTED above, and the
|
|
994
|
+
# hook runs ONLY on writes that landed proposed (D2).
|
|
995
|
+
ratified_by: str | None = None
|
|
996
|
+
auto_ratify_error: str | None = None
|
|
997
|
+
if (
|
|
998
|
+
attached_to is None
|
|
999
|
+
and not auto_accept
|
|
1000
|
+
and ratify_policy != RatifyPolicy.MANUAL
|
|
1001
|
+
and not _supports_a_still_proposed_decision(store, fact.supports)
|
|
1002
|
+
):
|
|
1003
|
+
live_tier12, ambiguous_or_orphan_only = _anchor_signal(store, fact.id)
|
|
1004
|
+
signal = AutoEligibility(
|
|
1005
|
+
kind="fact",
|
|
1006
|
+
live_tier12=live_tier12,
|
|
1007
|
+
ambiguous_or_orphan_only=ambiguous_or_orphan_only,
|
|
1008
|
+
pipeline_clean=True,
|
|
1009
|
+
has_provenance=True,
|
|
1010
|
+
domain_anchored=False,
|
|
1011
|
+
has_supersedes=fact.supersedes is not None,
|
|
1012
|
+
)
|
|
1013
|
+
if auto_ratify_eligible(signal, ratify_policy):
|
|
1014
|
+
outcome = _auto_ratify(store, fact.id, "fact", ratify_policy)
|
|
1015
|
+
ratified_by = outcome.ratified_by
|
|
1016
|
+
auto_ratify_error = outcome.error
|
|
1017
|
+
|
|
1018
|
+
return ProposeFactResult(
|
|
1019
|
+
status="written",
|
|
1020
|
+
fact_id=fact.id,
|
|
1021
|
+
redactions=redactions,
|
|
1022
|
+
anchors_skipped=anchors_skipped,
|
|
1023
|
+
anchors_orphaned=anchors_orphaned,
|
|
1024
|
+
ratified_by=ratified_by,
|
|
1025
|
+
auto_ratify_error=auto_ratify_error,
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _propose_one(
|
|
1030
|
+
raw: object,
|
|
1031
|
+
store: Store,
|
|
1032
|
+
reader: GraphifyReader | None,
|
|
1033
|
+
session_id: str | None,
|
|
1034
|
+
author: str | None,
|
|
1035
|
+
ref: str | None,
|
|
1036
|
+
graph_version: str | None,
|
|
1037
|
+
*,
|
|
1038
|
+
auto_accept: bool = False,
|
|
1039
|
+
ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
|
|
1040
|
+
) -> ProposeResult:
|
|
1041
|
+
"""One DraftDecision through the deterministic pipeline (see module docstring for the
|
|
1042
|
+
overall redact -> validate -> package -> anchor -> dedup -> write stages).
|
|
1043
|
+
|
|
1044
|
+
``auto_accept`` (default ``False``): when true (``SIDEGRAPH_AUTO_ACCEPT=on`` — see
|
|
1045
|
+
design/superpowers/specs/2026-07-10-ratification-ux-and-mcp-gaps-design.md), the
|
|
1046
|
+
decision AND every attached fact (draft.facts, via the facts loop below) land
|
|
1047
|
+
``status=ACCEPTED`` instead of ``PROPOSED``, bypassing the ratification queue.
|
|
1048
|
+
Provenance still stamps ``source="agent"`` regardless.
|
|
1049
|
+
|
|
1050
|
+
``ratify_policy`` (default ``RatifyPolicy.MANUAL``): when it allows and D3's gates pass
|
|
1051
|
+
(including the cascade rule over this call's own attached facts — see the post-write
|
|
1052
|
+
block after step 7, below), the decision is auto-ratified through the same
|
|
1053
|
+
``Store.ratify`` a human tap calls, stamped ``"auto:<policy>"``. Ignored entirely when
|
|
1054
|
+
``auto_accept`` is true — that write already landed ACCEPTED, and the hook runs ONLY on
|
|
1055
|
+
writes that landed ``proposed`` (design D2).
|
|
1056
|
+
"""
|
|
1057
|
+
try:
|
|
1058
|
+
draft = DraftDecision.model_validate(raw)
|
|
1059
|
+
except ValidationError as e:
|
|
1060
|
+
return ProposeResult(status="rejected", reason=f"invalid draft: {e}")
|
|
1061
|
+
|
|
1062
|
+
# D7.3: best-effort session_id fallback when the caller passed none (E8 measured
|
|
1063
|
+
# author=None session=None on Stop-channel captures) — see _session_id_fallback's
|
|
1064
|
+
# docstring. Resolved once, up front, so this decision's own Provenance AND any
|
|
1065
|
+
# attached facts (draft.facts, via the facts loop below, which already threads
|
|
1066
|
+
# `session_id` straight through) land the same attribution, rather than the decision
|
|
1067
|
+
# getting one value and its own evidence another.
|
|
1068
|
+
if session_id is None:
|
|
1069
|
+
session_id = _session_id_fallback(store)
|
|
1070
|
+
|
|
1071
|
+
# 1. Redact first — the scrubbed text is the only text that proceeds. Tags are free
|
|
1072
|
+
# text until slugified, so they go through the same gate.
|
|
1073
|
+
redactions = 0
|
|
1074
|
+
clean: dict[str, str | None] = {}
|
|
1075
|
+
for name in _TEXT_FIELDS:
|
|
1076
|
+
value = getattr(draft, name)
|
|
1077
|
+
if value is None:
|
|
1078
|
+
clean[name] = None
|
|
1079
|
+
else:
|
|
1080
|
+
scrubbed, n = redact(value)
|
|
1081
|
+
clean[name] = scrubbed
|
|
1082
|
+
redactions += n
|
|
1083
|
+
|
|
1084
|
+
tag_slugs: list[str] = []
|
|
1085
|
+
for tag in draft.tags:
|
|
1086
|
+
scrubbed, n = redact(tag)
|
|
1087
|
+
redactions += n
|
|
1088
|
+
slug = slugify(scrubbed)
|
|
1089
|
+
# A tag whose entire text WAS the secret redacts down to "[REDACTED]" -> slugifies
|
|
1090
|
+
# to exactly "redacted" -- skip it (never mint a nameless `tag:redacted` entity
|
|
1091
|
+
# that leaks nothing but also means nothing; see M2 review fold-in). A tag that
|
|
1092
|
+
# merely CONTAINS "redacted" alongside real words (e.g. "redacted-config") still
|
|
1093
|
+
# slugifies to something else and is kept.
|
|
1094
|
+
if slug and slug != "redacted":
|
|
1095
|
+
tag_slugs.append(slug)
|
|
1096
|
+
|
|
1097
|
+
# 2. Dedup (conservative: when unsure, write; the human drops at ratify).
|
|
1098
|
+
dup_id = _is_duplicate(draft, store)
|
|
1099
|
+
if dup_id is not None:
|
|
1100
|
+
dup = store.get_decision(dup_id)
|
|
1101
|
+
return ProposeResult(
|
|
1102
|
+
status="deduped",
|
|
1103
|
+
reason=f"duplicate of {dup_id}",
|
|
1104
|
+
redactions=redactions,
|
|
1105
|
+
# The dedup early-return means the general neighbors walk below never runs on
|
|
1106
|
+
# this path (design D2) — yet "agent re-proposed the same title" is exactly
|
|
1107
|
+
# where supersession advice matters most, so the dup itself rides as the one
|
|
1108
|
+
# neighbor. `dup` is always found here in practice (it was just looked up by
|
|
1109
|
+
# this same store), but the None guard keeps this path never-fail regardless.
|
|
1110
|
+
neighbors=[_neighbor_dict(dup)] if dup is not None else [],
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
# 2b. Neighbors (design D2): live decisions the draft's own anchors already reach,
|
|
1114
|
+
# reported back so the agent can consider superseding one instead of leaving a fresh,
|
|
1115
|
+
# possibly-contradicting record alongside it. MUST run here — pre-write, immediately
|
|
1116
|
+
# after the dedup check, before store.add_decision below — see _live_neighbors's
|
|
1117
|
+
# docstring for why a post-write walk would be wrong twice over.
|
|
1118
|
+
neighbors = [_neighbor_dict(d) for d in _live_neighbors(draft, store)]
|
|
1119
|
+
|
|
1120
|
+
# 3-4. Package + validate + write (append-only; supersede closes the predecessor).
|
|
1121
|
+
initiative = draft.initiative or _derive_initiative()
|
|
1122
|
+
# title/context/choice are required (non-Optional) on DraftDecision, and the loop above
|
|
1123
|
+
# only maps None -> None — they can only be None here if they went in None, which the
|
|
1124
|
+
# schema forbids. Only rejected/consequences are genuinely optional.
|
|
1125
|
+
assert clean["title"] is not None
|
|
1126
|
+
assert clean["context"] is not None
|
|
1127
|
+
assert clean["choice"] is not None
|
|
1128
|
+
try:
|
|
1129
|
+
decision = Decision(
|
|
1130
|
+
title=clean["title"],
|
|
1131
|
+
kind=draft.kind,
|
|
1132
|
+
status=DecisionStatus.ACCEPTED if auto_accept else DecisionStatus.PROPOSED,
|
|
1133
|
+
context=clean["context"],
|
|
1134
|
+
choice=clean["choice"],
|
|
1135
|
+
rejected=clean["rejected"],
|
|
1136
|
+
consequences=clean["consequences"],
|
|
1137
|
+
layer=draft.layer,
|
|
1138
|
+
valid_from=datetime.now(UTC),
|
|
1139
|
+
supersedes=draft.supersedes,
|
|
1140
|
+
provenance=Provenance(
|
|
1141
|
+
source="agent",
|
|
1142
|
+
ref=ref,
|
|
1143
|
+
author=author,
|
|
1144
|
+
session_id=session_id,
|
|
1145
|
+
graph_version=graph_version,
|
|
1146
|
+
commit=_capture_commit(store),
|
|
1147
|
+
),
|
|
1148
|
+
)
|
|
1149
|
+
# Deferred supersession for an auto-policy proposal (design D2/T12): closing the
|
|
1150
|
+
# predecessor eagerly (the default) would flip it to superseded BEFORE the
|
|
1151
|
+
# post-write eligibility block below can reject this successor -- leaving a
|
|
1152
|
+
# rejected/ineligible draft with an already-closed predecessor and no accepted
|
|
1153
|
+
# successor to show for it. `manual` and legacy `auto_accept=True` keep today's
|
|
1154
|
+
# eager close (a manual write is reviewed by a human either way; auto_accept lands
|
|
1155
|
+
# this decision ACCEPTED directly, so there is no gap to defer across). Only when
|
|
1156
|
+
# `ratify_policy` allows auto AND this write is landing `proposed` AND the draft
|
|
1157
|
+
# actually names a predecessor is the close deferred to a successful `Store.ratify`
|
|
1158
|
+
# (its own existing deferred-supersession branch performs it, `auto-all` and
|
|
1159
|
+
# `auto-low-risk` alike — `auto-all` admits the shape and can still fail a later
|
|
1160
|
+
# gate or lose the transition race, `auto-low-risk` almost always fails gate 1 for
|
|
1161
|
+
# a superseding draft, see D3 — either way the close must wait for that verdict).
|
|
1162
|
+
defer_close = (
|
|
1163
|
+
not auto_accept
|
|
1164
|
+
and ratify_policy != RatifyPolicy.MANUAL
|
|
1165
|
+
and draft.supersedes is not None
|
|
1166
|
+
)
|
|
1167
|
+
store.add_decision(decision, close_predecessor=not defer_close)
|
|
1168
|
+
except (ValidationError, ValueError) as e:
|
|
1169
|
+
return ProposeResult(status="rejected", reason=str(e), redactions=redactions)
|
|
1170
|
+
|
|
1171
|
+
# 5. Anchor (Stage-3 path with the engine; orphaned leaves without it). Anchors carry
|
|
1172
|
+
# their own optional relation override; strip it before handing the bare Descriptor to
|
|
1173
|
+
# resolve_and_bind/_bind_orphaned (which take the override as a separate argument).
|
|
1174
|
+
# resolve_and_bind's return already carries the reader.resolve() outcome (see
|
|
1175
|
+
# anchoring.AnchorResolution), so an ambiguous anchor is reported back (Gate-5 finding
|
|
1176
|
+
# S3) without a second resolve() call.
|
|
1177
|
+
anchors_skipped: list[dict] = []
|
|
1178
|
+
anchors_orphaned: list[dict] = []
|
|
1179
|
+
if reader is not None:
|
|
1180
|
+
for anchor in draft.anchors:
|
|
1181
|
+
result = resolve_and_bind(
|
|
1182
|
+
decision.id,
|
|
1183
|
+
Descriptor(name=anchor.name, file_path=anchor.file_path),
|
|
1184
|
+
reader,
|
|
1185
|
+
store,
|
|
1186
|
+
relation=anchor.relation,
|
|
1187
|
+
)
|
|
1188
|
+
if result.status == "ambiguous":
|
|
1189
|
+
anchors_skipped.append(
|
|
1190
|
+
{
|
|
1191
|
+
"name": anchor.name,
|
|
1192
|
+
"reason": "ambiguous",
|
|
1193
|
+
"candidates": result.candidates[:5],
|
|
1194
|
+
}
|
|
1195
|
+
)
|
|
1196
|
+
elif result.status == "unresolved":
|
|
1197
|
+
reason = orphan_reason(
|
|
1198
|
+
Descriptor(name=anchor.name, file_path=anchor.file_path), reader
|
|
1199
|
+
)
|
|
1200
|
+
anchors_orphaned.extend(
|
|
1201
|
+
{**s, "reason": reason}
|
|
1202
|
+
for s in entity_summaries(store, [b for b in result if b.tier == 2])
|
|
1203
|
+
)
|
|
1204
|
+
else:
|
|
1205
|
+
for anchor in draft.anchors:
|
|
1206
|
+
before = {b.entity_id for b in store.bindings_for_record(decision.id)}
|
|
1207
|
+
_bind_orphaned(
|
|
1208
|
+
decision.id,
|
|
1209
|
+
Descriptor(name=anchor.name, file_path=anchor.file_path),
|
|
1210
|
+
store,
|
|
1211
|
+
relation=anchor.relation,
|
|
1212
|
+
)
|
|
1213
|
+
anchors_orphaned.extend(
|
|
1214
|
+
{**s, "reason": "no-graph"}
|
|
1215
|
+
for s in entity_summaries(
|
|
1216
|
+
store,
|
|
1217
|
+
[
|
|
1218
|
+
b
|
|
1219
|
+
for b in store.bindings_for_record(decision.id)
|
|
1220
|
+
if b.tier == 2 and b.entity_id not in before
|
|
1221
|
+
],
|
|
1222
|
+
)
|
|
1223
|
+
)
|
|
1224
|
+
if initiative:
|
|
1225
|
+
init = store.get_or_create_abstract_entity(f"initiative:{initiative}")
|
|
1226
|
+
store.add_binding(
|
|
1227
|
+
AnchorBinding(
|
|
1228
|
+
record_id=decision.id,
|
|
1229
|
+
entity_id=init.entity_id,
|
|
1230
|
+
tier=0,
|
|
1231
|
+
status="live",
|
|
1232
|
+
)
|
|
1233
|
+
)
|
|
1234
|
+
# 6. Tags — durable, cross-cutting `tag:<slug>` entities (tier-0, no lifecycle; see
|
|
1235
|
+
# spec §2). Bound at propose time, same as initiative, so they carry through ratify.
|
|
1236
|
+
for slug in tag_slugs:
|
|
1237
|
+
tag_entity = store.get_or_create_abstract_entity(f"tag:{slug}")
|
|
1238
|
+
store.add_binding(
|
|
1239
|
+
AnchorBinding(
|
|
1240
|
+
record_id=decision.id,
|
|
1241
|
+
entity_id=tag_entity.entity_id,
|
|
1242
|
+
tier=0,
|
|
1243
|
+
)
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
# 7. Attached facts (draft.facts) -- each runs through the same deterministic pipeline,
|
|
1247
|
+
# supporting THIS decision and, absent their own anchors, inheriting its anchors (see
|
|
1248
|
+
# _propose_fact_one's docstring). A bad fact never aborts the decision or its siblings:
|
|
1249
|
+
# _propose_fact_one already catches every anticipated failure internally (mirrors this
|
|
1250
|
+
# function's own per-stage try/except), same isolation guarantee `propose` gives
|
|
1251
|
+
# per-draft.
|
|
1252
|
+
fact_results = [
|
|
1253
|
+
_propose_fact_one(
|
|
1254
|
+
raw_fact,
|
|
1255
|
+
store,
|
|
1256
|
+
reader,
|
|
1257
|
+
session_id,
|
|
1258
|
+
author,
|
|
1259
|
+
graph_version,
|
|
1260
|
+
attached_to=decision.id,
|
|
1261
|
+
inherited_anchors=draft.anchors,
|
|
1262
|
+
auto_accept=auto_accept,
|
|
1263
|
+
ratify_policy=ratify_policy,
|
|
1264
|
+
)
|
|
1265
|
+
for raw_fact in draft.facts
|
|
1266
|
+
]
|
|
1267
|
+
|
|
1268
|
+
# 8. Auto-ratify (design D2/D3) — runs AFTER step 7 so this call's own attached facts
|
|
1269
|
+
# already exist and can be checked as the cascade set. Skipped outright when
|
|
1270
|
+
# `auto_accept` is true (this decision already landed ACCEPTED, and the hook runs ONLY
|
|
1271
|
+
# on writes that landed proposed). The decision's own gates run first (cheaper); the
|
|
1272
|
+
# cascade rule (`_cascade_eligible`) runs only when the decision itself is already
|
|
1273
|
+
# eligible, and blocks the WHOLE auto-block when any fact in the cascade set is not —
|
|
1274
|
+
# decision AND facts stay proposed together, to leave the queue by one human verdict.
|
|
1275
|
+
ratified_by: str | None = None
|
|
1276
|
+
auto_ratify_error: str | None = None
|
|
1277
|
+
if not auto_accept and ratify_policy != RatifyPolicy.MANUAL:
|
|
1278
|
+
live_tier12, ambiguous_or_orphan_only = _anchor_signal(store, decision.id)
|
|
1279
|
+
decision_signal = AutoEligibility(
|
|
1280
|
+
kind=draft.kind.value,
|
|
1281
|
+
live_tier12=live_tier12,
|
|
1282
|
+
ambiguous_or_orphan_only=ambiguous_or_orphan_only,
|
|
1283
|
+
pipeline_clean=True,
|
|
1284
|
+
has_provenance=True,
|
|
1285
|
+
domain_anchored=False,
|
|
1286
|
+
has_supersedes=decision.supersedes is not None,
|
|
1287
|
+
)
|
|
1288
|
+
if auto_ratify_eligible(decision_signal, ratify_policy) and _cascade_eligible(
|
|
1289
|
+
store, decision.id, ratify_policy
|
|
1290
|
+
):
|
|
1291
|
+
# _cascade_eligible above is the cheap pre-check, run before Store.ratify takes
|
|
1292
|
+
# its write lock (design D2 checkpoint-2 fix, Ruling Q). _auto_ratify's own
|
|
1293
|
+
# decision route builds the authoritative re-check guard itself (Ruling T) and
|
|
1294
|
+
# hands it to Store.ratify, which re-runs the same per-fact test on a cascade set
|
|
1295
|
+
# re-queried fresh under that lock — closing the race where a fact could land
|
|
1296
|
+
# supporting this decision between the pre-check and the transition (external
|
|
1297
|
+
# review finding A1, scratchpad/probe_race.py).
|
|
1298
|
+
outcome = _auto_ratify(store, decision.id, decision_signal.kind, ratify_policy)
|
|
1299
|
+
ratified_by = outcome.ratified_by
|
|
1300
|
+
auto_ratify_error = outcome.error
|
|
1301
|
+
if outcome.cascaded_fact_ids:
|
|
1302
|
+
# Result truthfulness (design D6/T11/T14): the canonical cascade just
|
|
1303
|
+
# accepted these facts through Store.ratify -- stamp the matching NESTED
|
|
1304
|
+
# ProposeFactResult objects too, so the public result cannot say
|
|
1305
|
+
# `ratified_by=None` for a fact whose canonical row was just accepted.
|
|
1306
|
+
cascaded_ids = set(outcome.cascaded_fact_ids)
|
|
1307
|
+
fact_results = [
|
|
1308
|
+
fr.model_copy(update={"ratified_by": ratified_by})
|
|
1309
|
+
if fr.fact_id in cascaded_ids
|
|
1310
|
+
else fr
|
|
1311
|
+
for fr in fact_results
|
|
1312
|
+
]
|
|
1313
|
+
|
|
1314
|
+
return ProposeResult(
|
|
1315
|
+
status="written",
|
|
1316
|
+
decision_id=decision.id,
|
|
1317
|
+
redactions=redactions,
|
|
1318
|
+
anchors_skipped=anchors_skipped,
|
|
1319
|
+
anchors_orphaned=anchors_orphaned,
|
|
1320
|
+
facts=fact_results,
|
|
1321
|
+
neighbors=neighbors,
|
|
1322
|
+
ratified_by=ratified_by,
|
|
1323
|
+
auto_ratify_error=auto_ratify_error,
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def propose(
|
|
1328
|
+
drafts: Sequence[object],
|
|
1329
|
+
store: Store,
|
|
1330
|
+
reader: GraphifyReader | None,
|
|
1331
|
+
session_id: str | None = None,
|
|
1332
|
+
author: str | None = None,
|
|
1333
|
+
ref: str | None = None,
|
|
1334
|
+
*,
|
|
1335
|
+
auto_accept: bool = False,
|
|
1336
|
+
ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
|
|
1337
|
+
) -> list[ProposeResult]:
|
|
1338
|
+
"""Run the deterministic write pipeline per draft. Per-draft failure never aborts the batch.
|
|
1339
|
+
|
|
1340
|
+
``auto_accept`` (default ``False``, keyword-only): the ``SIDEGRAPH_AUTO_ACCEPT=on``
|
|
1341
|
+
opt-in (see design/superpowers/specs/2026-07-10-ratification-ux-and-mcp-gaps-design.md)
|
|
1342
|
+
— when true, every drafted decision AND its attached facts land ``status=ACCEPTED``
|
|
1343
|
+
instead of ``PROPOSED``, bypassing the human ratification queue. Capture itself stays
|
|
1344
|
+
pure: the env var is read once by the caller (``server._auto_accept()``) and passed in
|
|
1345
|
+
here as a plain bool — this module never reads the environment. Provenance still
|
|
1346
|
+
stamps ``source="agent"`` either way; only the ratification status changes.
|
|
1347
|
+
|
|
1348
|
+
``ratify_policy`` (default ``RatifyPolicy.MANUAL``, keyword-only): the resolved
|
|
1349
|
+
``SIDEGRAPH_RATIFY_POLICY`` value (design D1/D2), threaded the same way ``auto_accept``
|
|
1350
|
+
is — this module never reads the environment. When it allows and D3's gates pass, each
|
|
1351
|
+
written decision (and its eligible attached-fact cascade) is auto-ratified — see
|
|
1352
|
+
``_propose_one``'s own post-write block.
|
|
1353
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1/D2
|
|
1354
|
+
"""
|
|
1355
|
+
graph_version = reader.graph_version() if reader is not None else None
|
|
1356
|
+
return [
|
|
1357
|
+
_propose_one(
|
|
1358
|
+
raw,
|
|
1359
|
+
store,
|
|
1360
|
+
reader,
|
|
1361
|
+
session_id,
|
|
1362
|
+
author,
|
|
1363
|
+
ref,
|
|
1364
|
+
graph_version,
|
|
1365
|
+
auto_accept=auto_accept,
|
|
1366
|
+
ratify_policy=ratify_policy,
|
|
1367
|
+
)
|
|
1368
|
+
for raw in drafts
|
|
1369
|
+
]
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def propose_facts(
|
|
1373
|
+
drafts: Sequence[object],
|
|
1374
|
+
store: Store,
|
|
1375
|
+
reader: GraphifyReader | None,
|
|
1376
|
+
session_id: str | None = None,
|
|
1377
|
+
author: str | None = None,
|
|
1378
|
+
*,
|
|
1379
|
+
auto_accept: bool = False,
|
|
1380
|
+
ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
|
|
1381
|
+
) -> list[ProposeFactResult]:
|
|
1382
|
+
"""Run the deterministic Fact write pipeline per STANDALONE draft (mirrors ``propose``
|
|
1383
|
+
for decisions). Neither ``attached_to`` nor ``inherited_anchors`` is set — a standalone
|
|
1384
|
+
draft must supply its own anchor or ``supports`` id (see ``_propose_fact_one``'s
|
|
1385
|
+
reachability check) — unlike a ``DraftDecision.facts`` entry, which always inherits
|
|
1386
|
+
``attached_to``/the decision's anchors via ``_propose_one``'s facts loop. Per-draft
|
|
1387
|
+
failure never aborts the batch.
|
|
1388
|
+
|
|
1389
|
+
``auto_accept`` (default ``False``, keyword-only): same ``SIDEGRAPH_AUTO_ACCEPT=on``
|
|
1390
|
+
opt-in ``propose`` documents (see design/superpowers/specs/
|
|
1391
|
+
2026-07-10-ratification-ux-and-mcp-gaps-design.md) — standalone facts land
|
|
1392
|
+
``status=ACCEPTED`` instead of ``PROPOSED`` when true. This module never reads the
|
|
1393
|
+
environment itself; the bool is passed in by the caller.
|
|
1394
|
+
|
|
1395
|
+
``ratify_policy`` (default ``RatifyPolicy.MANUAL``, keyword-only): same keyword
|
|
1396
|
+
``propose`` documents (design D1/D2) — sampled once by the caller and passed down
|
|
1397
|
+
unchanged; the same object a combined MCP request passes to ``propose`` reaches this
|
|
1398
|
+
function too (see ``server._propose_decisions_impl``). When it allows and D3's gates
|
|
1399
|
+
pass, each written standalone fact is auto-ratified — see ``_propose_fact_one``'s own
|
|
1400
|
+
post-write block (``attached_to is None`` here always, for every draft this function
|
|
1401
|
+
writes).
|
|
1402
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1/D2
|
|
1403
|
+
"""
|
|
1404
|
+
graph_version = reader.graph_version() if reader is not None else None
|
|
1405
|
+
return [
|
|
1406
|
+
_propose_fact_one(
|
|
1407
|
+
raw,
|
|
1408
|
+
store,
|
|
1409
|
+
reader,
|
|
1410
|
+
session_id,
|
|
1411
|
+
author,
|
|
1412
|
+
graph_version,
|
|
1413
|
+
auto_accept=auto_accept,
|
|
1414
|
+
ratify_policy=ratify_policy,
|
|
1415
|
+
)
|
|
1416
|
+
for raw in drafts
|
|
1417
|
+
]
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def format_proposal(d: Decision) -> str:
|
|
1421
|
+
"""Human-readable render of a pending proposal (shared by the ratify CLI and MCP)."""
|
|
1422
|
+
lines = [f"{d.id} [{d.kind.value}] {d.title}"]
|
|
1423
|
+
lines.append(f" what: {d.choice}")
|
|
1424
|
+
lines.append(f" why: {d.context}")
|
|
1425
|
+
if d.rejected:
|
|
1426
|
+
lines.append(f" rejected: {d.rejected}")
|
|
1427
|
+
if d.consequences:
|
|
1428
|
+
lines.append(f" learned: {d.consequences}")
|
|
1429
|
+
prov = d.provenance
|
|
1430
|
+
lines.append(f" from: session={prov.session_id or '-'} author={prov.author or '-'}")
|
|
1431
|
+
return "\n".join(lines)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def format_fact_proposal(f: Fact) -> str:
|
|
1435
|
+
"""Human-readable render of a pending fact proposal — mirrors ``format_proposal``'s
|
|
1436
|
+
exact visual style (shared by the ratify CLI and MCP)."""
|
|
1437
|
+
lines = [f"{f.id} [fact] {f.statement}"]
|
|
1438
|
+
lines.append(f" source: {f.source}")
|
|
1439
|
+
lines.append(f" supports: {', '.join(f.supports) if f.supports else '-'}")
|
|
1440
|
+
prov = f.provenance
|
|
1441
|
+
lines.append(f" from: session={prov.session_id or '-'} author={prov.author or '-'}")
|
|
1442
|
+
return "\n".join(lines)
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def _lint_domain_path_prefixes(
|
|
1446
|
+
path_prefixes: list[str],
|
|
1447
|
+
reader: GraphifyReader | None,
|
|
1448
|
+
store: Store,
|
|
1449
|
+
) -> list[str]:
|
|
1450
|
+
"""Deterministic domain-proposal lint (design D7.4, E8 gate checklist) — two
|
|
1451
|
+
mechanical, warning-only checks over ``path_prefixes``; never blocks the write --
|
|
1452
|
+
under ``auto-all`` any warning keeps the draft proposed. Shared by
|
|
1453
|
+
``_propose_domain_one`` (the agent MCP path) and
|
|
1454
|
+
``domains.bootstrap_domains`` (the CLI path), so both authoring routes catch the same
|
|
1455
|
+
two mistakes the same way.
|
|
1456
|
+
|
|
1457
|
+
(a) **Dead prefix**: a ``path_prefix`` matching zero ``file_path``s among ALL current
|
|
1458
|
+
graph nodes (NIT-2, code review: not filtered to anchorable ones — the broader check
|
|
1459
|
+
is the safe direction, since it can only ever find MORE covering files than an
|
|
1460
|
+
anchorable-only scan would, so it never over-warns relative to that narrower
|
|
1461
|
+
reading) — a rule that can never resolve anything, most likely a typo or a path that
|
|
1462
|
+
moved. Best-effort: a no-op without a ``reader`` (nothing to check against);
|
|
1463
|
+
structurally can never fire for ``bootstrap_domains``'s own derived prefixes (they are
|
|
1464
|
+
computed from a real majority-share calc over this exact graph — see
|
|
1465
|
+
``domains._derive_path_prefixes``), but an agent-typed ``propose_domains`` prefix has
|
|
1466
|
+
no such guarantee.
|
|
1467
|
+
|
|
1468
|
+
(b) **Subsumes sibling anchor**: a ``path_prefix`` that would swallow another
|
|
1469
|
+
ACCEPTED domain's own ``seed_anchors`` file — the same "one rule silently expands to
|
|
1470
|
+
cover another domain's territory" shape the sync-time breadth guards
|
|
1471
|
+
(``domains._SHARED_DIR_NAMES``/``_PREFIX_BREADTH_CAP``) exist to catch for the
|
|
1472
|
+
bootstrap path; this is the propose-time counterpart for seed-anchor-based domains,
|
|
1473
|
+
which those guards don't cover. Store-only, no reader needed. "Live" = ACCEPTED —
|
|
1474
|
+
the same addressable-domain notion ``retrieval.py``'s TOC/drill_down use. Deduped one
|
|
1475
|
+
warning per ``(domain, prefix)`` pair (NIT-3, code review) — a domain with several
|
|
1476
|
+
seed anchors all falling under the SAME prefix names only the first match, rather
|
|
1477
|
+
than repeating the same complaint once per anchor.
|
|
1478
|
+
"""
|
|
1479
|
+
warnings: list[str] = []
|
|
1480
|
+
if reader is not None:
|
|
1481
|
+
for p in path_prefixes:
|
|
1482
|
+
covers_something = any(
|
|
1483
|
+
n.file_path and matches_path_prefix(n.file_path, p) for n in reader.list_nodes()
|
|
1484
|
+
)
|
|
1485
|
+
if not covers_something:
|
|
1486
|
+
warnings.append(
|
|
1487
|
+
f"path_prefix {p!r} matches no file in the current graph (dead prefix)"
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
if path_prefixes:
|
|
1491
|
+
for domain in store.iter_domains(status=DomainStatus.ACCEPTED):
|
|
1492
|
+
for p in path_prefixes:
|
|
1493
|
+
match = next(
|
|
1494
|
+
(
|
|
1495
|
+
anchor.file_path
|
|
1496
|
+
for anchor in domain.seed_anchors
|
|
1497
|
+
if anchor.file_path and matches_path_prefix(anchor.file_path, p)
|
|
1498
|
+
),
|
|
1499
|
+
None,
|
|
1500
|
+
)
|
|
1501
|
+
if match is not None:
|
|
1502
|
+
warnings.append(
|
|
1503
|
+
f"path_prefix {p!r} subsumes domain {domain.slug!r}'s seed anchor {match!r}"
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
return warnings
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def _propose_domain_one(
|
|
1510
|
+
raw: object,
|
|
1511
|
+
store: Store,
|
|
1512
|
+
reader: GraphifyReader | None,
|
|
1513
|
+
session_id: str | None,
|
|
1514
|
+
author: str | None,
|
|
1515
|
+
graph_version: str | None,
|
|
1516
|
+
*,
|
|
1517
|
+
ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
|
|
1518
|
+
) -> ProposeDomainResult:
|
|
1519
|
+
"""One Domain draft through the deterministic pipeline: redact -> dedup (by slug,
|
|
1520
|
+
ANY non-superseded status counts) -> resolve parent_slug -> write as `proposed` (§4.2,
|
|
1521
|
+
one gate, no exceptions).
|
|
1522
|
+
|
|
1523
|
+
``ratify_policy`` (default ``RatifyPolicy.MANUAL``, keyword-only): domains are eligible
|
|
1524
|
+
under ``auto-all`` only, never ``auto-low-risk`` (design D3) — the auto-block below is
|
|
1525
|
+
skipped outright unless ``ratify_policy is RatifyPolicy.AUTO_ALL``. On success, also
|
|
1526
|
+
runs ``sync.activate_accepted_domain`` (the same shared per-domain activation step the
|
|
1527
|
+
human MCP/CLI ratify paths use) — but never rebuilds the TOC cache itself; the
|
|
1528
|
+
once-per-batch rebuild is ``propose_domains``'s job (design D2), so a batch of N domains
|
|
1529
|
+
never pays N ``build_toc`` calls.
|
|
1530
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D2/D3
|
|
1531
|
+
"""
|
|
1532
|
+
try:
|
|
1533
|
+
draft = DraftDomain.model_validate(raw)
|
|
1534
|
+
except ValidationError as e:
|
|
1535
|
+
return ProposeDomainResult(status="rejected", reason=f"invalid draft: {e}")
|
|
1536
|
+
|
|
1537
|
+
redactions = 0
|
|
1538
|
+
title, n = redact(draft.title)
|
|
1539
|
+
redactions += n
|
|
1540
|
+
summary, n = redact(draft.summary)
|
|
1541
|
+
redactions += n
|
|
1542
|
+
|
|
1543
|
+
# Dedup: a non-superseded domain (proposed, accepted, OR dropped) at this slug already
|
|
1544
|
+
# exists -> skip rather than write a colliding/duplicate draft (find_domain_by_slug
|
|
1545
|
+
# already excludes only SUPERSEDED, matching this rule exactly).
|
|
1546
|
+
existing = store.find_domain_by_slug(draft.slug)
|
|
1547
|
+
if existing is not None:
|
|
1548
|
+
return ProposeDomainResult(
|
|
1549
|
+
status="skipped",
|
|
1550
|
+
domain_id=existing.domain_id,
|
|
1551
|
+
reason=f"slug {draft.slug!r} already used by domain {existing.domain_id}",
|
|
1552
|
+
redactions=redactions,
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
parent_id = None
|
|
1556
|
+
if draft.parent_slug is not None:
|
|
1557
|
+
parent = store.find_domain_by_slug(draft.parent_slug)
|
|
1558
|
+
if parent is None:
|
|
1559
|
+
return ProposeDomainResult(
|
|
1560
|
+
status="rejected",
|
|
1561
|
+
reason=f"parent_slug {draft.parent_slug!r} does not resolve to any domain",
|
|
1562
|
+
redactions=redactions,
|
|
1563
|
+
)
|
|
1564
|
+
parent_id = parent.domain_id
|
|
1565
|
+
|
|
1566
|
+
try:
|
|
1567
|
+
domain = Domain(
|
|
1568
|
+
slug=draft.slug,
|
|
1569
|
+
title=title,
|
|
1570
|
+
summary=summary,
|
|
1571
|
+
parent_id=parent_id,
|
|
1572
|
+
seed_anchors=draft.seed_anchors,
|
|
1573
|
+
path_prefixes=draft.path_prefixes,
|
|
1574
|
+
provenance=Provenance(
|
|
1575
|
+
source="agent", author=author, session_id=session_id, graph_version=graph_version
|
|
1576
|
+
),
|
|
1577
|
+
)
|
|
1578
|
+
store.add_domain(domain)
|
|
1579
|
+
except (ValidationError, ValueError) as e:
|
|
1580
|
+
return ProposeDomainResult(status="rejected", reason=str(e), redactions=redactions)
|
|
1581
|
+
|
|
1582
|
+
warnings = _lint_domain_path_prefixes(draft.path_prefixes, reader, store)
|
|
1583
|
+
|
|
1584
|
+
# Auto-ratify (design D2/D3) — auto-all only; never under auto-low-risk or manual.
|
|
1585
|
+
ratified_by: str | None = None
|
|
1586
|
+
auto_ratify_error: str | None = None
|
|
1587
|
+
if ratify_policy == RatifyPolicy.AUTO_ALL:
|
|
1588
|
+
domain_anchored = _domain_anchored(
|
|
1589
|
+
reader, warnings, draft.seed_anchors, draft.path_prefixes
|
|
1590
|
+
)
|
|
1591
|
+
signal = AutoEligibility(
|
|
1592
|
+
kind="domain",
|
|
1593
|
+
live_tier12=0,
|
|
1594
|
+
ambiguous_or_orphan_only=True,
|
|
1595
|
+
pipeline_clean=True,
|
|
1596
|
+
has_provenance=True,
|
|
1597
|
+
domain_anchored=domain_anchored,
|
|
1598
|
+
has_supersedes=False,
|
|
1599
|
+
)
|
|
1600
|
+
if auto_ratify_eligible(signal, ratify_policy):
|
|
1601
|
+
outcome = _auto_ratify(store, domain.domain_id, "domain", ratify_policy)
|
|
1602
|
+
ratified_by = outcome.ratified_by
|
|
1603
|
+
auto_ratify_error = outcome.error
|
|
1604
|
+
if outcome.ratified_by is not None:
|
|
1605
|
+
# domain_anchored required `reader is not None` for eligibility, so this
|
|
1606
|
+
# transition's own reader is guaranteed present here.
|
|
1607
|
+
#
|
|
1608
|
+
# Ruling R (design D2/D6 checkpoint-2 fix): the domain transition already
|
|
1609
|
+
# committed by this point, so an activation failure must report and continue,
|
|
1610
|
+
# never abort the batch (external review finding A2,
|
|
1611
|
+
# scratchpad/probe_activation.py — an unprotected write inside the helper's
|
|
1612
|
+
# own refresh-failure handler could raise past this call). `Exception`, not
|
|
1613
|
+
# `BaseException`, consistent with `_auto_ratify`'s own catch, so
|
|
1614
|
+
# `KeyboardInterrupt`/`SystemExit` still propagate. `sync.activate_accepted_domain`
|
|
1615
|
+
# itself is deliberately NOT changed: the human MCP/CLI wrappers carried the
|
|
1616
|
+
# identical unprotected stale-marker write before the Task 4 extraction, and
|
|
1617
|
+
# changing the helper would change those byte-identical-proven paths too.
|
|
1618
|
+
try:
|
|
1619
|
+
activation = activate_accepted_domain(domain, store, reader)
|
|
1620
|
+
except Exception as e:
|
|
1621
|
+
auto_ratify_error = f"activation: {e}"
|
|
1622
|
+
else:
|
|
1623
|
+
# `resolved` and `overbroad` are independent fields on
|
|
1624
|
+
# `sync.DomainActivation` (checked separately, not elif'd, so neither
|
|
1625
|
+
# depends on the other ever staying mutually exclusive) — rev 12
|
|
1626
|
+
# erratum: a claim-cap rejection used to report a clean success here,
|
|
1627
|
+
# while the human MCP/CLI wrappers (server.py, cli.py) rendered their
|
|
1628
|
+
# own "path rule too broad" sentence for the identical outcome. This
|
|
1629
|
+
# is that same sentence, the literal `sidegraph:heal-anchors` trigger
|
|
1630
|
+
# phrase, so an unattended auto-all caller gets it too (D2, D6).
|
|
1631
|
+
if not activation.resolved:
|
|
1632
|
+
auto_ratify_error = f"activation: {activation.error}"
|
|
1633
|
+
if activation.overbroad is not None:
|
|
1634
|
+
prefixes = ", ".join(repr(p) for p in domain.path_prefixes)
|
|
1635
|
+
auto_ratify_error = (
|
|
1636
|
+
f"activation: path rule too broad: {prefixes} match "
|
|
1637
|
+
f"{activation.overbroad['matched']}/{activation.overbroad['total']} "
|
|
1638
|
+
"communities — not applied; seed_anchors, if any, still applied"
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1641
|
+
return ProposeDomainResult(
|
|
1642
|
+
status="proposed",
|
|
1643
|
+
domain_id=domain.domain_id,
|
|
1644
|
+
redactions=redactions,
|
|
1645
|
+
warnings=warnings,
|
|
1646
|
+
ratified_by=ratified_by,
|
|
1647
|
+
auto_ratify_error=auto_ratify_error,
|
|
1648
|
+
)
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def propose_domains(
|
|
1652
|
+
drafts: Sequence[object],
|
|
1653
|
+
store: Store,
|
|
1654
|
+
reader: GraphifyReader | None = None,
|
|
1655
|
+
session_id: str | None = None,
|
|
1656
|
+
author: str | None = None,
|
|
1657
|
+
*,
|
|
1658
|
+
ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
|
|
1659
|
+
) -> list[ProposeDomainResult]:
|
|
1660
|
+
"""Run the deterministic Domain write pipeline per draft (§4.2, agent in-session path;
|
|
1661
|
+
mirrors ``propose`` for decisions). Per-draft failure never aborts the batch.
|
|
1662
|
+
|
|
1663
|
+
``reader`` (design D7.4) also feeds ``_lint_domain_path_prefixes``'s dead-prefix half —
|
|
1664
|
+
each result's ``warnings`` list is empty (never rejected/blocked) when a prefix has no
|
|
1665
|
+
reader to check against.
|
|
1666
|
+
|
|
1667
|
+
``ratify_policy`` (default ``RatifyPolicy.MANUAL``, keyword-only): the resolved
|
|
1668
|
+
``SIDEGRAPH_RATIFY_POLICY`` value (design D1/D2), sampled once by the caller and passed
|
|
1669
|
+
down unchanged (see ``server._propose_domains_impl``). Domains are only ever eligible
|
|
1670
|
+
under ``AUTO_ALL`` (never ``AUTO_LOW_RISK``) — see ``_propose_domain_one``'s own
|
|
1671
|
+
auto-block. This function rebuilds ``TOC_CACHE_KEY`` ONCE, after the whole batch, when
|
|
1672
|
+
at least one domain was actually auto-ratified — never per domain (design D2: a domain
|
|
1673
|
+
bootstrap of N domains must not pay N ``build_toc`` calls; ``_propose_domain_one``'s own
|
|
1674
|
+
activation step never rebuilds it).
|
|
1675
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1/D2
|
|
1676
|
+
"""
|
|
1677
|
+
graph_version = reader.graph_version() if reader is not None else None
|
|
1678
|
+
results = [
|
|
1679
|
+
_propose_domain_one(
|
|
1680
|
+
raw,
|
|
1681
|
+
store,
|
|
1682
|
+
reader,
|
|
1683
|
+
session_id,
|
|
1684
|
+
author,
|
|
1685
|
+
graph_version,
|
|
1686
|
+
ratify_policy=ratify_policy,
|
|
1687
|
+
)
|
|
1688
|
+
for raw in drafts
|
|
1689
|
+
]
|
|
1690
|
+
if any(r.ratified_by is not None for r in results):
|
|
1691
|
+
store.set_meta(TOC_CACHE_KEY, json.dumps(build_toc(store)))
|
|
1692
|
+
return results
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
# How many community ids surface before a domain proposal's `communities:` line
|
|
1696
|
+
# truncates (Gate-5 finding: an over-broad path rule can resolve to hundreds of
|
|
1697
|
+
# communities — unreadable, and unnecessary, to dump every id in a ratify listing meant to
|
|
1698
|
+
# catch scale at a glance, not enumerate membership).
|
|
1699
|
+
_DOMAIN_PROPOSAL_COMMUNITY_SAMPLE = 8
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def format_path_prefixes(prefixes: list[str]) -> str:
|
|
1703
|
+
"""Render a Domain's ``path_prefixes`` membership rule for a ratify listing.
|
|
1704
|
+
|
|
1705
|
+
``"(none)"`` when empty, rather than omitting the line — Gate-5 finding: the ratify
|
|
1706
|
+
listing didn't show ``path_prefixes`` at all, so an over-broad auto-derived rule
|
|
1707
|
+
(``path_prefixes=["tests"]``, ≥80% of a community's members happened to be test files)
|
|
1708
|
+
was invisible at the one human gate meant to catch it before sync's REPLACE refresh
|
|
1709
|
+
silently expanded the domain to swallow unrelated communities. Shared by
|
|
1710
|
+
``format_domain_proposal`` (CLI) and ``server._format_domain_proposal_line`` (MCP) so
|
|
1711
|
+
both surfaces show the same rule the same way.
|
|
1712
|
+
"""
|
|
1713
|
+
if not prefixes:
|
|
1714
|
+
return "(none)"
|
|
1715
|
+
return ", ".join(f"{p.rstrip('/')}/" for p in prefixes)
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def format_communities_sample(
|
|
1719
|
+
communities: list[str], limit: int = _DOMAIN_PROPOSAL_COMMUNITY_SAMPLE
|
|
1720
|
+
) -> str:
|
|
1721
|
+
"""Render a Domain's seed ``communities`` list for a ratify listing, truncated past
|
|
1722
|
+
``limit`` ids (see ``_DOMAIN_PROPOSAL_COMMUNITY_SAMPLE``). ``"(none)"`` when empty —
|
|
1723
|
+
same rationale as ``format_path_prefixes``. Shared by ``format_domain_proposal`` (CLI)
|
|
1724
|
+
and ``server._format_domain_proposal_line`` (MCP)."""
|
|
1725
|
+
if not communities:
|
|
1726
|
+
return "(none)"
|
|
1727
|
+
if len(communities) <= limit:
|
|
1728
|
+
return ", ".join(communities)
|
|
1729
|
+
shown = ", ".join(communities[:limit])
|
|
1730
|
+
return f"{shown}, … (+{len(communities) - limit} more)"
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
# How many seed-anchor descriptors surface before a domain proposal's `anchors:` line
|
|
1734
|
+
# truncates (Gate-6 finding: seed_anchors is the name-domains skill's PRIMARY membership
|
|
1735
|
+
# shape — an agent-curated merge with no shared path prefix can carry a dozen+ anchors,
|
|
1736
|
+
# unreadable to dump raw in a ratify listing meant to catch the rule at a glance).
|
|
1737
|
+
_DOMAIN_PROPOSAL_ANCHOR_SAMPLE = 3
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def _format_descriptor(d: Descriptor) -> str:
|
|
1741
|
+
"""Render one seed-anchor ``Descriptor`` as ``name@file_path`` (bare ``name`` when
|
|
1742
|
+
``file_path`` is absent) for a ratify listing."""
|
|
1743
|
+
return f"{d.name}@{d.file_path}" if d.file_path else d.name
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def format_seed_anchors_sample(
|
|
1747
|
+
seed_anchors: list[Descriptor], limit: int = _DOMAIN_PROPOSAL_ANCHOR_SAMPLE
|
|
1748
|
+
) -> str:
|
|
1749
|
+
"""Render a Domain's ``seed_anchors`` membership rule for a ratify listing: the count
|
|
1750
|
+
plus a truncated ``name@file_path`` sample past ``limit`` (see
|
|
1751
|
+
``_DOMAIN_PROPOSAL_ANCHOR_SAMPLE``).
|
|
1752
|
+
|
|
1753
|
+
Gate-6 finding: ``seed_anchors`` — the name-domains skill's new PRIMARY membership
|
|
1754
|
+
shape — rendered as invisible as ``path_prefixes``/``communities`` did before Gate-5's
|
|
1755
|
+
fix: a domain authored with ONLY ``seed_anchors`` showed "paths: (none) communities:
|
|
1756
|
+
(none)" at the human ratify gate, with no sign of the rule actually being approved.
|
|
1757
|
+
|
|
1758
|
+
Unlike ``format_path_prefixes``/``format_communities_sample``, returns ``""`` (not
|
|
1759
|
+
``"(none)"``) when empty — callers omit the whole ``anchors:`` line rather than adding
|
|
1760
|
+
a third always-present "(none)" row; ``path_prefixes``/``communities`` already show
|
|
1761
|
+
"no rule at all" between them. Shared by ``format_domain_proposal`` (CLI) and
|
|
1762
|
+
``server._format_domain_proposal_line`` (MCP)."""
|
|
1763
|
+
if not seed_anchors:
|
|
1764
|
+
return ""
|
|
1765
|
+
descriptors = [_format_descriptor(d) for d in seed_anchors]
|
|
1766
|
+
if len(descriptors) <= limit:
|
|
1767
|
+
shown = ", ".join(descriptors)
|
|
1768
|
+
else:
|
|
1769
|
+
shown = ", ".join(descriptors[:limit]) + f", … (+{len(descriptors) - limit} more)"
|
|
1770
|
+
return f"{len(seed_anchors)} ({shown})"
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
def format_domain_proposal(d: Domain) -> str:
|
|
1774
|
+
"""Human-readable render of a pending domain proposal (shared by the ratify CLI).
|
|
1775
|
+
|
|
1776
|
+
Always renders the membership rule (``path_prefixes``, seed ``communities``, and seed
|
|
1777
|
+
``seed_anchors``) — even when empty — so the human ratification gate can catch an
|
|
1778
|
+
over-broad rule, or see what it's actually approving, instead of only ever seeing
|
|
1779
|
+
prose (see ``format_path_prefixes``/``format_communities_sample``/
|
|
1780
|
+
``format_seed_anchors_sample`` docstrings for the Gate-5/Gate-6 findings this fixes).
|
|
1781
|
+
The ``anchors:`` line is the one exception: it's omitted entirely when
|
|
1782
|
+
``seed_anchors`` is empty, rather than printing a third "(none)" row."""
|
|
1783
|
+
lines = [f"{d.domain_id} [domain] {d.slug} — {d.title}"]
|
|
1784
|
+
lines.append(f" summary: {d.summary}")
|
|
1785
|
+
lines.append(f" paths: {format_path_prefixes(d.path_prefixes)}")
|
|
1786
|
+
lines.append(f" communities: {format_communities_sample(d.communities)}")
|
|
1787
|
+
anchors = format_seed_anchors_sample(d.seed_anchors)
|
|
1788
|
+
if anchors:
|
|
1789
|
+
lines.append(f" anchors: {anchors}")
|
|
1790
|
+
if d.parent_id:
|
|
1791
|
+
lines.append(f" parent: {d.parent_id}")
|
|
1792
|
+
prov = d.provenance
|
|
1793
|
+
lines.append(f" from: source={prov.source} author={prov.author or '-'}")
|
|
1794
|
+
return "\n".join(lines)
|