docs(6206): webhooks, search, rulebooks routes and retrieval_tuning, note, models/__init__ drop citations

routes/search states the derived-facets property rather than the silent
whole-corpus bug that motivated it; routes/rulebooks gives the preference
restore exception its reasoning without the milestone numbers it cited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:43:32 -04:00
co-authored by Claude Opus 5.5
parent 0c88e1b0b0
commit 6ec78b530e
6 changed files with 32 additions and 34 deletions
+4 -4
View File
@@ -8,7 +8,7 @@ from scribe.config import Config
# inline dict cannot be recovered from the engine — and a guard that cannot # inline dict cannot be recovered from the engine — and a guard that cannot
# read the value it guards is a guard that passes forever. # read the value it guards is a guard that passes forever.
_CONNECT_ARGS: dict = { _CONNECT_ARGS: dict = {
# A DEADLINE ON ESTABLISHING A CONNECTION (#4181, rule 156). # A DEADLINE ON ESTABLISHING A CONNECTION.
# #
# asyncpg's `timeout` bounds the CONNECT — the TCP handshake plus session # asyncpg's `timeout` bounds the CONNECT — the TCP handshake plus session
# setup — and nothing else. Against a host whose storage has wedged, that # setup — and nothing else. Against a host whose storage has wedged, that
@@ -80,9 +80,9 @@ from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
from scribe.models.rulebook import ( # noqa: E402, F401 from scribe.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, RuleRelation, rule_moments, rule_systems, Rulebook, RulebookTopic, Rule, RuleRelation, rule_moments, rule_systems,
) )
# After notes and rules: it foreign-keys both (milestone 440). # After notes and rules: it foreign-keys both.
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink # noqa: E402, F401 from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink # noqa: E402, F401
# After rules: it foreign-keys them (milestone 458 step 7). # After rules: it foreign-keys them.
from scribe.models.rule_moment_judgment import RuleMomentJudgment # noqa: E402, F401 from scribe.models.rule_moment_judgment import RuleMomentJudgment # noqa: E402, F401
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401 from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401 from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
@@ -90,7 +90,7 @@ from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEven
from scribe.models.system import System, RecordSystem # noqa: E402, F401 from scribe.models.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401 from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
# After notes, projects and rulebook topics: family canon foreign-keys all # After notes, projects and rulebook topics: family canon foreign-keys all
# three (milestone 463). # three.
from scribe.models.family import ( # noqa: E402, F401 from scribe.models.family import ( # noqa: E402, F401
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
FamilyIdeaReference, Platform, ProjectPlatform, FamilyIdeaReference, Platform, ProjectPlatform,
+5 -5
View File
@@ -76,7 +76,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column( recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
# The claim — which session is working this task NOW (milestone 381). # The claim — which session is working this task NOW.
# Orthogonal to `status`: status is the work's state and durable, the claim # Orthogonal to `status`: status is the work's state and durable, the claim
# is a session's attention and dies on read once `claim_touched_at` is past # is a session's attention and dies on read once `claim_touched_at` is past
# the lease. Semantics in services/task_claims.py. # the lease. Semantics in services/task_claims.py.
@@ -93,7 +93,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
# snippet — a reusable shape, with its structured fields mirrored in # snippet — a reusable shape, with its structured fields mirrored in
# `data` and its trigger composed into the title (0070) # `data` and its trigger composed into the title (0070)
# lesson — a transferable insight, findable by WHEN IT APPLIES rather # lesson — a transferable insight, findable by WHEN IT APPLIES rather
# than by topic (milestone 385). Same mirror discipline as a # than by topic. Same mirror discipline as a
# snippet: the trigger lives in `data.when_to_apply` and is # snippet: the trigger lives in `data.when_to_apply` and is
# composed into the title and the head of the body, which is # composed into the title and the head of the body, which is
# what puts it in the embedded document. A lesson is never a # what puts it in the embedded document. A lesson is never a
@@ -102,8 +102,8 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
# #
# DELIBERATELY UNGATED. Unlike `task_kind` there is no CHECK on this column: # DELIBERATELY UNGATED. Unlike `task_kind` there is no CHECK on this column:
# migration 0036 added it as plain Text with a server default and nothing # migration 0036 added it as plain Text with a server default and nothing
# has constrained it since, so rule 36 has no whitelist to expand when a # has constrained it since, so there is no whitelist to expand when a kind
# kind is added. The vocabulary that actually decides what a reader can # is added. The vocabulary that actually decides what a reader can
# reach is `services.knowledge._FACETS`; an unrecognised value there # reach is `services.knowledge._FACETS`; an unrecognised value there
# resolves to a filter matching nothing, which is the intended answer to a # resolves to a filter matching nothing, which is the intended answer to a
# typo. # typo.
@@ -117,7 +117,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
# so historical plan-tasks stay writable # so historical plan-tasks stay writable
# Only meaningful when the note is a task (status is not None); ordinary # Only meaningful when the note is a task (status is not None); ordinary
# notes keep the 'work' default and ignore it. Orthogonal to note_type # notes keep the 'work' default and ignore it. Orthogonal to note_type
# (which is the note/entity axis). CHECK notes_task_kind_check (rule 36). # (which is the note/entity axis). CHECK notes_task_kind_check.
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work") task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
# Queryable structured fields for typed records — currently snippets, whose # Queryable structured fields for typed records — currently snippets, whose
# name/language/signature/locations live here so they can be INDEXED. The # name/language/signature/locations live here so they can be INDEXED. The
+5 -5
View File
@@ -7,7 +7,7 @@ from scribe.models import Base
class RetrievalTuningEvent(Base): class RetrievalTuningEvent(Base):
"""One row per change to a retrieval surface's floor or budget (#4102). """One row per change to a retrieval surface's floor or budget.
WHY A TABLE AND NOT JUST THE SETTING WHY A TABLE AND NOT JUST THE SETTING
@@ -53,8 +53,8 @@ class RetrievalTuningEvent(Base):
# did; `tests/test_retrieval_surfaces.py` asserts it rather than trusting it. # did; `tests/test_retrieval_surfaces.py` asserts it rather than trusting it.
surface: Mapped[str] = mapped_column(Text, nullable=False) surface: Mapped[str] = mapped_column(Text, nullable=False)
# "floor" or "budget". Text rather than an enum because rule 36 makes a new # "floor" or "budget". Text rather than an enum because a new CHECK value is
# CHECK value a migration, and this is a young surface that may well grow a # a migration, and this is a young surface that may well grow a
# third tunable — a constraint bought nothing here except a future migration. # third tunable — a constraint bought nothing here except a future migration.
dial: Mapped[str] = mapped_column(Text, nullable=False) dial: Mapped[str] = mapped_column(Text, nullable=False)
@@ -75,7 +75,7 @@ class RetrievalTuningEvent(Base):
# becomes a formality; the service refuses a blank one. # becomes a formality; the service refuses a blank one.
reason: Mapped[str] = mapped_column(Text, nullable=False) reason: Mapped[str] = mapped_column(Text, nullable=False)
# WHAT SPACE THIS NUMBER WAS MEASURED IN (#4104). A floor is a cosine # WHAT SPACE THIS NUMBER WAS MEASURED IN. A floor is a cosine
# distance in one embedding model's geometry, over documents cut one # distance in one embedding model's geometry, over documents cut one
# particular way — change either and the number describes something that # particular way — change either and the number describes something that
# no longer exists. # no longer exists.
@@ -111,7 +111,7 @@ class RetrievalTuningEvent(Base):
"new_value": self.new_value, "new_value": self.new_value,
"actor": self.actor, "actor": self.actor,
"reason": self.reason, "reason": self.reason,
# Null for every row written before #4104, and rendered as # Null on rows written before the stamp existed, and rendered as
# "unstamped" rather than guessed at — see the column comments. # "unstamped" rather than guessed at — see the column comments.
"embedding_model": self.embedding_model, "embedding_model": self.embedding_model,
"shape_version": self.shape_version, "shape_version": self.shape_version,
+6 -7
View File
@@ -280,19 +280,18 @@ async def get_rule_version(rule_id: int, version_id: int):
return jsonify(version.to_dict(include_text=True)) return jsonify(version.to_dict(include_text=True))
# NO restore route FOR A RULE, deliberately (milestone 323). A note version # NO restore route FOR A RULE, deliberately. A note version
# can be restored; a binding instruction should not be revertible in one # can be restored; a binding instruction should not be revertible in one
# click. Putting a rewrite back goes through update_rule, which takes its own # click. Putting a rewrite back goes through update_rule, which takes its own
# snapshot and leaves the undo in the history like any other edit — a silent # snapshot and leaves the undo in the history like any other edit — a silent
# revert would erase the only record of why the rewrite happened. # revert would erase the only record of why the rewrite happened.
# #
# A PREFERENCE IS THE EXCEPTION, and the route below refuses anything else. # A PREFERENCE IS THE EXCEPTION, and the route below refuses anything else.
# 323's reasoning turns on the rewrite being the operator's own decision. # The reasoning above turns on the rewrite being the operator's own decision.
# A preference's rewrite is not: the agent makes it mid-work without asking, # A preference's rewrite is not: the agent makes it mid-work without asking,
# which is what the kind is for. Reverting one is a veto over someone else's # which is what the kind is for. Reverting one is a veto over someone else's
# edit rather than an undo of your own, and milestone 399 named the cost of # edit rather than an undo of your own, and making that veto expensive leaves
# making that veto expensive — drift supervised in name only. The safeguard # drift supervised in name only. The safeguard survives intact, because the restore goes through
# 323 actually wanted survives intact, because the restore goes through
# update_rule too: the rewrite stays in the history with the revert recorded # update_rule too: the rewrite stays in the history with the revert recorded
# after it, so the history gains an entry rather than losing one. # after it, so the history gains an entry rather than losing one.
@@ -369,7 +368,7 @@ async def unrelate_rules(relation_id: int):
@login_required @login_required
async def move_rule(rule_id: int): async def move_rule(rule_id: int):
"""Give a rule a new home: {topic_id} makes it global, {project_id} makes """Give a rule a new home: {topic_id} makes it global, {project_id} makes
it that project's. The MCP twin is move_rule (rule 33: same names).""" it that project's. The MCP twin is move_rule, under the same names."""
data = await request.get_json() or {} data = await request.get_json() or {}
uid = get_current_user_id() uid = get_current_user_id()
try: try:
@@ -452,7 +451,7 @@ async def create_project_rule(project_id: int):
)), 201 )), 201
# ── The staleness sweep (milestone 312) ──────────────────────────────── # ── The staleness sweep ────────────────────────────────────────────────
@rulebooks_bp.get("/rules-due-for-verification") @rulebooks_bp.get("/rules-due-for-verification")
@login_required @login_required
+8 -9
View File
@@ -11,8 +11,8 @@ from scribe.services.embeddings import semantic_search_notes
from scribe.services.knowledge import content_type_filters from scribe.services.knowledge import content_type_filters
from scribe.services.retrieval_telemetry import record_retrieval from scribe.services.retrieval_telemetry import record_retrieval
# The interactive floor lives in embeddings.py now, shared with Browse search # The interactive floor lives in embeddings.py, shared with Browse search and
# and the list views' semantic `q` — one number, one rationale (#2463). # the list views' semantic `q` — one number, one rationale.
search_bp = Blueprint("search", __name__, url_prefix="/api/search") search_bp = Blueprint("search", __name__, url_prefix="/api/search")
@@ -26,10 +26,9 @@ async def search_route():
return jsonify({"error": "q is required"}), 400 return jsonify({"error": "q is required"}), 400
limit = min(request.args.get("limit", 10, type=int), 50) limit = min(request.args.get("limit", 10, type=int), 50)
# Every kind the facet table declares, derived rather than mapped here — # Every kind the facet table declares, derived rather than mapped here, so
# this route used to know exactly two and read anything else as "no # no kind can fall through to "no filter" and silently return the whole
# filter", so `?content_type=snippets` silently returned the whole corpus # corpus. An unknown kind is a 400 naming the ones that exist: a
# (#4250). An unknown kind is now a 400 naming the ones that exist: a
# result set is an answer, and it should not be able to answer a question # result set is an answer, and it should not be able to answer a question
# nobody asked. # nobody asked.
try: try:
@@ -37,9 +36,9 @@ async def search_route():
except ValueError as exc: except ValueError as exc:
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
is_task = filters.get("is_task") is_task = filters.get("is_task")
# Same association filters the MCP tool takes (#33). Optional, default # Same association filters the MCP tool takes. Optional, default global:
# global: this route has NO frontend consumer today (measured 2026-08-08 — # the web UI searches through /api/knowledge, so this route serves API
# the web UI searches through /api/knowledge), so it serves API callers, # callers,
# and an API caller states its scope explicitly. # and an API caller states its scope explicitly.
system_id = request.args.get("system_id", type=int) system_id = request.args.get("system_id", type=int)
project_id = request.args.get("project_id", type=int) project_id = request.args.get("project_id", type=int)
+4 -4
View File
@@ -1,4 +1,4 @@
"""Forge push webhook — drift flagging at the moment the repo moves (#2691). """Forge push webhook — drift flagging at the moment the repo moves.
The forge POSTs here on every push; changed paths are matched against recorded The forge POSTs here on every push; changed paths are matched against recorded
snippet locations (through repo bindings) and matched verdicts get an snippet locations (through repo bindings) and matched verdicts get an
@@ -14,7 +14,7 @@ Contract with the forge's delivery loop:
- No secret configured → 404: the endpoint doesn't exist until an operator - No secret configured → 404: the endpoint doesn't exist until an operator
creates it. Bad signature → 401: that's a caller problem worth signaling. creates it. Bad signature → 401: that's a caller problem worth signaling.
- A PROCESSING failure returns 200 with ``{"ok": false}`` and drops a - A PROCESSING failure returns 200 with ``{"ok": false}`` and drops a
WARNING + AppLog row (the #2663 canary pattern): repeated 5xx responses WARNING + AppLog row (the telemetry canary pattern): repeated 5xx responses
make forges mark deliveries failed and operators disable the hook, which make forges mark deliveries failed and operators disable the hook, which
would silently turn the feature off — the exact failure mode this would silently turn the feature off — the exact failure mode this
milestone exists to prevent. milestone exists to prevent.
@@ -55,7 +55,7 @@ def signature_ok(secret: str, body: bytes, signature: str) -> bool:
def delivered_signature(headers) -> str: def delivered_signature(headers) -> str:
"""The HMAC hex a delivery carries, whichever forge sent it: Gitea's """The HMAC hex a delivery carries, whichever forge sent it: Gitea's
X-Gitea-Signature verbatim, or GitHub's X-Hub-Signature-256 minus its X-Gitea-Signature verbatim, or GitHub's X-Hub-Signature-256 minus its
"sha256=" scheme prefix (#2693). Empty when neither header is present.""" "sha256=" scheme prefix. Empty when neither header is present."""
return headers.get("X-Gitea-Signature", "") or headers.get( return headers.get("X-Gitea-Signature", "") or headers.get(
"X-Hub-Signature-256", "" "X-Hub-Signature-256", ""
).removeprefix("sha256=") ).removeprefix("sha256=")
@@ -104,7 +104,7 @@ async def forge_push():
"forge push %s flagged %d snippet(s) for recheck", head[:12], flagged "forge push %s flagged %d snippet(s) for recheck", head[:12], flagged
) )
# A push is exactly when the coverage number goes stale — recompute it # A push is exactly when the coverage number goes stale — recompute it
# off the delivery path (#2692). Fire-and-forget: the forge's delivery # off the delivery path. Fire-and-forget: the forge's delivery
# loop must not wait on an archive download, and a failure is a # loop must not wait on an archive download, and a failure is a
# WARNING from spawn(), never a failed delivery. This also SEEDS the # WARNING from spawn(), never a failed delivery. This also SEEDS the
# cache on a webhook-configured instance — no manual first refresh. # cache on a webhook-configured instance — no manual first refresh.