docs(6205): the remaining services files state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m46s
CI & Build / Python tests (push) Successful in 2m36s
CI & Build / Build & push image (push) Successful in 31s

The 1-2 hit tail: access, background, backup, backup_job, canonical_systems,
design_stylesheet, forge_connections, moment_actions, notes, notifications,
placement, planning, platforms, projects, record_batch, recurrence,
report_check, retrieval_review, rule_scope, rule_versions, scheduler,
shape_check, supersession, system_rulings, system_usage, text, trash,
verification, version_pinning.

Also corrected while reading:
- backup.py said restore step 16 fills the System map; step 15 does.
- version_pinning.py pointed at a design doc that is not in the repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:34:24 -04:00
co-authored by Claude Opus 5.5
parent 172e784cb2
commit fa4f88747e
29 changed files with 82 additions and 100 deletions
+1 -1
View File
@@ -260,7 +260,7 @@ async def can_write_design_system(user_id: int, design_system_id: int) -> bool:
# ---------------------------------------------------------------------------
# Set-based visibility (for LIST queries)
#
# Two scopes, deliberately different (decision note 2094):
# Two scopes, deliberately different:
#
# readable_* — everything the ACL permits, including a record reachable ONLY
# through a direct or group note share. For EXPLICIT acts: a
+5 -5
View File
@@ -2,7 +2,7 @@
The event loop holds only a WEAK reference to a task, so a bare
``create_task`` with no other holder can be garbage-collected mid-flight — a
write that never errors and never lands (the #2663 GC footgun). This module is
write that never errors and never lands. This module is
the one place that gets the pattern right: strong references in ``_pending``,
discarded on completion, with failures logged at WARNING instead of vanishing.
@@ -36,10 +36,10 @@ async def report_telemetry_failure(subsystem: str, site: str) -> None:
WARNING to the process log every time; one AppLog error row per process per
(subsystem, site) so the admin UI shows the outage without host access.
THIS IS NOT DECORATION. #2663 is the record of a telemetry subsystem running
at zero for weeks — every counter reading empty, indistinguishable from
"nobody uses this" — because every failure went to ``logger.debug``. A
subsystem whose failures are all invisible cannot report its own death.
THIS IS NOT DECORATION. A telemetry subsystem whose every failure goes to
``logger.debug`` can run at zero — every counter reading empty,
indistinguishable from "nobody uses this". A subsystem whose failures are
all invisible cannot report its own death.
The AppLog write is itself guarded: when the database is down it fails too,
and that is fine. The WARNING already said so, and a canary must never take
+1 -1
View File
@@ -2781,7 +2781,7 @@ async def _restore_v2(data: dict) -> dict:
session.add(event)
stats["rule_usage_events"] += 1
# And the System twin, after the Systems (step 16 fills their map).
# And the System twin, after the Systems (step 15 fills their map).
for ev in data.get("system_usage_events", []):
event = _build_system_usage_event(ev, maps)
if event is None:
+3 -3
View File
@@ -3,8 +3,8 @@
ONE APScheduler job (services/scheduler.py) fires at the top of every hour and
reads the config at fire time: when backups are on and the hour is the chosen
one, it runs. Reading at fire time rather than scheduling the chosen hour
keeps the app's startup free of a database read (#4181 — a lifespan hook that
waits on the database is a worker that never serves), and a changed hour
keeps the app's startup free of a database read (a lifespan hook that waits
on the database is a worker that never serves), and a changed hour
needs no reschedule.
A run exports the full backup, writes it as the dated tar
@@ -40,7 +40,7 @@ KEEP_RUNS = 30
# room for one slow run, so a backup that lands at 02:40 instead of 02:05
# does not flicker the page into a warning.
STALE_AFTER = timedelta(hours=26)
# The whole run's deadline (rule 156: a wait with no deadline is a bug). The
# The whole run's deadline, because a wait with no deadline is a bug. The
# S3 upload has its own 15-minute timeout, but a folder on a hung NFS mount
# has none, and a run that never returns holds the lock — every later backup
# would be skipped as "already running", silently, forever.
+3 -3
View File
@@ -1,5 +1,5 @@
"""The global canonical area vocabulary, and the mapping from a project's
Systems onto it (milestone 307 step 1, decision note 3026).
Systems onto it.
A `System` is per-project. Nothing outside a project can reference one, so a
rule that spans projects has no way to say "this is about CI" without chaining
@@ -210,8 +210,8 @@ async def set_system_canonical(
"""Map (or unmap) one project System onto a catalog entry.
Authorised by the PROJECT, not the catalog: mapping changes the project's
row, so project write access is the right gate (rule 78 — never a bare
owner filter). Passing None clears the mapping.
row, so project write access is the right gate — never a bare owner
filter. Passing None clears the mapping.
Touches `canonical_id` and nothing else — the System's own name, charter
and record associations are left exactly as they are.
+3 -3
View File
@@ -24,7 +24,7 @@ with every new element, where a purpose name (`--action-primary`) is reused.
SAFETY
------
Values are interpolated into a stylesheet, and design systems are shareable
records (rule #47). A value of `red; } body { display: none` in a system someone
records. A value of `red; } body { display: none` in a system someone
shared with you would otherwise inject CSS into your page. Everything rendered
here is validated or dropped — never escaped-and-hoped.
"""
@@ -75,8 +75,8 @@ def selector_for_mode(mode: str, root_selector: str = ":root") -> str:
attribute selector, matching the convention already in the codebase.
The root selector is a PARAMETER because a container-scoped preview cannot
use `:root` — #251 recorded that mode scoping is one-way (light lives on
`:root`, dark layers over it), so a generator that hardcoded `:root` could
use `:root` — mode scoping is one-way (light lives on `:root`, dark layers
over it), so a generator that hardcoded `:root` could
not serve a preview at all.
"""
if mode == BASE_MODE:
+1 -1
View File
@@ -1,4 +1,4 @@
"""User-level forge connection CRUD — the keyring rows get_forges reads (#2778).
"""User-level forge connection CRUD — the keyring rows get_forges reads.
A connection is a user's read-only credential for one forge host; one row per
(user, host) keeps host-keyed resolution deterministic with no default-pointer
+2 -2
View File
@@ -1,4 +1,4 @@
"""Which actions reach which moment: shipped defaults plus each install's own (milestone 458 step 2).
"""Which actions reach which moment: shipped defaults plus each install's own.
The catalog (`services/moments.py`) says what the moments ARE. This module says
how the work gets there. One call can reach several moments: `kubectl apply`
@@ -63,7 +63,7 @@ COMMAND_TOOLS = frozenset({"bash"})
# procedures' own.
SKILL_TOOL, SKILL_FIELD = "skill", "skill"
# A procedure also reaches the moment it is FOR (step 5): loading the
# A procedure also reaches the moment it is FOR: loading the
# reporting procedure is a report as surely as the reply that follows it.
#
# The product's own skills declare theirs in their SKILL.md frontmatter
+1 -1
View File
@@ -845,7 +845,7 @@ async def mark_note_verified(
So a failed check leaves `verified_at` untouched and the note stays at the
top of the sweep until someone actually deals with it.
Write access, not read (rules 47/78): stamping is a mutation, and an
Write access, not read: stamping is a mutation, and an
editor-share holder may make it while a viewer may not.
Returns None when the note is not found, not writable, or carries no
+4 -5
View File
@@ -149,7 +149,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
def utc_day_start(day: date) -> datetime:
"""Midnight UTC on `day`, as an AWARE datetime — the reminder dedup window.
Deliberately a `datetime` and not the bare `date` (#1727). Comparing a
Deliberately a `datetime` and not the bare `date`. Comparing a
`timestamptz` column against a `date` does work, via an implicit cast — but
Postgres resolves that cast in the SESSION's TimeZone, so the window would
move with a server setting nobody remembers is load-bearing. An aware UTC
@@ -200,10 +200,9 @@ async def check_due_tasks() -> None:
# Dedup: check if we already sent a reminder today.
# `window_start` is an aware datetime, NOT `today.isoformat()`.
# asyncpg binds a str as VARCHAR and Postgres has no
# `timestamptz >= text` operator, so this raised on every run
# that got this far — swallowed by the per-user `except` below,
# which is why the only symptom was reminders silently never
# sending plus an hourly traceback in the DB log (#1727).
# `timestamptz >= text` operator, so a string would raise on
# every run — swallowed by the per-user `except` below, leaving
# reminders silently never sending.
dedup_result = await session.execute(
select(func.count(AppLog.id)).where(
AppLog.action == "task_reminder",
+4 -5
View File
@@ -1,13 +1,12 @@
"""Where a task sits — its project, its milestone, its step position, what is next.
WHY THIS EXISTS (milestone 409 step 1)
WHY THIS EXISTS
An agent reporting finished work to the operator is asked to place it: which
milestone, which step of how many, what comes next. Without those facts in
hand it reconstructs them from memory, and a reconstruction reads exactly like
the real thing while being wrong — the drafting of the feature note that
started this milestone invented a milestone title and named a "next" step that
was already done. So the facts come back on the write that changes a task,
the real thing while being wrong — an invented milestone title, a "next" step
that is already done. So the facts come back on the write that changes a task,
where the report is about to be written, instead of being left to recall.
THE SHAPE
@@ -57,7 +56,7 @@ from scribe.services import access as access_svc
from scribe.services.milestones import OPEN_STEP_STATUSES, _progress_from_counts
# Imported, not restated. The milestone summary names a plan's next open step
# too (#4154), and the two answers must agree about what "open" means.
# too, and the two answers must agree about what "open" means.
_OPEN = OPEN_STEP_STATUSES
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -9,7 +9,7 @@ kind=plan task is retired going forward — start_planning never creates one.
Steps passed up front are created in the SAME transaction as the milestone
(services/record_batch.py), so the plan body and the steps can cite each other
through `{{ref:N}}` placeholders instead of predicted ids (#4016).
through `{{ref:N}}` placeholders instead of predicted ids.
"""
from __future__ import annotations
+2 -2
View File
@@ -1,5 +1,5 @@
"""Platforms — what a project is built on or ships as, and which projects are
which (milestone 463 step 2).
which.
Membership is what makes "is this idea in family for that project?" a lookup
rather than a judgment: a family idea is for some platforms, and it reaches
@@ -266,7 +266,7 @@ async def set_project_platforms(
user_id: int, project_id: int, updates: dict[str, str | None],
) -> list[dict]:
"""Apply a person's answers about a project's platforms. Write-gated on
the project (rule 78). Platforms not named are left exactly as they are.
the project. Platforms not named are left exactly as they are.
Raises ValueError, naming the problem, on a malformed update, an unknown
slug, or no write access — before anything is written, so the update
+9 -12
View File
@@ -132,19 +132,16 @@ async def get_project_summaries(
) -> dict[int, dict]:
"""Summaries for MANY projects — four queries and one session, total.
Replaces an `asyncio.gather` over the per-project version below, which was
a nested fan-out: each project opened its own session for three queries,
then called the milestone summary, which opened one more per milestone. For
25 projects that asked for roughly 250 pooled connections at once against a
pool of 15 (SQLAlchemy's default 5 + 10 overflow), so most of them sat out
the 30-second checkout timeout and everything else on the instance queued
behind them — including unrelated routes, which is why /api/settings
returned 500 while /api/projects took 30.9s (#2384).
Not an `asyncio.gather` over the per-project version below: that is a
nested fan-out — each project opens its own session for three queries,
then calls the milestone summary, which opens one more per milestone. For
a few dozen projects that asks for far more pooled connections than the
pool holds, so most of them sit out the checkout timeout and everything
else on the instance queues behind them, unrelated routes included.
The comment it replaced said "one backend pass instead of N+1 frontend
calls". It did remove the N+1 from the network — and recreated it against
the connection pool, where it is worse: the browser had at least been
serialising those calls.
Moving an N+1 from the browser to the server does not remove it; it moves
it onto the connection pool, where it is worse — the browser at least
serialises its calls.
"""
if not project_ids:
return {}
+4 -5
View File
@@ -1,9 +1,8 @@
"""Create several records at once, so they can cite each other without guessing ids.
Why this exists is in services/record_refs.py (#4016): a session that needs
records to reference one another used to create them one by one and predict
the ids of the ones not yet made — and any concurrent create, from any session
or any user, took those numbers.
Why this exists is in services/record_refs.py: a session that creates records
one by one and predicts the ids of the ones not yet made loses those numbers
to any concurrent create, from any session or any user.
Here the whole batch is ONE transaction: insert every record, flush so the
sequence assigns the real ids, rewrite each `{{ref:N}}` with the id and title
@@ -128,7 +127,7 @@ async def create_batch(
project_id = existing.project_id
if milestone is not None and not project_id:
raise ValueError("a new milestone needs a project_id")
# Share-aware (rule 78): a collaborator with write access to a shared
# Share-aware: a collaborator with write access to a shared
# project may plan in it; a bare owner filter would refuse them.
if project_id and not await access_svc.can_write_project(user_id, project_id):
raise ValueError(f"project {project_id} not found")
+1 -2
View File
@@ -138,8 +138,7 @@ async def spawn_recurring_tasks() -> int:
recurrence_rule=task.recurrence_rule,
)
# No explicit embed here: create_note calls embed_note itself, so
# the spawn path stopped being a second copy of the embedding rule
# the moment that moved into the service (#2056, #280).
# the spawn path is not a second copy of the embedding rule.
except Exception:
logger.exception("Failed to spawn recurring task %d", task.id)
continue
+5 -8
View File
@@ -1,7 +1,7 @@
"""The report-shape check: does a reply that closed a task carry the completion
sections, and what is it sent back with when it does not.
WHY THIS EXISTS (milestone 409 step 5)
WHY THIS EXISTS
Everything that helps an agent write a readable completion report arrives
BEFORE the reply is written. The Stop hook is the one moment the finished reply
@@ -13,13 +13,10 @@ exists, so a reply that closed a task is checked for the completion sections
- OWNING THE WORDS the agent is sent back with, so every client's hook says
the same thing (plugin/PACKAGING.md: hooks carry timing and transport).
ONE END-OF-TURN REQUEST (milestone 500 step 4). This used to be a Stop hook of
its own that checked the sections in shell and reported here; the reply-moment
check sent the same reply to `/reply-rules` a moment later. The hook now sends
the reply once, with the ids of the tasks the turn closed, and the section
check runs here beside the reply hold. The patterns are the ones the shell
used, matched on the words that carry the meaning rather than exact headings,
so a shape's wording can change without breaking this.
ONE END-OF-TURN REQUEST. The Stop hook sends the reply once, with the ids of
the tasks the turn closed, and the section check runs here beside the reply
hold. The patterns match the words that carry the meaning rather than exact
headings, so a shape's wording can change without breaking this.
app_logs rather than a table of its own: one small event with a JSON detail is
what that table holds, it already has retention and an admin viewer, and
+3 -3
View File
@@ -1,8 +1,8 @@
"""Judging what a retrieval arm offered — the review pass (#4772).
"""Judging what a retrieval arm offered — the review pass.
The usage tables record what the reader DID with a line: opened it or not.
Since every menu line carries its matched passage (#4364), that action no
longer stands in for relevance — a line can be unrelated, related and enough
Because every menu line carries its matched passage, that action does not
stand in for relevance — a line can be unrelated, related and enough
as shown, related and already in context, or related and ignored, and all four
are "not opened". Only someone reading the query beside the line can tell
them apart. This module is how that reading gets done and kept.
+3 -4
View File
@@ -1,4 +1,4 @@
"""Which rules a caller may be shown — a rule's HOME, stated once (milestone 414).
"""Which rules a caller may be shown — a rule's HOME, stated once.
A rule lives in a rulebook topic — GLOBAL, it applies wherever its owner
works — or on one project, where it applies to that project and nowhere else.
@@ -13,9 +13,8 @@ home, so it is answered here and nowhere else:
- `everywhere=True`: every rule the caller owns, in any home — the explicit
whole-rulebook question.
It was written inline in `semantic_search_rules` until the moment lookup
(milestone 458) needed the same answer. Two copies of a scope clause is how
one of them starts surfacing another project's rules, so it moved here first.
Rule search and the moment lookup both need this answer. Two copies of a
scope clause is how one of them starts surfacing another project's rules.
"""
from __future__ import annotations
+1 -1
View File
@@ -8,7 +8,7 @@ lifecycle: a soft delete keeps the rule row whole and restorable, so there is
nothing to preserve at delete time, and anything that outlived a purge would be
data the operator explicitly asked to be gone.
WHAT IS DELIBERATELY NOT COPIED FROM note_versions (milestone 323). Both of its
WHAT IS DELIBERATELY NOT COPIED FROM note_versions. Both of its
guards defend against note autosave, which fires every 60 seconds and would
otherwise burn every slot. Rules have no autosave — every version here comes
from a deliberate `update_rule` — so both guards would only ever discard a real
+2 -2
View File
@@ -1,6 +1,6 @@
"""One APScheduler job bridged into the asyncio loop — the shape the four
"""One APScheduler job bridged into the asyncio loop — the shape the
*_scheduler modules (recurrence spawn, auto-pin scan, trash purge, DB
maintenance) each used to carry a private copy of.
maintenance) share instead of each carrying a private copy.
APScheduler's BackgroundScheduler fires from a worker thread; the work is
async and must run on the app's loop, so the fire is bridged with
+8 -9
View File
@@ -1,15 +1,14 @@
"""The end-of-turn shape check: what a turn wrote that nobody has judged, and what the agent is asked.
WHY THIS EXISTS (milestone 439)
WHY THIS EXISTS
The shape ledger used to be filled by machinery: extraction found the
definitions, framework rules decided which were one-offs, and the write-path
hook stamped "instance of #N" with nobody reading. The agent that WROTE the
code — the one participant who knows what it is — was asked only in an audit,
weeks later, if anyone ran one. A first instance of a reusable piece was never
asked about at all, so it was never recorded, and the next session rebuilt it.
The agent that WROTE the code is the one participant who knows what it is.
Machinery can find definitions and stamp "instance of #N", but it cannot say
that a first instance is a reusable piece — and a piece never recorded is
rebuilt by the next session. An audit run later asks too late, if anyone runs
one.
So the question moves to the moment the knowledge exists. The client's write
So the question is asked at the moment the knowledge exists. The client's write
hooks keep a ledger of the definitions each turn wrote; its Stop hook sends
them here at the end of the turn. Two jobs live on this side, as with the
report check (services/report_check.py):
@@ -66,7 +65,7 @@ def _evidence_note(evidence: dict) -> str:
def block_reason(unjudged: list[dict], *, project_id: int, repo: str) -> str:
"""What the agent is told at the end of a turn that wrote unjudged shapes.
Phrased as the practice wanted (note #3565): what to decide and the one
Phrased as a practice: what to decide and the one
call that records it, not a prohibition. The reusing-code skill owns the
longer version of what each verdict means.
"""
+3 -3
View File
@@ -1,6 +1,6 @@
"""Which records have been overtaken by which — the claim, not the ranking.
Step 2 of milestone #278. This module only records and reads the relation; the
This module only records and reads the relation; the
demotion that makes it matter lives in the retrieval layer.
WHY THE CLAIM POINTS FORWARD
@@ -202,8 +202,8 @@ async def attach_relations(user_id: int, note_id: int, data: dict, *, hint: bool
acted on confidently, which is worse than never surfacing it.
Omitted entirely when empty, so an ordinary note's payload doesn't grow
two permanently-empty lists (#2483 — a field that always says nothing
trains readers to skip fields). `hint=True` (the agent surface) also
two permanently-empty lists — a field that always says nothing trains
readers to skip fields. `hint=True` (the agent surface) also
attaches `superseded_note`, the one-sentence reading instruction.
"""
rel = await get_relations(user_id, note_id)
+2 -2
View File
@@ -1,4 +1,4 @@
"""An area's rulings, delivered when its files are touched (milestone 444).
"""An area's rulings, delivered when its files are touched.
A RULING is the operator's decision about how one area of the work must behave.
It lives in a `Rulings` section at the end of the area's System description
@@ -13,7 +13,7 @@ patterns or it does not. It takes no budget and no floor from the retrieval
arms beside it, and writes no retrieval_logs row — there is no score
distribution for it to join. Its surfacings go to `system_usage_events`.
ONCE IN FULL, THEN A REFERENCE (the #3750 shape). The hook keeps the Systems
ONCE IN FULL, THEN A REFERENCE. The hook keeps the Systems
already shown this session and passes them back; a repeat renders as one short
line rather than the rulings again, and is not counted as a surfacing.
+2 -3
View File
@@ -1,6 +1,6 @@
"""System usage telemetry — were an area's rulings read once shown?
The twin of `note_usage` and `rule_usage` for Systems (milestone 444). The
The twin of `note_usage` and `rule_usage` for Systems. The
rulings arm shows a System's rulings when a command or edit touches the
System's files; a pull is somebody then opening the System (`get_system`). The
ratio says whether delivering rulings by path earns its line.
@@ -97,8 +97,7 @@ async def usage_for_systems(system_ids: list[int]) -> dict[int, dict]:
One GROUP BY for the whole list, never a query per row — this decorates a
list view. Systems with no events come back as `empty_system_usage()`.
Fails open: a readout that could break the Systems list is worse than a
zero, but the failure is reported so the zero is not mistaken for silence
(#2663).
zero, but the failure is reported so the zero is not mistaken for silence.
"""
ids = [int(s) for s in system_ids]
out: dict[int, dict] = {sid: empty_system_usage() for sid in ids}
+3 -5
View File
@@ -25,7 +25,7 @@ def elide(text: str, budget: int) -> tuple[str, bool]:
This is the fallback, not the goal. Where the system knows WHICH span of a
record is the relevant one — a semantic search knows exactly that, and
stores it (#4243) — show that span and say so. Reach for this only when
stores it — show that span and say so. Reach for this only when
nothing identifies a better part than "all of it".
"""
if budget <= 0 or len(text) <= budget:
@@ -41,11 +41,9 @@ def elide(text: str, budget: int) -> tuple[str, bool]:
# What a search result shows a reader, as one decision made in one place.
#
# Every door onto a semantic search faces the same question — which span of a
# record do I show someone deciding whether to open it? — and each used to
# answer it separately with a bare head cut of a different length: 240
# characters in the MCP search, 200 in the web's knowledge search. Both showed
# record do I show someone deciding whether to open it? A bare head cut shows
# the document's OPENING, which is not the span that matched and not the span
# the ranking was built on.
# the ranking was built on, and a cut per door gives each a different answer.
#
# Doors keep their own field names (the web UI renders `snippet`, the MCP
# surface returns `excerpt`), because renaming a field a frontend consumes is
+1 -1
View File
@@ -170,7 +170,7 @@ async def restore_entity(user_id: int, entity_type: str, entity_id: int) -> int
The inverse of `delete(user_id, entity_type, entity_id)`, which returns a
batch id the caller usually doesn't keep. Callers that need to undo their own
soft-delete later — snippet un-merge (#2165) — know the entity id, not the
soft-delete later — snippet un-merge — know the entity id, not the
batch, and looking it up here keeps them from reaching into the column set.
Restores the whole batch on purpose, not just the row: the batch is the
+4 -5
View File
@@ -1,11 +1,10 @@
"""What a record's own check MEANS — shared by rules and notes.
Two record types carry `verify_with` / `expires_when` / `verified_at`: rules
(milestone 312) and notes (milestone 317). What they share is BEHAVIOUR — how
a stamp is read, how old it is, what "never" means — not storage and not the
query. Note 3163 is the rule this file is an instance of: `semantic_search_*`
could never have been shared between them because a rule scopes by rulebook
ownership and a note scopes by the note ACL, so the two sweeps are siblings.
and notes. What they share is BEHAVIOUR — how a stamp is read, how old it is,
what "never" means — not storage and not the query. The two sweeps stay
separate siblings, because a rule scopes by rulebook ownership and a note
scopes by the note ACL.
These functions are the part that genuinely is common, factored so it exists
once rather than twice.
-2
View File
@@ -8,8 +8,6 @@ Tiers (pin_kind values):
None → rolling autosave; capped at MAX_VERSIONS, FIFO.
"auto" → system-declared via the stability scan; capped at MAX_AUTO_PINS, FIFO.
"manual" → user-declared; unlimited, never pruned.
Design: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md
"""
from __future__ import annotations