docs(6206): settings, projects, api routes and three models drop citations; four capitalised services stragglers
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 24s

- api.py's build-version docstring states why a channel name is not a build
  id, without the dated incident.
- rule_version states why rules need a history, without the rule that was
  rescoped and the date.
- Capitalised "Rule N" / "Milestone N" citations the earlier lowercase grep
  missed, in reply_shapes, supersession, services/family and models/family.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:42:41 -04:00
co-authored by Claude Opus 5.5
parent 78e1bdbd93
commit 0c88e1b0b0
9 changed files with 44 additions and 51 deletions
+6 -7
View File
@@ -1,4 +1,4 @@
"""Family canon (milestone 463): ideas that every project on a platform shares,
"""Family canon: ideas that every project on a platform shares,
and each project's recorded answer to them.
A FAMILY IDEA is not a new kind of record. It is an existing `notes` row — a
@@ -45,7 +45,7 @@ from scribe.models import Base
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
# Each tuple is the whitelist its CHECK constraint enforces (migration 0120).
# Rule 36: a new value means DROP + ADD CONSTRAINT in the same migration.
# A new value means DROP + ADD CONSTRAINT in the same migration.
MEMBERSHIP_STATES = ("declared", "detected", "rejected")
IDEA_STATUSES = ("candidate", "canon", "retired")
ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed")
@@ -73,7 +73,7 @@ class Platform(Base, TimestampMixin, SoftDeleteMixin):
form a backup carries, since ids are per-install.
`markers` are glob patterns over a bound repo's paths that suggest a
project is this platform (step 2's detection). A pattern without a slash
project is this platform (platform detection). A pattern without a slash
matches a file's basename anywhere in the tree; one with a slash matches
the repo-relative path. Empty means declare-only: some platforms leave no
reliable file behind.
@@ -166,7 +166,7 @@ class FamilyIdea(Base, TimestampMixin):
comparing the two numbers, never stored as a flag that could go stale.
`topic_id` is the rule topic holding the idea's binding norms, if it has
any (the note↔topic link #3236 asked for), set by `set_topic`. The topic
any, set by `set_topic`. The topic
never carries platforms of its own; the idea's readouts list its rules,
so an assessment judges a project against them.
"""
@@ -308,8 +308,7 @@ class FamilyDecision(Base, CreatedAtMixin):
`before` and `after` are STATE snapshots — status, version, reason — and
deliberately hold no foreign ids: an id inside JSON cannot be remapped by a
restore, and would come back pointing at whatever took that number (the
#3182 trap). The ids this row needs are columns.
restore, and would come back pointing at whatever took that number. The ids this row needs are columns.
`precedent_ids` are the earlier decisions this one followed. They are what
keeps a call consistent with the last similar one when no person approves
@@ -367,7 +366,7 @@ class FamilyDecision(Base, CreatedAtMixin):
stored again inside `after` (and the prior one inside `before`), and
a promotion's evidence carries the whole criteria reasoning. So a
state here is its status, version and platforms only, and evidence is
the cited list alone (#5131). The full row is `to_dict`, which the
the cited list alone. The full row is `to_dict`, which the
decision log serves.
"""
return {
+5 -5
View File
@@ -10,12 +10,12 @@ from scribe.models.base import CreatedAtMixin, iso
SUGGESTED = "suggested"
CONFIRMED = "confirmed"
REJECTED = "rejected"
# CHECK ck_lesson_rule_links_state (migration 0111, rule 36).
# CHECK ck_lesson_rule_links_state (migration 0111).
LINK_STATES = (SUGGESTED, CONFIRMED, REJECTED)
class LessonRuleLink(Base, CreatedAtMixin):
"""A lesson says it is an instance of a rule (milestone 440, #4196).
"""A lesson says it is an instance of a rule.
A lesson records one concrete situation; a rule records the binding choice
for a class of them. The link lets the rule be reached through the
@@ -53,7 +53,7 @@ class LessonRuleLink(Base, CreatedAtMixin):
# Why it was confirmed or rejected — the reasoning a later reader needs to
# decide whether it still holds, as a rule relation's `note` is.
note: Mapped[str | None] = mapped_column(Text, nullable=True)
# What the suggestion rests on (filled by the co-surfacing recorder, #4637).
# What the suggestion rests on (filled by the co-surfacing recorder).
# Kept after a judgment so a confirmation can be read beside its evidence.
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# When a judgment moved it out of `suggested`. Null while suggested.
@@ -78,12 +78,12 @@ class LessonRuleLink(Base, CreatedAtMixin):
class LessonNoRule(Base):
"""A judgment that no rule governs a lesson's situation (#4631).
"""A judgment that no rule governs a lesson's situation.
The third answer to "which rule is this an instance of?", beside naming
one and leaving it open. It is what lets an UNJUDGED lesson be told apart
from one that was looked at and found to stand alone — and a lesson with
no rule is the raw material #4634 reads for convergence, so the
no rule is the raw material convergence reads, so the
difference is the whole signal.
A row of its own rather than a key in the lesson's `data`: that column is
+5 -8
View File
@@ -10,18 +10,15 @@ from scribe.models.base import CreatedAtMixin, iso
class RuleVersion(Base, CreatedAtMixin):
"""One snapshot of a rule's text, taken before an edit overwrote it.
THE SIBLING NOTES ALREADY HAD. `note_versions` has existed for a long
time, and the design-system note calls its history "the changelog". Rules
— which BIND BEHAVIOUR on every session that loads them — had nothing, so
an edit destroyed what the rule used to say. Rescoping rule 79 on
2026-08-29 meant hand-copying the superseded statement into a task log to
keep it (#3237). The more consequential record had the weaker protection.
THE SIBLING OF `note_versions`. Rules BIND BEHAVIOUR on every session that
loads them; without a history, an edit destroys what the rule used to say,
and the more consequential record has the weaker protection.
`CreatedAtMixin`, not `TimestampMixin`: a version is an EVENT. It is
written once and never updated, so an `updated_at` on it would be a column
that can only ever lie.
WHAT IS DELIBERATELY DIFFERENT FROM NoteVersion (milestone 323):
WHAT IS DELIBERATELY DIFFERENT FROM NoteVersion:
- `user_id` is the ACTOR — who made the edit — where NoteVersion's is the
owner, because `update_note` passes an owner-scoped id. For an audit
@@ -34,7 +31,7 @@ class RuleVersion(Base, CreatedAtMixin):
own lifecycle; folding them in would make one word, "version", mean two
different things — the rule's wording, and the rule's place in the
graph. `verified_at` is likewise absent: it is a stamp about a check,
not a property of the text, and step 2's snapshot is taken before
not a property of the text, and the snapshot is taken before
update_rule clears it precisely so the history shows the check that was
in force when this wording was written.
"""
+6 -9
View File
@@ -12,14 +12,11 @@ async def health():
def build_version_payload() -> dict:
"""What build is this, separated into the values that answer different
questions (rule 149).
questions.
UNTIL 2026-08-31 THIS RETURNED THE CHANNEL. `BUILD_VERSION` in CI was
literally "dev" / "main" / the tag, so a running instance reported
`{"version": "main"}` — a channel name sitting where a build identifier
belongs. The cost was concrete: with a deploy misbehaving, nothing on the
instance could say which commit was serving it, and the one endpoint whose
job that is answered with the name of a branch.
A channel name ("dev", "main") is not a build identifier: with a deploy
misbehaving, the one endpoint whose job is to say which commit is serving
must not answer with the name of a branch.
The three values, and why they are three:
@@ -33,7 +30,7 @@ def build_version_payload() -> dict:
- `channel` — its own field, never folded into the name.
Plus `commit`, so the artifact's claim about itself can be checked against
the `:<sha>` it was published under (rule 145).
the `:<sha>` it was published under.
ABSENT RATHER THAN EMPTY when unknown. A local build has no ordering key
and no channel, and saying so is honest; emitting `""` or a placeholder
@@ -43,7 +40,7 @@ def build_version_payload() -> dict:
payload: dict = {"version": os.environ.get("APP_VERSION", "dev")}
# Reported verbatim, never validated against an enum — a build claiming
# something unexpected is better shown than dropped (rule 149).
# something unexpected is better shown than dropped.
for key, env in (("channel", "APP_CHANNEL"), ("commit", "APP_COMMIT")):
value = (os.environ.get(env) or "").strip()
if value:
+8 -8
View File
@@ -33,10 +33,10 @@ async def list_projects_route():
projects = await list_projects_for_user(uid, status=status)
if include_summary:
# Batched: four queries plus two, in two sessions, for ALL projects.
# This replaced an asyncio.gather over a per-project summary that opened
# its own session and then one more per milestone — ~250 concurrent
# checkouts against a pool of 15, all waiting out the 30s timeout and
# starving every other route on the instance (#2384).
# An asyncio.gather over a per-project summary would open a session per
# project and one more per milestone — far more concurrent checkouts
# than the pool holds, all waiting out the checkout timeout and
# starving every other route on the instance.
#
# Grouped by OWNER because a shared project's counts belong to its
# owner's records, matching what the per-project path passed.
@@ -67,7 +67,7 @@ async def create_project_route():
status = data.get("status", "active")
if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
# The inception decision rides the create (milestone 297): the UI's
# The inception decision rides the create: the UI's
# second step sends `inception: {choices}`; absent = undecided, and the
# project page shows the card until it is. Validated before the create
# so a bad decision never leaves a half-made project behind.
@@ -98,7 +98,7 @@ async def create_project_route():
@projects_bp.route("/<int:project_id>/inception", methods=["POST"])
@login_required
async def decide_inception_route(project_id: int):
"""Record (or re-record) what a project inherits — milestone 297.
"""Record (or re-record) what a project inherits.
Body: the choices object {design_system_id, seed_systems}; owner-only."""
uid = get_current_user_id()
data = await request.get_json() or {}
@@ -185,7 +185,7 @@ async def get_coverage_route(project_id: int):
project, _ = result
owner_uid = project.user_id or uid
return jsonify({
# The OWNER's keyring (#2778) — whether refresh could do anything,
# The OWNER's keyring — whether refresh could do anything,
# regardless of who is looking.
"configured": (await get_forges(owner_uid, project_id)).configured,
"coverage": await cached_coverage(owner_uid, project_id),
@@ -230,7 +230,7 @@ async def refresh_coverage_route(project_id: int):
@projects_bp.route("/<int:project_id>/forge", methods=["PUT"])
@login_required
async def set_project_forge_route(project_id: int):
"""Pin the project to one forge connection, or clear the pin (#2778).
"""Pin the project to one forge connection, or clear the pin.
Body: {"connection_id": <id> | null}. Owner-or-admin may ask; either way
the pin can only reference a connection the project OWNER holds — the
+7 -7
View File
@@ -24,7 +24,7 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# read and skip the mask on write; this generic KV surface has to apply the
# same treatment, or it silently un-masks what those endpoints masked — the
# rows live on the admin's own user_id, so the plain GET returned them raw.
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
# (No forge_token here: forge credentials are keyring rows, since migration 0078.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
# What the tuning history records for a dial changed through this form. The MCP
@@ -62,10 +62,10 @@ async def update_settings_route():
for k, v in data.items():
str_v = str(v)
# A retrieval dial is never written as a plain settings row, wherever
# the request came from (#4102). The value would land correctly and the
# tuning history would say nothing happened — and since milestone 416
# those dials are moved by the model on the operator's behalf, a
# history with holes in it is worse than none: it reads as complete.
# the request came from. The value would land correctly and the tuning
# history would say nothing happened — and since those dials are moved
# by the model on the operator's behalf, a history with holes in it is
# worse than none: it reads as complete.
#
# Routed rather than refused on purpose. Refusing would work only for
# callers that had been updated; this way every caller that ever writes
@@ -113,7 +113,7 @@ async def test_search():
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
# --- forge connections (#2778) ------------------------------------------------
# --- forge connections ---------------------------------------------------------
# The user's keyring: read-only forge credentials, one per host, resolved by
# repo host for every server-side forge read on the user's projects. Strictly
# own-rows — a connection is a credential, and there is no admin view of
@@ -201,7 +201,7 @@ async def delete_forge_connection_route(connection_id: int):
async def test_forge_connection_route(connection_id: int):
"""Probe the SAVED connection: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied per keyring row)."""
fallbacks later, per keyring row."""
from scribe.services.forge import ForgeError, build_adapter
from scribe.services.forge_connections import connection_token, get_connection
+1 -1
View File
@@ -449,7 +449,7 @@ async def _open_ledger(session, user_id: int, note_id: int) -> int:
)).scalars().all())
opened = 0
for pid in sorted(project_ids - answered):
# Rule 78: a promotion reaches only the projects its promoter could
# A promotion reaches only the projects its promoter could
# have written an answer into themselves.
if await access.can_write_project(user_id, pid):
session.add(FamilyAdoption(project_id=pid, idea_id=note_id, status="unassessed"))
+5 -5
View File
@@ -3,11 +3,11 @@
WHY THIS EXISTS
A reply is where the person the work is for finds out what happened, and they
were not there while it was done. Milestone 409 wrote the shapes that make a
reply readable to them — conclusion first, the shortest reply that carries the
answer, where the work stands — and put them in one bundled skill. A skill
arrives only when the agent decides to load it, so in a long session the shape
faded out of context exactly when replies were getting longer.
were not there while it was done. The shapes make a reply readable to them —
conclusion first, the shortest reply that carries the answer, where the work
stands. A skill arrives only when the agent decides to load it, so in a long
session a shape held in a skill fades out of context exactly when replies get
longer.
So the shapes are product content held here, on the server, and delivered by
the moments they belong to rather than by the agent remembering to ask:
+1 -1
View File
@@ -82,7 +82,7 @@ async def set_supersedes(
silent drop, because it is the one case where the caller might reasonably
believe they succeeded and be wrong in a way that matters — demoting someone
else's record out of their retrieval is damage you cannot see from the
outside. Rule #47.
outside.
"""
if not await access.can_write_note(user_id, note_id):
return None