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/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)