Compare commits

...
3 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 40f603ba28 docs(6206): retrieval_tuning model docstring drops the decision quote
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:44:50 -04:00
bvandeusenandClaude Opus 5.5 f6734291c9 docs(6206): the remaining models, routes, mcp/tools/repos and config comments drop citations
A shipped tool docstring (mcp/tools/repos bind_repo) cited this instance's
"rule 1: dev is home"; it now says what a dev-branch project should do.
routes/knowledge, routes/tasks, routes/systems and models/retrieval_log state
the property each guards rather than the bug that once broke it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:44:31 -04:00
bvandeusenandClaude Opus 5.5 6ec78b530e 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>
2026-10-21 20:43:32 -04:00
28 changed files with 89 additions and 100 deletions
+2 -2
View File
@@ -60,9 +60,9 @@ class Config:
# the MCP layer doesn't proxy web search (Claude has its own).
SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "")
# Git forge integration (#2689) — optional read access to a git forge so
# Git forge integration — optional read access to a git forge so
# snippet bodies can be fetched/verified server-side. Connections are
# per-user keyring rows (#2778, Settings → Git forges); these env values
# per-user keyring rows (Settings → Git forges); these env values
# survive as an implicit keyring entry for ADMIN users' projects only, so
# a deployment can keep the operator's token in a Docker secret instead
# of the database. A stored row for the same host wins over the env entry.
+2 -2
View File
@@ -22,8 +22,8 @@ async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict:
The binding is also what the shape ledger reads (refresh_pattern_coverage):
`ref` names the branch it follows. Default (""): the repo's default branch
— which means the ledger only sees work after a merge. A dev-first project
(rule 1: dev is home) should bind with ref="dev" so classification follows
— which means the ledger only sees work after a merge. A project that
works on a dev branch should bind with ref="dev" so classification follows
the push, not the merge. Re-binding with ref="" keeps the standing ref;
pass ref="-" to clear it back to the default branch.
+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
# read the value it guards is a guard that passes forever.
_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
# 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
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
# 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.repo_binding import RepoBinding # 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.design_system import DesignSystem, DesignToken # noqa: E402, F401
# After notes, projects and rulebook topics: family canon foreign-keys all
# three (milestone 463).
# three.
from scribe.models.family import ( # noqa: E402, F401
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
FamilyIdeaReference, Platform, ProjectPlatform,
+1 -1
View File
@@ -7,7 +7,7 @@ from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class CanonicalSystem(Base, TimestampMixin, SoftDeleteMixin):
"""A GLOBAL area name — the shared vocabulary every project's Systems
can point at (milestone 307, decision note 3026).
can point at.
A `System` is per-project and NOT NULL on project_id, so nothing outside a
project can reference one: a rule that applies across projects has no way
+1 -1
View File
@@ -135,7 +135,7 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
# and the token's own value are usually different colours entirely. No
# value-matching rule could ever connect them.
#
# Consumed by the source lint (#2277), not by the drift panel: these
# Consumed by the source lint, not by the drift panel: these
# literals live in component CSS, which the panel cannot see and says so.
supersedes: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
+1 -1
View File
@@ -6,7 +6,7 @@ from scribe.models.base import TimestampMixin, iso
class ForgeConnection(Base, TimestampMixin):
"""One user's read-only credential for one git forge host (#2778).
"""One user's read-only credential for one git forge host.
The keyring model: a user owns a set of connections and every server-side
forge read for a project runs on the PROJECT OWNER's set, resolved by the
+3 -3
View File
@@ -6,7 +6,7 @@ from scribe.models.base import CreatedAtMixin, iso
class MomentMapping(Base, CreatedAtMixin):
"""One install's correction to which actions reach which moment (milestone 458).
"""One install's correction to which actions reach which moment.
The moments are the product's vocabulary and the shipped defaults say how
the common actions reach them (`services/moment_actions.py`). What no
@@ -20,8 +20,8 @@ class MomentMapping(Base, CreatedAtMixin):
say "not here", and saying so must survive an upgrade that ships the same
default again.
Text rather than a CHECK on `effect`, for retrieval_tuning_events' reason
(rule 36): the service validates it, and a constraint would buy nothing but
Text rather than a CHECK on `effect`, for retrieval_tuning_events' reason:
the service validates it, and a constraint would buy nothing but
a migration the day a third effect is wanted.
ONE ROW PER (user, tool, match, moment), so mapping the same thing twice
+5 -5
View File
@@ -76,7 +76,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
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
# is a session's attention and dies on read once `claim_touched_at` is past
# 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
# `data` and its trigger composed into the title (0070)
# 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
# composed into the title and the head of the body, which is
# 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:
# 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
# kind is added. The vocabulary that actually decides what a reader can
# has constrained it since, so there is no whitelist to expand when a kind
# is added. The vocabulary that actually decides what a reader can
# reach is `services.knowledge._FACETS`; an unrecognised value there
# resolves to a filter matching nothing, which is the intended answer to a
# typo.
@@ -117,7 +117,7 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
# so historical plan-tasks stay writable
# 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
# (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")
# Queryable structured fields for typed records — currently snippets, whose
# name/language/signature/locations live here so they can be INDEXED. The
+6 -7
View File
@@ -54,23 +54,22 @@ class NoteUsageEvent(Base, CreatedAtMixin):
# A CONVENTION, not a fixed vocabulary: `mcp_<tool>` for an agent call,
# `rest_<kind>` for a human opening a detail view, and a bare name for a
# hook or background surface ('auto_inject', 'write_path_place',
# 'write_path_semantic'). This comment deliberately no longer lists the
# members — the previous list had gone stale, naming 'rest_note' that
# nothing wrote while omitting sources that existed, and a half-true
# enumeration reads as authoritative in exactly the way that misleads
# (#2476). `grep -rn record_pulled\\\|record_surfaced src/` is the
# 'write_path_semantic'). This comment deliberately does not list every
# member — such a list goes stale, naming sources nothing writes while
# omitting ones that exist, and a half-true enumeration reads as
# authoritative in exactly the way that misleads. `grep -rn record_pulled\\\|record_surfaced src/` is the
# authoritative list, and unlike a comment it cannot drift.
#
# The mcp_/rest_ split is load-bearing. "Is this dead weight?" is served by
# any pull; "was that injected line useful?" is served by AGENT pulls only,
# so never aggregate across the prefix without saying why (#1038, #2085).
# so never aggregate across the prefix without saying why.
source: Mapped[str] = mapped_column(Text, nullable=False)
# The project the READER was in when this happened — not the project the
# record belongs to, which is already on `notes.project_id`. The whole
# point is the comparison between the two: a record opened somewhere other
# than where it was written is the evidence that it TRANSFERRED, which is
# the claim the lesson kind rests on (milestone 385) and the one thing
# the claim the lesson kind rests on and the one thing
# RetrievalLog could half-answer and this table could not answer at all.
#
# Nullable, and null is ordinary rather than historical. A surfacing arm
+2 -2
View File
@@ -28,7 +28,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
design_system_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True
)
# The per-project forge pin (#2778). NULL is the ordinary state: forge
# The per-project forge pin. NULL is the ordinary state: forge
# reads resolve against the owner's keyring by repo host. When set, the
# project's forge reads use ONLY this connection — an explicit, auditable
# choice, constrained by the service layer to a connection the project
@@ -37,7 +37,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
BigInteger, ForeignKey("forge_connections.id", ondelete="SET NULL"),
nullable=True,
)
# The inception record (milestone 297): what this project was decided to
# The inception record: what this project was decided to
# inherit, when, and through which door — {decided_at, decided_by, via,
# choices: {design_system_id, seed_systems}}. NULL means nobody has
# decided yet, and enter_project asks; the effects themselves live in
+1 -1
View File
@@ -28,7 +28,7 @@ class RepoBinding(Base, TimestampMixin):
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
repo_key: Mapped[str] = mapped_column(Text, nullable=False)
# The branch the coverage refresh reads for this binding (#2873); NULL =
# The branch the coverage refresh reads for this binding; NULL =
# the forge's default branch. Chosen at bind time so a dev-first project
# can have its ledger follow dev instead of waiting for the merge.
ref: Mapped[str | None] = mapped_column(Text, nullable=True)
+3 -4
View File
@@ -11,12 +11,11 @@ VERDICTS = (ON_POINT, ADJACENT, UNRELATED)
class RetrievalJudgment(Base, CreatedAtMixin):
"""One reviewer's verdict on one candidate of one logged retrieval call
(#4772).
"""One reviewer's verdict on one candidate of one logged retrieval call.
The relevance half the usage tables cannot hold. `note_usage_events` says
whether a line was OPENED, and since every menu line carries its matched
passage (#4364) "not opened" covers an unrelated line, a line whose passage
whether a line was OPENED, and because every menu line carries its matched
passage, "not opened" covers an unrelated line, a line whose passage
was enough, and a line the reader already had. Only someone reading the
query beside the line can tell those apart, so this records that reading —
with its reason, because a verdict nobody can re-read is a threshold.
+4 -4
View File
@@ -48,8 +48,8 @@ class RetrievalLog(Base):
# suppressed". `result_count == 0` alone conflates two different events —
# the ranker found nothing above threshold, and the ranker found something
# the reader already had — and only the first says a threshold is too high.
# Reading a zero as a ranker decline is how #3311 mis-scoped a milestone;
# an unmeasured value that renders as 0 is the same mistake with a nicer
# Reading a zero as a ranker decline sends a reader to move a floor that
# was never involved; an unmeasured value that renders as 0 is the same mistake with a nicer
# face, so surfaces that filter INSIDE the search leave this null.
suppressed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
top_score: Mapped[float | None] = mapped_column(Float, nullable=True)
@@ -58,12 +58,12 @@ class RetrievalLog(Base):
# against `top_score`, which is the best it DID offer. They are equal on
# any call that returned something, and only this one exists on a call
# that returned nothing, which is the only place a bar can be judged from
# (#3670). Null means the caller did not measure it, never "nothing was
# Null means the caller did not measure it, never "nothing was
# close": a 0.0 there would read as a corpus with no relevant records at
# all, which is an artifact standing in for a measurement.
best_available_score: Mapped[float | None] = mapped_column(Float, nullable=True)
# WHICH record scored that, so a reader can judge what the bar refused
# rather than only how close it came (#3807). Written from the same ranked
# rather than only how close it came. Written from the same ranked
# candidate as the score above — the two describing different records would
# be worse than no id at all, because it invites judging the wrong one.
#
+8 -9
View File
@@ -7,14 +7,13 @@ from scribe.models import 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
The setting holds the number. It cannot hold the argument, and from this
step onward the argument is the interesting part: the operator's decision is
that the model moves these values, *"9 times out of 10"*, without being
asked each time. Delegation like that is only safe if it leaves a trail the
The setting holds the number. It cannot hold the argument, and the argument
is the interesting part: the model moves these values most of the time,
without being asked each time. Delegation like that is only safe if it leaves a trail the
operator can read afterwards — what changed, from what to what, and on what
evidence — and can disagree with.
@@ -53,8 +52,8 @@ class RetrievalTuningEvent(Base):
# did; `tests/test_retrieval_surfaces.py` asserts it rather than trusting it.
surface: Mapped[str] = mapped_column(Text, nullable=False)
# "floor" or "budget". Text rather than an enum because rule 36 makes a new
# CHECK value a migration, and this is a young surface that may well grow a
# "floor" or "budget". Text rather than an enum because a new CHECK value is
# a migration, and this is a young surface that may well grow a
# third tunable — a constraint bought nothing here except a future migration.
dial: Mapped[str] = mapped_column(Text, nullable=False)
@@ -75,7 +74,7 @@ class RetrievalTuningEvent(Base):
# becomes a formality; the service refuses a blank one.
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
# particular way — change either and the number describes something that
# no longer exists.
@@ -111,7 +110,7 @@ class RetrievalTuningEvent(Base):
"new_value": self.new_value,
"actor": self.actor,
"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.
"embedding_model": self.embedding_model,
"shape_version": self.shape_version,
+3 -3
View File
@@ -10,7 +10,7 @@ from scribe.models.lesson_rule_link import CONFIRMED, LINK_STATES, REJECTED, SUG
# The same three states as a lesson's link to a rule, for the same reasons —
# re-exported so a reader of this table need not know where they were first
# named. CHECK ck_rule_moment_judgments_state (migration 0118, rule 36).
# named. CHECK ck_rule_moment_judgments_state (migration 0118).
JUDGMENT_STATES = LINK_STATES
__all__ = [
"CONFIRMED", "JUDGMENT_STATES", "NO_MOMENT", "REJECTED", "SUGGESTED",
@@ -31,7 +31,7 @@ SOURCES = ("pass", "signal", "edit")
class RuleMomentJudgment(Base, CreatedAtMixin):
"""Whether a rule belongs on a moment, and who said so (milestone 458 step 7).
"""Whether a rule belongs on a moment, and who said so.
A mount (`rule_moments`) is the current answer; this is the reasoning
behind it and the answers that are NOT mounts. One row per (rule, moment):
@@ -60,7 +60,7 @@ class RuleMomentJudgment(Base, CreatedAtMixin):
note: Mapped[str | None] = mapped_column(Text, nullable=True)
# The co-occurrence evidence (signal source), in lesson_rules' shape.
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# The other direction (step 7b, migration 0119): sessions saying this
# The other direction (migration 0119): sessions saying this
# MOUNTED rule arrived at the moment and did not apply. lesson_rules'
# evidence shape keyed per day, plus the reasons given and the actions
# that reached the moment; `kept_at` once the operator kept the mount.
+2 -2
View File
@@ -25,7 +25,7 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
Integer, ForeignKey("projects.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(Text, default="", server_default="")
# The GLOBAL area this local System is an instance of (milestone 307).
# The GLOBAL area this local System is an instance of.
# Nullable and SET NULL on purpose: the catalog is a convergence aid, not a
# gate — an unmapped System is fully usable, and retiring a canonical entry
# must never take a project's System with it. The local `name` is NEVER
@@ -40,7 +40,7 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
status: Mapped[str] = mapped_column(Text, default="active", server_default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
# The files that ARE this area, as globs relative to the repo root
# (milestone 444). The description says what the area is FOR; these say
# The description says what the area is FOR; these say
# where it lives, so a command or edit touching those files can be
# resolved to the System — and its rulings — without a similarity search.
# NOT NULL with a `[]` default for the reason DesignToken.supersedes gives:
+1 -1
View File
@@ -10,7 +10,7 @@ PULLED = "pulled"
class SystemUsageEvent(Base, CreatedAtMixin):
"""One row per time a System's rulings were SHOWN to the agent because its
files were touched, or the System was PULLED in full (milestone 444).
files were touched, or the System was PULLED in full.
The third of the usage tables, after `note_usage_events` and
`rule_usage_events`, and separate from both for the reason the rule twin
+4 -4
View File
@@ -172,8 +172,8 @@ async def test_smtp():
return jsonify({"error": str(e)}), 500
# The forge CONFIG moved to per-user keyring rows (#2778, Settings → Git
# forges); what stays admin is the webhook secret, because the push endpoint
# The forge CONFIG is per-user keyring rows (Settings → Git forges); what
# stays admin is the webhook secret, because the push endpoint
# is one URL per instance and authenticates deliveries, not users.
@@ -329,13 +329,13 @@ async def run_db_maintenance_now():
return jsonify(summary)
# ── Scheduled backups (milestone 523) ────────────────────────────────────────
# ── Scheduled backups ────────────────────────────────────────────────────────
# One dated, uncompressed tar per run to a folder or an S3 bucket — see
# services/backup_file.py for why it is shaped that way, backup_destination.py
# for where it goes, backup_job.py for when.
# How long "Test" waits on the destination. A folder on a hung NFS mount has
# no timeout of its own (rule 156), and the request must come back either way.
# no timeout of its own, and the request must come back either way.
_BACKUP_CHECK_TIMEOUT = 45.0
+1 -1
View File
@@ -1,5 +1,5 @@
"""Canonical-system routes — the GLOBAL area vocabulary, and the mapping of a
project's Systems onto it (milestone 307, decision note 3026).
project's Systems onto it.
Two shapes live here because they are two halves of one idea:
+1 -1
View File
@@ -1,4 +1,4 @@
"""Design system + token REST endpoints (milestone #254 step 4).
"""Design system + token REST endpoints.
Wraps `services/design_systems.py`, which owns the ACL and the cycle guard.
Design systems are owner-scoped top-level records rather than project-scoped
+3 -3
View File
@@ -1,11 +1,11 @@
"""Family canon routes — the web door to the promotion engine (milestone 463).
"""Family canon routes — the web door to the promotion engine.
The agent decides promotions and each project's answers through the MCP
tools; this door exists so a person can READ every decision and its reasons
— the ideas, the adoption matrix, the log — and retire an idea or undo a
decision when they disagree. The promote and propose endpoints are here for
parity with the agent's door (rule 33), and are recorded as the operator's.
Every write is gated on the idea's note in the service (rule 78).
parity with the agent's door, and are recorded as the operator's.
Every write is gated on the idea's note in the service.
"""
import logging
+7 -11
View File
@@ -14,10 +14,9 @@ logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
# Derived from the service's facet table, never re-listed here. This set was a
# hand-kept copy and had drifted three kinds behind it: it admitted `plan`
# (retired in 0066) and rejected `issue` (shipped in 0065, 435 rows) and
# `snippet` — so the browse surface could not filter to the kinds it was
# already rendering badges for (#3128).
# hand-kept copy drifts behind it — admitting retired kinds and rejecting new
# ones — until the browse surface cannot filter to the kinds it renders
# badges for.
_VALID_TYPES = FACET_TYPES
_VALID_SORTS = {"modified", "created", "alpha", "type"}
@@ -64,13 +63,10 @@ async def list_knowledge():
)
items = await label_shared_items(uid, items)
# The surfaced-vs-opened counts, on the list a person actually browses
# (#4230). `usage_for_notes` always worked on every note row, but only the
# snippet and rule lists ever attached it — and this is the ONLY lesson and
# note list in the UI, so those two kinds had the counter collected and
# shown nowhere. Attaching here rather than teaching `/api/lessons` a
# second time is what closes both holes at once: `/knowledge` is how notes,
# lessons and processes are all browsed.
# The surfaced-vs-opened counts, on the list a person actually browses.
# This is the ONLY lesson and note list in the UI — `/knowledge` is how
# notes, lessons and processes are all browsed — so attaching here is what
# shows those kinds' counters at all.
#
# Mixed kinds is not a problem for this: usage keys on the note row, which
# every facet of this feed is.
+1 -1
View File
@@ -1,5 +1,5 @@
"""Platform routes — the GLOBAL platform catalog, and which platforms a
project is (milestone 463 step 2).
project is.
Two shapes, as with the canonical areas:
+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))
# 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
# 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
# revert would erase the only record of why the rewrite happened.
#
# 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,
# 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
# making that veto expensive — drift supervised in name only. The safeguard
# 323 actually wanted survives intact, because the restore goes through
# edit rather than an undo of your own, and making that veto expensive leaves
# drift supervised in name only. The safeguard survives intact, because the restore goes through
# 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.
@@ -369,7 +368,7 @@ async def unrelate_rules(relation_id: int):
@login_required
async def move_rule(rule_id: int):
"""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 {}
uid = get_current_user_id()
try:
@@ -452,7 +451,7 @@ async def create_project_rule(project_id: int):
)), 201
# ── The staleness sweep (milestone 312) ────────────────────────────────
# ── The staleness sweep ────────────────────────────────────────────────
@rulebooks_bp.get("/rules-due-for-verification")
@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.retrieval_telemetry import record_retrieval
# The interactive floor lives in embeddings.py now, shared with Browse search
# and the list views' semantic `q` — one number, one rationale (#2463).
# The interactive floor lives in embeddings.py, shared with Browse search and
# the list views' semantic `q` — one number, one rationale.
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
@@ -26,10 +26,9 @@ async def search_route():
return jsonify({"error": "q is required"}), 400
limit = min(request.args.get("limit", 10, type=int), 50)
# Every kind the facet table declares, derived rather than mapped here —
# this route used to know exactly two and read anything else as "no
# filter", so `?content_type=snippets` silently returned the whole corpus
# (#4250). An unknown kind is now a 400 naming the ones that exist: a
# Every kind the facet table declares, derived rather than mapped here, so
# no kind can fall through to "no filter" and silently return the whole
# corpus. An unknown kind is a 400 naming the ones that exist: a
# result set is an answer, and it should not be able to answer a question
# nobody asked.
try:
@@ -37,9 +36,9 @@ async def search_route():
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
is_task = filters.get("is_task")
# Same association filters the MCP tool takes (#33). Optional, default
# global: this route has NO frontend consumer today (measured 2026-08-08 —
# the web UI searches through /api/knowledge), so it serves API callers,
# Same association filters the MCP tool takes. Optional, default global:
# the web UI searches through /api/knowledge, so this route serves API
# callers,
# and an API caller states its scope explicitly.
system_id = request.args.get("system_id", type=int)
project_id = request.args.get("project_id", type=int)
+3 -4
View File
@@ -44,7 +44,7 @@ async def list_systems_route(project_id: int):
uid, project_id, include_archived=_truthy(request.args.get("include_archived")),
)
counts = await systems_svc.open_issue_counts_by_system(uid, project_id)
# How often each area's rulings reached a session (#4769) — one GROUP BY
# How often each area's rulings reached a session — one GROUP BY
# for the page, and a zero shape for an area nothing has touched.
usage = await system_usage_svc.usage_for_systems([s.id for s in systems])
out = []
@@ -67,9 +67,8 @@ async def create_system_route(project_id: int):
data = await request.get_json() or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "name is required"}), 400
# The same gate the MCP door enforces. It lived only in the tool layer
# until now, which is exactly how the web UI shipped without gates the
# agent surface had (#2482) — one service call, one answer (rule 33).
# The same gate the MCP door enforces — one service call, one answer, so
# neither door ships without a gate the other has.
assessment = await systems_svc.assess_system_name(uid, project_id, data["name"])
duplicate = assessment["duplicate"]
if duplicate and not data.get("force"):
+2 -3
View File
@@ -242,9 +242,8 @@ async def update_task_route(task_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
# Re-filing a task's kind. The editor has always SENT this field and the
# route silently dropped it — reporting "Task saved" and reverting on the
# next load (#3129). Validated at the same layer as status and priority so
# Re-filing a task's kind. The editor sends this field; dropping it would
# report "Task saved" and revert on the next load. Validated at the same layer as status and priority so
# an unrecognised value is a 400 rather than a database CHECK violation.
if "kind" in data:
try:
+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
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
creates it. Bad signature → 401: that's a caller problem worth signaling.
- 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
would silently turn the feature off — the exact failure mode this
milestone exists to prevent.
@@ -55,7 +55,7 @@ def signature_ok(secret: str, body: bytes, signature: str) -> bool:
def delivered_signature(headers) -> str:
"""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
"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(
"X-Hub-Signature-256", ""
).removeprefix("sha256=")
@@ -104,7 +104,7 @@ async def forge_push():
"forge push %s flagged %d snippet(s) for recheck", head[:12], flagged
)
# 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
# WARNING from spawn(), never a failed delivery. This also SEEDS the
# cache on a webhook-configured instance — no manual first refresh.