Compare commits

...
3 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 fa4f88747e 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>
2026-10-21 20:34:24 -04:00
bvandeusenandClaude Opus 5.5 172e784cb2 docs(6205): rule_moment_judgments, retrieval_migration, repo_bindings, moments, logging, design_systems comments drop citations
retrieval_migration states what it lets a tuned floor do without quoting
the operator, and its order-of-operations refusal is described as enforced
rather than as history. logging states why raw date filters fail rather
than citing the two issues that found it. The rest drop milestone, step and
record citations; step references now name the code that does the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:32:07 -04:00
bvandeusenandClaude Opus 5.5 cc5401b2d3 docs(6205): task_logs, task_claims, reply_shapes, design_starter_roles, db_maintenance_scheduler comments drop citations and incident stories
- db_maintenance_scheduler states the storage-stall hazard the startup
  timeout guards, without the dated outage.
- design_starter_roles states why a missing role produces literals, without
  the call-site count, the operator's call date or a project's name.
- The rest drop milestone/step labels and record citations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:31:14 -04:00
40 changed files with 150 additions and 177 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.
+12 -15
View File
@@ -25,16 +25,14 @@ logger = logging.getLogger(__name__)
_JOB_ID = "db_maintenance_vacuum"
_DEFAULT_HOUR = 4
# HOW LONG A COLD START WILL WAIT FOR THIS ONE SETTING (#4181).
# HOW LONG A COLD START WILL WAIT FOR THIS ONE SETTING.
#
# This read is the FIRST database call in the app's `before_serving` hook, and
# a lifespan hook that does not return is a worker that never serves. On
# 2026-09-19 a host storage stall made one Postgres checkpoint of 14 buffers
# take 281 seconds against a 1.3-second baseline; the app restarted into the
# tail of it, this query hung with no deadline, Hypercorn killed the worker at
# its 60-second lifespan timeout, and nothing retries a failed lifespan. A
# five-minute disk hiccup became a three-hour outage that only a human restart
# could clear.
# a lifespan hook that does not return is a worker that never serves. If the
# app starts while the database host is stalled on storage, a query with no
# deadline hangs, Hypercorn kills the worker at its 60-second lifespan timeout,
# and nothing retries a failed lifespan — a few minutes of disk trouble becomes
# an outage that only a human restart clears.
#
# Three seconds because the honest requirement is "don't hold up the boot",
# not "get the right hour". The value is one small indexed row on a local
@@ -47,12 +45,11 @@ _STARTUP_READ_TIMEOUT = 3.0
async def get_maintenance_hour() -> int:
"""The configured run-hour (UTC, 0–23), clamped; default 04:00.
BOUNDED, because the caller is a lifespan hook (rule 156). The fallback is
not new behaviour invented for the timeout — this function already answers
`_DEFAULT_HOUR` for a value it cannot parse, and a database that will not
answer in three seconds is the same class of "no usable value here". What
changes is that the failure is now a logged line and a default hour rather
than the application failing to start.
BOUNDED, because the caller is a lifespan hook. The fallback is the same
one this function gives a value it cannot parse: a database that will not
answer in three seconds is the same class of "no usable value here". The
failure is a logged line and a default hour rather than the application
failing to start.
Degrading to the default is the right trade in both directions: the cost of
being wrong is that a VACUUM runs at 04:00 instead of the configured hour,
@@ -66,7 +63,7 @@ async def get_maintenance_hour() -> int:
)
except (TimeoutError, asyncio.TimeoutError):
# WARNING, not debug: this is never normal, and it is the breadcrumb
# that would have named #4181 in seconds instead of requiring
# that names a stalled database in seconds instead of requiring
# Postgres's own log to be read.
logger.warning(
"db maintenance: reading db_maintenance_hour exceeded %.1fs; "
+13 -14
View File
@@ -3,18 +3,17 @@
WHY THIS EXISTS
---------------
A literal gets written into a stylesheet when there is no role to reach for.
That is the mechanism, and this codebase produced a clean demonstration of it:
the house style had no "text on a filled colour" role, so 76 call sites wrote
a pure-white literal — not out of defiance, but because nothing existed to write
instead (#2275). The correction was not a better ban list. It was declaring the
missing role.
A house style with no "text on a filled colour" role gets a pure-white literal
at every call site that needs one — not out of defiance, but because nothing
exists to write instead. The correction is not a better ban list. It is
declaring the missing role.
So the useful moment is CREATION. A system whose roles are named on day one
never presents the occasion for a literal, and never needs a list of values it
forbids.
WHAT SHIPS AND WHAT DOES NOT (rule #115)
----------------------------------------
WHAT SHIPS AND WHAT DOES NOT
----------------------------
The ROLES ship: `surface-page`, `text-primary`, `action-destructive` are
generic CSS-design vocabulary, not one operator's kit. Every install that has a
page has a page background.
@@ -25,21 +24,21 @@ appears anywhere in this file, and none should ever be added to it — a default
palette would be this operator's palette wearing product clothes.
A valueless token is already legible downstream: `render_stylesheet` emits it as
a commented-out declaration in its group (#2299), and `stylesheet_for_system`
a commented-out declaration in its group, and `stylesheet_for_system`
reports it under `valueless`. So a blank role reads as "to be decided" rather
than as breakage, without anything new.
THE PREFIX IS THE INSTALL'S
---------------------------
`--fs-` is FabledSword's convention, not the product's. The prefix is a
parameter with a neutral default; a caller that has a house convention passes
it. Baking `--fs-` in would put one family's naming into every install.
A house prefix such as `--fs-` is one family's convention, not the
product's. The prefix is a parameter with a neutral default; a caller that has
a house convention passes it. Baking one in would put one family's naming into
every install.
FLAT, NOT PRESET
----------------
One list, every group individually skippable, all on by default (operator's
call, 2026-08-03). Presets keyed to app shape — web / CLI / docs — were
considered and rejected: they would require the product to hold opinions about
One list, every group individually skippable, all on by default. Presets
keyed to app shape — web / CLI / docs — are not offered: they would require the product to hold opinions about
app categories, and a wrong category is worse than a generic list someone
prunes once.
"""
+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:
+3 -3
View File
@@ -1,6 +1,6 @@
"""Design system + token persistence, and the guard on the parent chain.
Access goes through `services/access.py` (rule #78), where reaching a system via
Access goes through `services/access.py`, where reaching a system via
a project you can see grants READ but never write — see
`get_design_system_permission` for why that asymmetry exists.
@@ -92,7 +92,7 @@ async def create_design_system(
which, per the ACL, means one they do not own.
`starter_role_groups` seeds the system with named, VALUELESS token roles
(#2349) — the moment a role is missing is the moment a literal gets written
— the moment a role is missing is the moment a literal gets written
instead, so the cheapest time to name them is now. Pass a list of group
names to choose, `[]` for none, or None for none.
@@ -244,7 +244,7 @@ async def create_token(
async def list_tokens(user_id: int, design_system_id: int) -> list[DesignToken]:
"""One system's OWN tokens — its override set, not its effective set.
Resolving the chain is step 2's job; this deliberately answers the narrower
Resolving the chain is `resolve_design_system`'s job; this deliberately answers the narrower
question ("what does this system change?") that the model exists to make
free.
"""
+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
+4 -5
View File
@@ -92,11 +92,10 @@ async def log_error(
def parse_filter_datetime(value: str | None, *, end_of_day: bool = False) -> datetime | None:
"""An ISO date/datetime from a query string as an AWARE UTC datetime.
The admin log filters arrive as raw `request.args` strings and used to be
compared straight against `AppLog.created_at`. asyncpg binds a str as
VARCHAR and Postgres has no `timestamptz >= text` operator, so supplying
either date filter raised — the same defect as #1727 in notifications, in a
second place (#2254).
The admin log filters arrive as raw `request.args` strings. Compared
straight against `AppLog.created_at`, they fail: asyncpg binds a str as
VARCHAR and Postgres has no `timestamptz >= text` operator, so either date
filter would raise.
Returns None for anything unparseable: a malformed filter should narrow
nothing rather than 500 the log viewer.
+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
+3 -3
View File
@@ -1,4 +1,4 @@
"""The moments of work that rules mount on (milestone 458).
"""The moments of work that rules mount on.
WHY THIS EXISTS
@@ -19,12 +19,12 @@ the same mount points, or a rule written on one could not mean the same thing on
another, and the plugin's hooks, the skills that declare moments and the
instruction surfaces all name them. What varies per install is which ACTIONS
reach a moment — one operator delivers with a push, another with a deploy
script, a third by sending a document — and that mapping is data (step 2). The
script, a third by sending a document — and that mapping is data (`moment_actions`). The
moment is generic; the action that reaches it is local.
HOW TO WRITE ONE
Domain-neutral (rule 115): Scribe's work may be software, configuration,
Domain-neutral: Scribe's work may be software, configuration,
infrastructure or anything else a person drives through an agent, so a
definition names the moment in words every one of those recognises. The test
beside this module refuses software-only vocabulary in both fields. Concrete
+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
+6 -7
View File
@@ -1,4 +1,4 @@
"""The default shapes of a reply, and the moment each one rides (milestone 500).
"""The default shapes of a reply, and the moment each one rides.
WHY THIS EXISTS
@@ -33,7 +33,7 @@ the operator asked for.
HOW TO WRITE ONE
Domain-neutral (rule 115): the work may be software, configuration,
Domain-neutral: the work may be software, configuration,
infrastructure or anything else a person drives through an agent. "Evidence"
is what the reader could open to check; "verified" is whatever checking means
for that work. The test beside this module refuses software-only vocabulary.
@@ -80,12 +80,11 @@ class ReplyShape:
CORE_KEY = "core"
# ~550 tokens at four characters a token. The core is paid for in every
# session, so this is the ceiling the test holds it to — not a target. It sits
# at ~2,050 with the judgement line (milestone 500 step 2): close to full, so a
# sentence added now has to replace one.
# session, so this is the ceiling the test holds it to — not a target. The
# core sits close to it, so a sentence added has to replace one.
CORE_BUDGET_CHARS = 2200
# A slice is paid for only at its moment, and once per session; it can afford
# more than the core and still has to stay a slice, not the old skill again.
# more than the core and still has to stay a slice, not a whole skill.
# The asks slice is the largest (~1,530) because it carries who decides what in
# full: handing a question back is what an ask is tempted to do.
SLICE_BUDGET_CHARS = 1600
@@ -289,7 +288,7 @@ async def record_delivery(user_id: int | None, forms: dict[str, str], *, via: st
logger.debug("reply shape delivery not recorded", exc_info=True)
# ── The operator's view (milestone 500 step 5) ──────────────────────────
# ── The operator's view ─────────────────────────────────────────────────
# The window the Settings view counts deliveries over — the same 30 days the
# moments panel beside it reads, so the two count the same stretch of work.
+3 -3
View File
@@ -73,7 +73,7 @@ async def set_binding(
) -> RepoBinding:
"""Create or update the binding for a repo. Idempotent on (user, repo_key).
``ref`` (#2873) is the branch the coverage refresh reads for this
``ref`` is the branch the coverage refresh reads for this
binding: a name sets it, ``""`` clears it back to the forge's default
branch, ``None`` leaves whatever stands (a re-bind that only moves the
project keeps the ref it had).
@@ -111,7 +111,7 @@ async def list_bindings(user_id: int) -> list[RepoBinding]:
async def bindings_for_project(user_id: int, project_id: int) -> list[RepoBinding]:
"""Every binding of a project — key AND the ref its ledger follows (#2873)."""
"""Every binding of a project — key AND the ref its ledger follows."""
async with async_session() as session:
rows = await session.execute(
select(RepoBinding).where(
@@ -141,7 +141,7 @@ async def is_bound(project_id: int, raw_repo: str) -> str:
async def keys_for_project(user_id: int, project_id: int) -> list[str]:
"""Every repo key bound to a project — the snippet→forge join (#2691).
"""Every repo key bound to a project — the snippet→forge join.
Recorded snippet locations carry free-form repo names ("Scribe"), which
can't address a forge API. The project's binding is the identity that can:
+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
+9 -11
View File
@@ -1,4 +1,4 @@
"""Carrying a tuned floor across an embedding-model change (#4104).
"""Carrying a tuned floor across an embedding-model change.
THE PROBLEM
@@ -9,10 +9,8 @@ in `settings` survive the swap unchanged and silently stop describing anything:
the bar keeps applying, the telemetry keeps filling, and nothing anywhere says
the arm is now cutting in a different place.
Re-deriving six floors by hand is the alternative, and it is the thing the
operator asked for a way out of — *"a path for thresholds to be inherited by
the next version or different model so that they don't have to recalibrate a
lot."*
Re-deriving every floor by hand is the alternative. This module lets a tuned
threshold be inherited by the next model instead of recalibrated from scratch.
WHAT TRANSFERS, AND WHY IT IS NOT THE NUMBER
@@ -30,8 +28,8 @@ units.
WHY `best_available_score` IS THE FIGURE ON BOTH SIDES
Because it is the one number that exists whether or not a call returned
anything — the highest score the corpus offered, before the bar was applied
(#3670). It is what `retrieval_logs` recorded under the old model, and it is
anything — the highest score the corpus offered, before the bar was applied.
It is what `retrieval_logs` recorded under the old model, and it is
what a re-score reproduces under the new one, so the two sides are the same
measurement rather than two things that resemble each other.
@@ -46,7 +44,7 @@ WHAT THIS DOES NOT DO
It does not fire by itself, and `migrate_floor` will not write anything unless
asked twice — `apply=True` on top of having read the dry run. A model change is
exactly the moment when every number is uncertain at once, which is the worst
possible moment to let a statistic move six dials unattended. The percentile is
possible moment to let a statistic move every dial unattended. The percentile is
a starting point on the new scale, in the same sense the shipped defaults are a
starting point: better than a stale number, not a substitute for reading what
the surface actually turned away.
@@ -122,7 +120,7 @@ _RESCORERS = {
# The embedding table each surface's re-scorer reads. A migration from a corpus
# that is still part-way through a model change re-scores against a blend of
# two geometries and writes a floor computed from it, with a confident reason
# attached (#4132) — so the corpus has to be wholly in the live space first.
# attached — so the corpus has to be wholly in the live space first.
_CORPUS = {
"auto_inject": NoteEmbedding,
"write_path": NoteEmbedding,
@@ -178,8 +176,8 @@ async def migrate_floor(
off_model = await rows_off_the_live_model(_CORPUS[surface])
if off_model:
# Refused before sampling anything. The order of operations — re-embed,
# THEN migrate — used to be held only in the operator's memory.
# Refused before sampling anything, so the order of operations —
# re-embed, THEN migrate — is enforced rather than remembered.
stamp = calibration_stamp()
return {
"surface": surface, "migrated": False,
+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 -3
View File
@@ -1,4 +1,4 @@
"""Which rules belong on which moments — proposals and judgments (milestone 458 step 7).
"""Which rules belong on which moments — proposals and judgments.
A rule written before moments existed is mounted on nothing, and reaches a
session only when its words resemble the work. Mounting the corpus is a
@@ -19,7 +19,7 @@ Neither source mounts anything by itself. A suggestion that delivered its rule
would manufacture the opens it counts, and the operator's corpus is theirs to
mount.
THE MISFIRE (step 7b) is the same machinery pointed the other way. A session
THE MISFIRE is the same machinery pointed the other way. A session
that was handed a mounted rule at a moment where it did not apply says so
(`misfired`), with why and the action that reached the moment. Reports count
per distinct DAY — the MCP door is stateless and no session id reaches it, and
@@ -35,7 +35,7 @@ write path, `rulebooks.set_rule_moments`) confirms what was added and rejects
what was removed — a moment taken off a rule must not be proposed straight
back by the signal.
ACL (rule 78): rules are owner-scoped, and every read and write here goes
ACL: rules are owner-scoped, and every read and write here goes
through `rulebooks._owned_rules_clause` / `_fetch_owned_rule`.
"""
from __future__ import annotations
+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}
+5 -5
View File
@@ -1,4 +1,4 @@
"""A task's claim — which session is working it right now (milestone 381 step 2).
"""A task's claim — which session is working it right now.
`status` says where THE WORK stands and is durable: `in_progress` means
committed to, not finished. It cannot also say "someone is on this now",
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
# session gone overnight reads as gone. The cost either way is stated rather
# than hidden: readers show the age beside `live`, never the boolean alone.
#
# NOT A SETTING, deliberately (#4385 weighed it). Settings are per user, and a
# NOT A SETTING, deliberately. Settings are per user, and a
# claim is read by everyone who can see the task: a per-user lease would make
# one shared task read as live to one collaborator and dead to another, and
# "is anyone on this?" only means something if every reader gets the same
@@ -131,9 +131,9 @@ async def bind_session(user_id: int, task_id: int, session_id: str) -> dict | No
return claim_state(note, now)
# --- The readers (milestone 381 step 3) ---------------------------------------
# --- The readers --------------------------------------------------------------
#
# A claim nobody reads is the state before this milestone. SessionStart is the
# A claim nobody reads is worth nothing. SessionStart is the
# reader that pays rent to the session that set it, and it branches on the
# `source` the host sends, because the same claim means different things
# depending on what just happened to the context:
@@ -290,7 +290,7 @@ async def claims_for_session_start(
async def release_session(user_id: int, session_id: str) -> int:
"""Release every claim the caller holds under `session_id` (milestone 381 step 4).
"""Release every claim the caller holds under `session_id`.
Called by the plugin's SessionEnd hook: the session's context is about to
stop existing, so the attention its claims record is ending too. A TIDY-UP,
+7 -8
View File
@@ -16,11 +16,10 @@ _UNSET = object()
async def _refresh_task_document(session, task_id: int) -> None:
"""Re-embed the task whose work log just changed (#4251).
"""Re-embed the task whose work log just changed.
A task's embedded document carries its logs, so a log written and not
indexed is #4241's half-surface wearing different clothes: the entry is
readable and unfindable, and the next session rebuilds what this one ruled
indexed is half a surface: the entry is readable and unfindable, and the next session rebuilds what this one ruled
out. Create, edit and delete all go through here — an edited log that keeps
matching its old wording is the stale-vector problem `upsert_note_embedding`
already refuses to leave behind for a body.
@@ -51,9 +50,9 @@ async def create_log(
content: str,
duration_minutes: int | None = None,
) -> TaskLog:
# Whoever may WRITE the task may log on it (rule #78) — a collaborator on a
# shared project included. This used to be a bare owner filter, which
# refused exactly the person a shared task exists for.
# Whoever may WRITE the task may log on it — a collaborator on a shared
# project included. An owner-only filter would refuse exactly the person a
# shared task exists for.
if not await can_write_note(user_id, task_id):
raise ValueError(f"Task {task_id} not found")
async with async_session() as session:
@@ -61,7 +60,7 @@ async def create_log(
task = result.scalars().first()
if task is None:
raise ValueError(f"Task {task_id} not found")
# Logging IS working the task, so it stamps the claim (milestone 381) —
# Logging IS working the task, so it stamps the claim —
# unless the work is over: a retrospective note on a closed task is not
# a session picking it up.
if task.status not in (TaskStatus.done.value, TaskStatus.cancelled.value):
@@ -101,7 +100,7 @@ async def logs_for_task(
exactly what a collaborator needs — `list_logs`' `TaskLog.user_id ==
user_id` returns them an empty list, which reads as "no work has been
done" rather than "not yours". The permission question is asked of the
note through `can_read_note` (rule #78), so this is not an unscoped
note through `can_read_note`, so this is not an unscoped
read with the check left to the caller.
2. Newest first, because the question a reader asks of a work log is
"where does this actually stand", and the answer is the last entry.
+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