Compare commits

...
3 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 d0d278dcce docs(6209): snippets, rule embeddings, notes, ledger ageing and references, routes, reporting, handshake, lesson kind, request deadlines, contract-around-change, family models
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Python tests (push) Successful in 1m59s
CI & Build / Build & push image (push) Successful in 15s
Twelve more test files drop issue, milestone, rule, note, lesson and
snippet numbers and the dated or one-project figures that recorded how
each property was found; the docstrings state the property and why it
holds. The contract-around-the-change guard keeps the dict-key example as
the failure a signature-watcher misses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:34:24 -04:00
bvandeusenandClaude Opus 5.5 ead4a7e2ad docs(6209): preference door, systems and notes tools, lesson surfacing, rule versions, backup round trip, JSON reader, charter search, provenance, location filter
Ten more test files: issue, milestone, rule, decision and snippet numbers
come out of docstrings, comments and messages; the preference door states
what a preference is instead of quoting the framing; the JSON reader says
why jq is not assumed instead of what every hook used to do; the backup
round trip's column warning no longer claims the import side is unguarded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:33:08 -04:00
bvandeusenandClaude Opus 5.5 fa41e006a7 docs(6209): lesson links, design roles, version readout and endpoint, timestamp filters, settings defaults, opened ledger, surfaces
Eight more test files state their properties without issue, milestone and
rule numbers or dated regressions. The version tests describe what folding
the channel into the version breaks instead of the date it did; the surface
registry and settings-defaults tests state that floors move rather than
quoting the decision that made them move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:32:13 -04:00
30 changed files with 240 additions and 273 deletions
+12 -15
View File
@@ -1,16 +1,14 @@
"""You altered the shape of something — here is everything that reads it (#4215).
"""You altered the shape of something — here is everything that reads it.
WHY THIS EXISTS
Milestone 419's most repeated miss, five of seven: acting on the thing in hand
without reading the contract around it. Lesson #4207 says it in words —
"widening a tuple is an interface change to every unpack site, and the
compiler will not tell you" — and was written by its author HOURS before a
structurally identical mistake, having been surfaced twice in the turns
between. Text delivered at the moment of acting is too weak a carrier for a
reflex that has to change what the act IS. This looks it up instead.
The commonest miss is acting on the thing in hand without reading the contract
around it: "widening a tuple is an interface change to every unpack site, and
the compiler will not tell you". Knowing that in words does not stop it — text
delivered at the moment of acting is too weak a carrier for a reflex that has
to change what the act IS. This looks it up instead.
Rule 33 one scope down: its checks are between layers ("every parameter the
Layer parity one scope down: its checks are between layers ("every parameter the
caller sends is read by the handler under the same name"), and the same
question exists between a definition and its callers.
@@ -21,18 +19,17 @@ nothing alike in source:
arg its parameter names arity and order
key quoted keys of dict literals the shape of what it RETURNS
The third is here because of the miss that produced this step, and it is the
one a signature-watcher would have missed: `get_writepath_config` gained one
dict key, three arms read that dict inside a fail-open `except`, every one
silently became a no-op, and ten tests went red at once with nothing pointing
at the cause. No signature changed.
The third is the one a signature-watcher misses: a function gains one dict
key, three callers read that dict inside a fail-open `except`, every one
silently becomes a no-op, and tests go red at once with nothing pointing at
the cause. No signature changed.
WHAT IS PINNED: which edits speak and which stay silent, and that both gates
are load-bearing. NOT pinned: the wording, which is prose.
A FIXTURE REPO, NEVER THIS ONE. Asserting against Scribe's own files would
make the test a description of today's tree, failing the next time someone
renames something (rule 115's reasoning, one floor down).
renames something.
"""
import subprocess
from pathlib import Path
+10 -11
View File
@@ -1,13 +1,13 @@
"""The starter role set (#2349).
"""The starter role set.
The premise: a literal gets written when there is no role to reach for. This
codebase demonstrated it — the house style had no "text on a filled colour"
role, so 76 call sites wrote `color: #fff` (#2275). Naming the roles at
creation removes the occasion.
The premise: a literal gets written when there is no role to reach for. A
house style with no "text on a filled colour" role collects `color: #fff` at
every call site that needs one. Naming the roles at creation removes the
occasion.
The tests that matter here are about the BOUNDARY, not the content. Roles ship
with the product; values never do. A default palette would be one operator's
taste shipped as product code (rule #115), and it would be very easy to add by
taste shipped as product code, and it would be very easy to add by
accident while "being helpful".
"""
import re
@@ -28,9 +28,8 @@ def test_no_role_carries_a_VALUE():
def _colour_literals(text: str) -> list[str]:
"""Hex colours in `text`, NOT counting issue references.
`#2275` is four hex-valid digits and also how this codebase cites an issue —
so the naive pattern flags its own documentation, which is the third time
that has happened here (#2353). A real colour either contains a letter
`#2275` is four hex-valid digits and also how an issue is cited — so the
naive pattern flags documentation. A real colour either contains a letter
a–f or is a full 6/8-digit value; an all-decimal 3- or 4-digit match is an
issue number.
"""
@@ -64,7 +63,7 @@ def test_the_colour_check_does_not_flag_issue_references():
def test_every_group_is_individually_selectable():
"""Operator's call: one flat list, all skippable. An install that wants
"""One flat list, all skippable. An install that wants
three tokens must be able to get three."""
only_text = roles.starter_tokens(["text"])
assert {r["group_name"] for r in only_text} == {"text"}
@@ -99,7 +98,7 @@ def test_text_on_action_is_in_the_starter_set():
"""The specific role whose absence produced 76 literals. It is separate
from text-primary on purpose: the surfaces it sits on do not change with
the mode, while the page does — so reusing text-primary there passes in
dark and fails contrast in light (#2275)."""
dark and fails contrast in light."""
names = {r["name"] for r in roles.starter_tokens(["text"])}
assert "--ds-text-on-action" in names
assert "--ds-text-primary" in names
+5 -6
View File
@@ -1,11 +1,10 @@
"""Family canon's schema — the parts that need no database (milestone 463 step 1).
"""Family canon's schema — the parts that need no database.
The constraints themselves, against real Postgres, are in
tests/test_integration_family_canon.py. These pin that the migration and the
model agree on every whitelist (rule 36: a value one side accepts and the
model agree on every whitelist (a value one side accepts and the
other refuses only fails at INSERT time, which CI's unit lane never reaches),
and that the seed ships nothing an install other than this one would find
foreign (rule 115).
and that the seed ships nothing a stranger's install would find foreign.
"""
from __future__ import annotations
@@ -62,14 +61,14 @@ def test_the_column_defaults_match_the_first_state_of_each_lifecycle():
def test_a_canon_ideas_scope_lives_on_the_idea_and_nowhere_else():
"""One scope source. A rule topic is linked FROM the idea and carries no
platforms of its own, so the two cannot disagree about who a standard
reaches (#4221's two-sources-of-truth shape)."""
reaches — two sources of truth."""
from scribe.models.rulebook import RulebookTopic
assert "topic_id" in family.FamilyIdea.__table__.c
assert not {c.name for c in RulebookTopic.__table__.c} & {"platform_id", "platform_ids"}
# --- a decision read as precedent (#5131) ------------------------------------------
# --- a decision read as precedent ------------------------------------------------
def _assessment():
reason = "the APK is built in CI and signed with the one release key"
+6 -8
View File
@@ -1,4 +1,4 @@
"""Every request the web UI makes has a deadline (rule 156).
"""Every request the web UI makes has a deadline.
A source-inspection guard in the unit lane — there is no frontend test runner,
and this is a property of the source rather than of a rendered result, so
@@ -7,7 +7,7 @@ reading the source is the honest way to check it.
WHY. `fetch`'s default is to wait as long as the browser will. That is not a
long timeout, it is the absence of one, and there is no state a surface can
render for "pending forever" that is not a lie — the spinner that never
resolves is indistinguishable from work still in progress. Rule 156 names
resolves is indistinguishable from work still in progress. The practice names
`fetch` specifically:
When a library's default is "wait indefinitely" — `fetch`, most HTTP
@@ -67,17 +67,15 @@ def test_every_fetch_passes_a_signal():
if "signal:" not in call
]
assert not naked, (
"these fetch calls carry no AbortSignal, so they wait forever "
"(rule 156):\n " + "\n ".join(naked)
"these fetch calls carry no AbortSignal, so they wait forever:\n " + "\n ".join(naked)
)
def test_the_client_applies_its_deadline_by_default():
"""The specific regression that would silently undo this.
An earlier pass (#3329) made `timeoutMs` OPT-IN and used it at exactly one
call site, which left ~330 others waiting forever while the mechanism
looked present. Reverting to that shape would not fail the guard above —
An OPT-IN `timeoutMs` used at one call site leaves every other call
waiting forever while the mechanism looks present. That shape would not fail the guard above —
every call would still reach `fetch` through `request()` — so the default
is pinned here separately.
@@ -93,7 +91,7 @@ def test_the_client_applies_its_deadline_by_default():
def test_a_timeout_arrives_as_the_error_shape_callers_already_handle():
"""Rule 156's second half: expiry surfaces as a NAMED failure.
"""The deadline's second half: expiry surfaces as a NAMED failure.
A raw `DOMException: TimeoutError` reaches `apiErrorMessage(e, fallback)`
as an object with no `body`, so every catch site in the app would print its
+11 -13
View File
@@ -1,12 +1,10 @@
"""JSON, read and written by the hooks without jq (#4107).
"""JSON, read and written by the hooks without jq.
WHAT THIS REPLACED, AND WHY IT NEEDS TESTING AT ALL. Every hook used to open
`command -v jq >/dev/null 2>&1 || exit 0`, so on a machine without jq the
operator got no session context, no rules, no prior art and no process sync,
and not one word saying why — `exit 0` is indistinguishable from "ran fine,
nothing to say". jq is absent by default on macOS, on the Debian/Ubuntu slim
images, on Alpine and in most CI containers. The parser that replaced it is
`plugin/hooks/scribe_json.awk`, written in POSIX awk, and a parser is exactly
WHY NOT jq, AND WHY THIS NEEDS TESTING AT ALL. jq is absent by default on
macOS, on the Debian/Ubuntu slim images, on Alpine and in most CI containers,
and a hook that exits quietly without it gives the operator no session context,
no rules, no prior art and no process sync, with not one word saying why. The
parser is `plugin/hooks/scribe_json.awk`, written in POSIX awk, and a parser is exactly
the kind of thing that works on the six documents you tried it on.
So the centre of this file is a DIFFERENTIAL against Python's `json`: the same
@@ -16,7 +14,7 @@ is one the hooks can be handed an arbitrary event with.
The rest pins the three jobs around it — writing the hook envelope,
percent-encoding a URL, and bounding a turn in a transcript — and that each
guard here can fail (rule 167).
guard here can fail.
"""
from __future__ import annotations
@@ -74,7 +72,7 @@ def reference_flatten(value, path="", out=None):
shell reads it back with. Every array also reports its LENGTH at `[#]`,
including an empty one, and an empty object reports `{#}` 0 — that is what
lets a caller tell "the server answered with zero notes" from "the server
did not answer", which #2932 built an outage marker around.
did not answer", which the outage marker is built around.
"""
out = [] if out is None else out
if isinstance(value, dict):
@@ -221,7 +219,7 @@ def test_an_absent_field_and_a_null_field_both_read_as_empty():
def test_an_absent_array_length_is_empty_not_zero():
"""NOT the same answer as 0, and the distinction is load-bearing: the
record nudge fires when the server said "zero notes" and must not fire
when the server said nothing at all (#2932)."""
when the server said nothing at all."""
out = sh(r"""flat=$(scribe_json_flat)
printf '[%s][%s]\n' "$(scribe_json_len "$flat" '.note_ids')" \
"$(scribe_json_len "$flat" '.missing')" """,
@@ -272,7 +270,7 @@ def test_urlenc_percent_encodes_exactly_the_unreserved_set(value):
def test_urlenc_and_the_envelope_can_fail():
"""Rule 167 — a guard that cannot fail is decoration. If the escaper were a
"""A guard that cannot fail is decoration. If the escaper were a
no-op, this pair would be the assertion that noticed."""
assert sh("scribe_urlenc", stdin="a b") != "a b"
raw = sh('scribe_json_out PreToolUse "$(cat)"', stdin='he said "hi"')
@@ -376,7 +374,7 @@ def test_a_tool_result_does_not_read_as_a_prompt():
def test_the_turn_guards_can_fail():
"""Rule 167. If the analyzer counted everything, or nothing, these are the
"""If the analyzer counted everything, or nothing, these are the
assertions that would notice."""
assert _turn([_prompt(), _close("t1", 41), _text("r")])["closed"] == "1"
assert _turn([_prompt(), _text("r")])["closed"] == "0"
@@ -1,9 +1,9 @@
"""Real-Postgres round trip for rule_usage_events (milestone 333 step 1).
"""Real-Postgres round trip for rule_usage_events.
**This file is the reason the table exists.** `rule_usage_events` could have
been a `rule_id` column on `note_usage_events` — the row carries no
note-specific field and the readout is the same shape, which is the strongest
case for sharing that note #3163 admits. What decided against it is identity at
case for sharing. What decides against it is identity at
restore, and that is a claim only a real round trip can support.
The failure it guards is the quiet kind. `note_usage_events`'s importer maps
@@ -17,7 +17,7 @@ So the assertions below are about WHICH MAP resolved the id, and they are
written to fail if the answer ever becomes "the note one" or "neither".
Same shape as `test_integration_backup_rule_version_roundtrip.py`, which guards
`rule_versions` against #3182's `arose_from_id` trap on the same seam.
`rule_versions` against the same id-remapping trap on the same seam.
"""
import pytest
import pytest_asyncio
@@ -143,7 +143,7 @@ async def source():
user_id=None, rule_id=rule.id,
event=SURFACED, source="write_path_rule",
),
# A departure and its reason (#4212). Seeded here because
# A departure and its reason. Seeded here because
# `detail` is the ONE field on this table that cannot be
# recomputed: a fresh install re-earns its counts by being used,
# but a stated reason exists once and is gone if a restore drops
@@ -290,7 +290,7 @@ async def test_the_actor_is_remapped_and_a_missing_one_survives(restored):
attributed = [e for e in restored["events"] if e.user_id is not None]
orphaned = [e for e in restored["events"] if e.user_id is None]
# Four attributed: the surfacing, the pull, and the two outcome rows
# added with `detail` (#4212). One orphaned, deliberately.
# that carry `detail`. One orphaned, deliberately.
assert len(attributed) == 4
assert len(orphaned) == 1, (
"the event with no actor did not come back. Telemetry outlives the "
@@ -325,10 +325,9 @@ async def test_a_departures_reason_survives_the_round_trip(restored):
419 was opened to create, undone silently at the one moment nobody is
watching.
#4197 is the standing warning behind this test: the backup column guard
watches the export side only, so a column added to the model and to the
exporter and NOT to the importer round-trips as null with nothing to say
so.
A column added to the model and to the exporter and NOT to the importer
round-trips as null with nothing to say so; this round trip is where that
shows up in the data.
"""
departures = [e for e in restored["events"] if e.event == DEPARTED]
assert len(departures) == 1
+9 -9
View File
@@ -1,17 +1,17 @@
"""A lesson is a row the database actually accepts (milestone 385 step 2).
"""A lesson is a row the database actually accepts.
WHY THIS IS THE REAL GUARD, AND WHY IT IS HERE
The step asked for "a guard that the CHECK actually accepts `lesson` and
rejects a typo", citing #3128 — a `spike` kind that could not be written on an
instance predating its migration. That failure mode belongs to `task_kind`,
The obvious ask is a guard that a CHECK accepts `lesson` and rejects a typo —
the failure where a new kind cannot be written on an instance predating its
migration. That failure mode belongs to `task_kind`,
which IS gated (`notes_task_kind_check`, migrations 0056 and 0065).
`note_type` is not gated at all: migration 0036 added it as plain Text with a
server default and nothing has constrained it since.
So there is no whitelist to expand and no rejection to assert. Writing the
guard as "the constraint admits lesson" would have pinned a constraint that
does not exist; writing it as "no constraint exists" would pin today's schema
guard as "the constraint admits lesson" would pin a constraint that does not
exist; writing it as "no constraint exists" would pin today's schema
rather than the behaviour that matters, and would go red on a change that is
perfectly fine.
@@ -19,7 +19,7 @@ Asserting the WRITE covers both worlds. It passes today, it keeps passing if a
CHECK is added that admits `lesson`, and it goes red the day one is added that
does not — which is the only outcome anyone needs to be told about.
The second test is the cell #3163 asks to be left empty on purpose: a lesson
The second test is the cell left empty on purpose: a lesson
must not be a task. `is_task` is a read-only property over `status`, so this
cannot be asserted by setting a flag — it has to be observed on a real row.
"""
@@ -83,13 +83,13 @@ async def test_a_lesson_is_a_row_the_database_accepts(owner_id):
# and the readable body — because the vector is built from the text and
# the queries are built from the mirror.
assert lessons_svc.lesson_trigger(stored) == TRIGGER
# The subject alone (milestone 427): the trigger is in `data` and the body.
# The subject alone: the trigger is in `data` and the body.
assert stored.title == SUBJECT
assert "**When to apply:**" in (stored.body or "")
async def test_a_lesson_is_not_a_task(owner_id):
"""#3163's cell left empty on purpose.
"""The cell left empty on purpose.
`is_task` is `status is not None` and is read-only, so this is only
observable on a stored row. A lesson that arrived with a status would join
+7 -7
View File
@@ -1,4 +1,4 @@
"""Real-Postgres tests for lesson → rule links (milestone 440 step 1, #4630).
"""Real-Postgres tests for lesson → rule links.
What a mock cannot show: that the CHECK holds the three states, that deleting
either end takes the link with it, that a rule the caller does not own cannot
@@ -162,7 +162,7 @@ async def test_one_row_per_pair(world):
await s.commit()
# ── #4631: "no rule fits" and the unjudged list ─────────────────────────────
# ── "no rule fits" and the unjudged list ────────────────────────────────────
async def _judgment(lesson_id: int) -> dict:
@@ -243,7 +243,7 @@ async def test_deleting_the_lesson_takes_its_answer_with_it(world):
assert left == []
# ── #4637: soft links from co-surfacing ─────────────────────────────────────
# ── soft links from co-surfacing ────────────────────────────────────────────
async def _link(lesson_id: int, rule_id: int):
@@ -316,12 +316,12 @@ async def test_confirming_a_suggestion_keeps_what_it_rested_on(world):
assert len(out["evidence"]["situations"]) == 3
# ── #4633: which links can carry a rule ─────────────────────────────────────
# ── which links can carry a rule ────────────────────────────────────────────
async def test_only_a_confirmed_link_can_carry_its_rule(world):
"""A suggested link never expands: it would manufacture the co-arrival
that #4637 counts, and prove itself. A rejected one obviously not."""
that co-surfacing counts, and prove itself. A rejected one obviously not."""
owner, lesson = world["owner"], world["lesson"]
await links_svc.co_surfaced(owner, [lesson], [world["r1"]], arm="p", situation="one ask here")
await links_svc.judge_link(owner, lesson, world["r2"], "reject", "not this")
@@ -335,7 +335,7 @@ async def test_only_a_confirmed_link_can_carry_its_rule(world):
async def test_a_project_rule_stays_in_its_project_even_through_a_lesson(world):
"""Milestone 414's scope, kept on the side door: the lesson surfaces
"""Rule scope, kept on the side door: the lesson surfaces
everywhere, its project rule only in its project."""
owner, lesson = world["owner"], world["lesson"]
await links_svc.set_lesson_rules(owner, lesson, [world["r1"]])
@@ -349,7 +349,7 @@ async def test_a_stranger_reaches_no_rule_through_someone_elses_link(world):
assert await links_svc.confirmed_lessons(world["stranger"]) == set()
# ── #4634: which lessons count toward convergence ───────────────────────────
# ── which lessons count toward convergence ──────────────────────────────────
async def test_only_answered_lessons_are_no_rule_lessons(world):
+6 -6
View File
@@ -1,12 +1,12 @@
"""Real-Postgres tests for the rule-history write path (milestone 323 step 2).
"""Real-Postgres tests for the rule-history write path.
Every assertion here is about ORDERING or ABSENCE, which is why none of them
can be made with mocks.
What a version is for: it holds what the rule USED TO SAY. The edit destroys
that, and nothing can recompute it — the rescoping of rule 79 had to be
hand-copied into a task log to survive (#3237), which is not a process, it is
a person remembering.
that, and nothing can recompute it — without a version, a rescoped rule's old
wording survives only if a person copies it somewhere, which is not a
process.
Two of these pin a DELIBERATE ABSENCE. `note_versions` carries a 300-second
interval gate and a 50-version cap, both defences against note autosave. Rules
@@ -199,9 +199,9 @@ async def test_history_reads_newest_first(constraint):
]
# ── The read path is ACL-scoped (milestone 323 step 3) ─────────────────
# ── The read path is ACL-scoped ───────────────────────────────────────
#
# Rule 47: every read of user data is scoped by owner. A version carries a
# Every read of user data is scoped by access. A version carries a
# `user_id`, which makes it tempting to scope the history by it — that would
# be wrong in both directions, and these pin which way round it goes.
+13 -15
View File
@@ -1,22 +1,20 @@
"""A repeat on a note arm is referenced, not withheld (#4101).
"""A repeat on a note arm is referenced, not withheld.
WHY THIS EXISTS
#3750 settled the argument for rules: a record the session was told about an
hour ago is not a record in front of the reader now, so the second time it is
A record the session was told about an hour ago is not a record in front of the reader now, so the second time it is
the best answer the session gets the line again with a tail saying so — never
silence, which is indistinguishable from "nothing matched".
The note and snippet arms never got that fix. Worse, their ledger went into
`semantic_search_notes` as `exclude_ids`, so the repeat left the candidate set
entirely, which had three consequences:
The note and snippet arms hold the same line. A ledger passed into
`semantic_search_notes` as `exclude_ids` takes the repeat out of the candidate
set entirely, with three consequences:
- the session got silence the second time, on the arms that fire most;
- a compaction made it permanent, because the ledger outlived the context it
described (the same step fixes that one layer down);
- and `best_available` was measured against a candidate set the caller had
already edited, so the bar could be blamed for a record the caller withheld
(#3739, from the side its fix never reached).
- the session gets silence the second time, on the arms that fire most;
- a compaction makes it permanent, because the ledger outlives the context it
describes;
- and `best_available` is measured against a candidate set the caller has
already edited, so the bar can be blamed for a record the caller withheld.
WHAT THIS PINS
@@ -28,11 +26,11 @@ WHAT THIS PINS
and a dev-log borrowing it would claim authority it does not have — so
what is pinned is that the line appears and is distinguishable, not its
prose.
3. The telemetry splits the two (#3752 / #3668): the row's result set and the
3. The telemetry splits the two: the row's result set and the
surfaced table both take FRESH only, and the repeat is COUNTED in
`suppressed` instead. This is what makes a zero-result call readable —
`result_count == 0` with `suppressed_count > 0` says "everything that
matched, the session has already seen", which was unreportable here.
matched, the session has already seen".
4. The deliberate exception: the write-path SYNC class still shows once. Its
claim is about an edit in progress, not about a record's continuing
relevance, and repeating it would be nagging rather than reminding.
@@ -141,7 +139,7 @@ async def test_a_call_where_everything_was_already_seen_is_readable():
@pytest.mark.asyncio
async def test_a_repeat_is_not_recorded_as_a_fresh_surfacing():
"""#3668's identity: this table and the log row describe the same call."""
"""The two-table identity: this table and the log row describe the same call."""
surf = MagicMock()
hits = [(0.80, fake_note(id=11, title="Pool sizing decision", user_id=1)),
(0.78, fake_note(id=22, title="run_maintenance thresholds", user_id=1))]
+7 -7
View File
@@ -1,11 +1,11 @@
"""A lesson reaches the session it applies to — milestone 385, step 5.
"""A lesson reaches the session it applies to.
Three decisions are guarded here, and each has a way of going quietly wrong
that these tests are shaped to catch rather than to describe:
WHICH ARM. The two note arms, and no new one. An arm that FILTERS kinds does
not merely outrank a kind it omits — it makes it unreachable, and nothing
reports an arm that never had the candidate (#3702). The write path filters;
reports an arm that never had the candidate. The write path filters;
the prompt menu does not, but is project-scoped, which for a kind whose whole
claim is that it transfers amounts to the same silence.
@@ -115,8 +115,8 @@ async def test_the_write_path_asks_for_lessons_at_all():
async def test_a_lesson_takes_a_reserved_slot_and_the_query_is_logged():
"""The slot is a claim that a kind deserves a guaranteed line. A claim like
that has to be falsifiable from the first deploy, which means its own query
in `retrieval_logs` — #2463's finding, recorded about the slot that shipped
without one."""
in `retrieval_logs`; a slot without one cannot be judged against what it
displaced."""
rec = MagicMock()
out, calls = await _menu(
[(0.70, fake_note(id=1, title="Badge layout task", user_id=1, is_task=True))],
@@ -143,7 +143,7 @@ async def test_the_reserved_lesson_extends_and_never_evicts():
"""It sides with `preference_slot` over `reuse_slot`, and the reason is the
ledger rather than taste: an evicted hit was RETURNED by the general search
and sits in that call's log row, so displacing it makes the two tables
disagree about one call for a reason nothing in the data explains (#3668).
disagree about one call for a reason nothing in the data explains.
The voice argument points the same way — a record that binds nothing should
not be able to throw a better-scoring one off the menu."""
main = [(0.72, fake_note(id=1, title="a", user_id=1)),
@@ -190,7 +190,7 @@ async def test_a_weak_lesson_does_not_buy_the_slot():
@pytest.mark.asyncio
async def test_the_slot_surfacing_matches_its_own_retrieval_row():
"""#3668's identity, on the new arm: what a source says it retrieved and
"""The two-table identity, on this arm: what a source says it retrieved and
what it says it showed must be the same records. The reserved lesson is
fetched by `lesson_slot`'s query, so it is `lesson_slot`'s surfacing —
counting it under `auto_inject` as well would book one delivery twice and
@@ -275,7 +275,7 @@ async def test_the_write_path_names_the_register_for_a_lesson_too():
def test_the_surfacing_guards_can_fail():
"""Rule 167: each assertion above has to be able to bite. The three shapes
"""Each assertion above has to be able to bite. The three shapes
it would take to break this feature silently, each checked here against the
condition the tests actually assert on."""
# An arm that stops asking for the kind.
+6 -6
View File
@@ -15,10 +15,10 @@ pytestmark = pytest.mark.usefixtures("_bind_user")
@pytest.fixture(autouse=True)
def _no_supersession():
"""Every note read/write now asks for its supersession relations (#278).
"""Every note read/write asks for its supersession relations.
These are unit tests of the TOOL layer and this job has no database — the
same hazard the `_fake_note` comment below records for note 2109. Stubbed
same hazard the `_fake_note` comment below records. Stubbed
to "no relations", which is the state of essentially every note; the
relation's own behaviour is covered in test_services_supersession.py, and
the attachment is covered explicitly below.
@@ -120,7 +120,7 @@ async def test_get_note_returns_dict():
# Own record: no provenance noise.
assert "shared" not in out
# No supersession relations: both keys ABSENT, not present-and-empty. A
# field that always says nothing trains readers to skip fields (#2483).
# field that always says nothing trains readers to skip fields.
assert "supersedes" not in out
assert "superseded_by" not in out
@@ -218,7 +218,7 @@ async def test_update_note_only_sends_non_default_fields():
`clear` is always forwarded and is not a field: it is how this door says
"unset these", and an empty one says "unset nothing". Same shape the rules
door took in #3096, and the same test that had to learn about it there."""
door takes, and the same test."""
fake = fake_note()
mock = AsyncMock(return_value=fake)
with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock):
@@ -232,7 +232,7 @@ async def test_update_note_only_sends_non_default_fields():
@pytest.mark.asyncio
async def test_update_note_forwards_a_check_but_not_an_empty_one():
""""" means "leave this alone" at this door — an agent updating a body must
not wipe a check it was never asked about (milestone 317)."""
not wipe a check it was never asked about."""
mock = AsyncMock(return_value=fake_note())
with patch("scribe.mcp.tools.notes.notes_svc.update_note", mock):
await update_note(note_id=1, verify_with="curl the docs")
@@ -305,7 +305,7 @@ async def test_delete_note_soft_deletes_and_returns_batch():
):
result = await delete_note(note_id=7)
assert result["deleted_batch_id"] == "batch-1"
# The confirmation NAMES what went (#3273). After the delete the row is
# The confirmation NAMES what went. After the delete the row is
# trashed, so this line is the last chance to say which note it was.
assert result["title"] == "the note that went"
assert "the note that went" in result["message"]
+7 -7
View File
@@ -5,7 +5,7 @@ import pytest
from tests.helpers import fake_note, fake_system, fake_task
# The name assessment both doors run before minting (milestone 307). A test
# The name assessment both doors run before minting. A test
# that patches systems_svc wholesale must stub it, or the awaited MagicMock
# raises — this shape is the "nothing matched" answer.
_NO_MATCH = {"duplicate": None, "canonical": None}
@@ -179,8 +179,8 @@ async def test_untagged_hint_zero_systems_prompts_first_create_and_fails_open():
@pytest.mark.asyncio
async def test_untagged_hint_escalates_in_a_mature_zero_systems_project():
"""#2683: at 282 records and zero Systems (Minstrel), the generic question
had demonstrably never converted. The escalated ask must carry the
"""In a mature project with zero Systems the generic question does not
convert. The escalated ask must carry the
project's OWN evidence — record count and recent titles — and demand a
concrete deliverable, because that is the property separating the nudges
that convert from the prose that doesn't."""
@@ -190,7 +190,7 @@ async def test_untagged_hint_escalates_in_a_mature_zero_systems_project():
patch("scribe.mcp.tools.systems.notes_svc") as notes:
svc.list_systems = AsyncMock(return_value=[])
notes.list_notes = AsyncMock(return_value=(recent, 282))
# The standard names come from the GLOBAL catalog now (milestone 307),
# The standard names come from the GLOBAL catalog,
# not a module constant — so the ask reads them through the service.
# That the SEEDED vocabulary is these eight is migration 0087's
# business, asserted against a real database in the inception
@@ -203,7 +203,7 @@ async def test_untagged_hint_escalates_in_a_mature_zero_systems_project():
assert "282 records" in hint
assert "Fix scrape retry backoff" in hint # the project's own evidence
assert "3-6" in hint and "create_system" in hint
# NOT an approval flow (#2798): the agent mints directly, and the
# NOT an approval flow: the agent mints directly, and the
# standard cross-project names carry the consistency instead.
assert "without asking permission" in hint
assert "propose" not in hint and "confirmed" not in hint
@@ -262,7 +262,7 @@ async def test_populated_vocabulary_never_counts_records():
# The name gate's own cases (case/whitespace normalisation, exact-over-overlap,
# fail-open) moved to tests/test_services_systems.py with the logic itself —
# services/systems.assess_system_name, so both doors share one answer (#2482).
# services/systems.assess_system_name, so both doors share one answer.
# What stays the TOOL's job — rendering a duplicate, applying an exact area,
# offering an overlap — is covered at the top of this file.
@@ -345,7 +345,7 @@ async def test_add_task_log_on_untagged_project_task_asks_the_question():
notes_svc.get_note_for_user = AsyncMock(return_value=(task, "owner"))
seam_svc.list_record_systems = AsyncMock(return_value=[])
seam_svc.list_systems = AsyncMock(return_value=[])
# Young project: below the #2683 bootstrap threshold, so the mild
# Young project: below the bootstrap threshold, so the mild
# question is the expected form here.
seam_notes.list_notes = AsyncMock(return_value=([], 2))
from scribe.mcp.tools.tasks import add_task_log
+10 -10
View File
@@ -1,9 +1,9 @@
"""The enter_project handshake stays a small primer (#4045).
"""The enter_project handshake stays a small primer.
enter_project once carried every milestone's full plan body, the whole project
record, full rule text and ~9k of design guidance. On a project with 39
milestones that came to ~222k characters, past what an MCP client accepts as
a tool result, so the session handshake arrived as a file to page through.
Every milestone's full plan body, the whole project record, full rule text and
the design guidance add up, on a project with a few dozen milestones, to more
than an MCP client accepts as a tool result — and the session handshake
arrives as a file to page through.
"""
import contextlib
import json
@@ -66,7 +66,7 @@ def test_brief_rows_leave_out_the_plan_and_what_the_caller_already_knows():
"id": 1, "title": "M1", "description": "what M1 is for", "status": "active",
"order_index": 1, "total": 4, "completed": 2, "pct": 50.0,
"status_counts": {"todo": 2, "in_progress": 0, "done": 2, "cancelled": 0},
# Trimmed of the plan body, but NOT of which step is next (#4154): the
# Trimmed of the plan body, but NOT of which step is next: the
# listing is where "2 of 4 done" gets read, and that number is exactly
# what invites a reader to name the open step from memory.
"next_step": {"id": 901, "title": "M1 step 3", "status": "todo"},
@@ -187,7 +187,7 @@ async def test_open_tasks_name_their_milestone_even_when_it_is_not_listed():
@pytest.mark.asyncio
async def test_omitted_key_is_absent_when_nothing_was_left_out():
"""Attached only when it applies (#2483)."""
"""Attached only when it applies."""
out, _ = await _enter(*_enter_stubs(fake_project(id=5), _history(3), []))
assert len(out["milestone_summary"]) == 3
assert "milestone_summary_omitted" not in out
@@ -217,8 +217,8 @@ async def test_list_milestones_lists_every_milestone_without_plans():
def test_a_planning_read_lists_rules_without_restating_them():
"""#4081: a project's listing is every global rule tagged to its areas, so
the full rule_brief of each put start_planning at 92k characters. Planning
"""A project's listing is every global rule tagged to its areas, so the
full rule_brief of each would make a planning read enormous. Planning
reads name the rules; get_rule reads one."""
from scribe.services.rulebooks import rules_payload
@@ -241,7 +241,7 @@ def test_a_planning_read_lists_rules_without_restating_them():
assert len(json.dumps(out)) < 6_000, len(json.dumps(out))
# ── Milestones with no steps are open work (milestone 415) ─────────────────
# ── Milestones with no steps are open work ─────────────────────────────────
def _planless(mid: int, status: str = "active") -> dict:
+8 -9
View File
@@ -1,14 +1,13 @@
"""The preference write path, and how it differs from a rule's (milestone 399).
"""The preference write path, and how it differs from a rule's.
WHY TWO DOORS AT ALL
`create_rule`'s docstring IS the approval gate (#3557): it tells its caller to
`create_rule`'s docstring IS the approval gate: it tells its caller to
propose, offer three answers, and wait. That is right for a rule — the person
a rule binds should have agreed to be bound.
A preference inverts it. The operator's framing: *"preferences are rules that
scribe can and should update during use."* A preference that asks every time
never drifts, and drifting is the whole feature. Reaching one through
A preference inverts it: it is a rule Scribe can and should update during
use. A preference that asks every time never drifts, and drifting is the whole feature. Reaching one through
`create_rule(kind=..., when_to_apply="when the moment this fixture stands in for arises")` would mean reading it through the gate's prose, and
the caller would hesitate over exactly the act this kind exists to make
routine.
@@ -18,7 +17,7 @@ So the asymmetry is the product, and these tests pin it.
TWO PRESENCE CHECKS, NEVER AN ABSENCE
The tempting guard is "create_preference's docstring does NOT run the approval
loop". That is the shape snippet #3352 warns against: an absence check passes
loop". That is a trap: an absence check passes
against a docstring that has been deleted, emptied, or rewritten into
something else entirely, and it reads as coverage while proving nothing.
@@ -35,7 +34,7 @@ from tests.helpers import fake_rule, plain_rule_detail as _plain_detail
from tests.helpers import tool_doc as _doc
# The tool layer reads its caller from a ContextVar the HTTP transport sets.
# With no request in flight, the module binds it itself (snippet #2836).
# With no request in flight, the module binds it itself.
pytestmark = pytest.mark.usefixtures("_bind_user")
MODULE = "scribe.mcp.tools.rulebooks"
@@ -176,7 +175,7 @@ def test_the_preference_door_says_to_write_it():
"""Half two. The inverting instruction has to be PRESENT, not merely
unaccompanied by a gate.
An agent that has internalised #3557 will hesitate to write or rewrite a
An agent that has internalised the rule gate will hesitate to write or rewrite a
preference unless told plainly that this door is different. Silence here
does not read as permission — it reads as an omission, and the caller
falls back on the behaviour it already knows.
@@ -194,7 +193,7 @@ def test_the_preference_door_says_to_write_it():
def test_the_preference_door_names_the_force_distinction():
"""The routing test, stated positively (rule 165).
"""The routing test, stated positively.
The confusion this milestone exists to fix is that a session cannot tell
which kind it is holding. If the docstring stops drawing the line, the
+8 -9
View File
@@ -1,4 +1,4 @@
"""The reporting-back skill keeps its shape (milestone 409 step 2).
"""The reporting-back skill keeps its shape.
WHY THIS EXISTS
@@ -14,8 +14,8 @@ Structure, never wording — the same reason test_create_tools_disambiguate
gives: a test that punishes rewriting gets deleted. It pins that the
completion report keeps its five sections, that placement is taken from the
record rather than recalled, and that the shipped shapes stay domain-neutral.
Whether the guidance is any good is milestone 409's last step, read against
real replies, not something a test can see.
Whether the guidance is any good is read against real replies, not something a
test can see.
"""
import pathlib
import re
@@ -39,14 +39,13 @@ def test_the_completion_report_keeps_its_sections():
def test_a_request_for_approval_has_its_own_named_section():
"""The operator, 2026-09-15 (#4084): a session's go-ahead request sat inside
a completion list as "Blocked by the permission check", and read as a
fault rather than a question waiting on them. The heading is what they
scan for, so it is pinned by name."""
"""A go-ahead request inside a completion list, as "Blocked by the
permission check", reads as a fault rather than a question waiting on the
operator. The heading is what they scan for, so it is pinned by name."""
from scribe.services import reply_shapes
assert "Approval requested" in _text()
# The kinds moved to the delivered shapes (milestone 500): the asks shape
# The kinds live in the delivered shapes: the asks shape
# carries the Approval kind, and the core sends a held action there.
assert "**Approval**" in reply_shapes.SHAPES["asks"].text
assert "Approval requested" in reply_shapes.core().text
@@ -70,7 +69,7 @@ def test_the_shipped_shapes_assume_no_particular_domain():
def test_the_skill_points_at_the_delivered_shapes_instead_of_restating_them():
"""Milestone 500 step 4: the shapes are server product, delivered at their
"""The shapes are server product, delivered at their
moments. The skill says where they come from and holds the reasoning."""
text = _text()
assert "list_reply_shapes" in text and "Reply shape · Every reply" in text
+10 -19
View File
@@ -1,19 +1,11 @@
"""The surface registry, and the identity the whole tuning story rests on (#4102).
"""The surface registry, and the identity the whole tuning story rests on.
WHY THIS EXISTS
Six arms used to own their own numbers: a settings key, a default, and a limit
that was usually a module constant nobody could change. That survived while the
values were shipped constants. It stops surviving once the operator's decision
is that the numbers MOVE:
"the floor should be chosen and adjusted by the model using it… the user
should be able to touch it but the model should be the thing handling it 9
times out of 10."
A tuning surface cannot be model-consistent across arms that each spell their
configuration differently, so the arms now read `{floor, budget}` from one
registry.
The retrieval floors MOVE: the model using an arm chooses and adjusts them, and
a person can too. A tuning surface cannot be model-consistent across arms that
each own a settings key, a default and a limit spelled their own way, so the
arms read `{floor, budget}` from one registry.
WHAT THIS PINS
@@ -24,9 +16,8 @@ WHAT THIS PINS
different file. Renaming one without the other produces a surface that can
be tuned and cannot be measured, or measured and not tuned, and both fail
silently.
2. **Keys are unique.** Two surfaces sharing a settings key is how the
completion-report arm (since retired) spent its first release moving
whenever the prompt arm was tuned (#3860) — one dial wearing two labels.
2. **Keys are unique.** Two surfaces sharing a settings key move together
whenever either is tuned — one dial wearing two labels.
3. **An unknown surface is refused.** Settings keys are free-form strings in
a generic table, so a typo'd name would write a key nothing reads: a
change that appears to succeed, reports a new value, and alters nothing.
@@ -63,7 +54,7 @@ def test_every_surface_name_is_a_real_telemetry_source():
to justify a change describes a different arm from the one the change hits.
"""
blob = "\n".join(_service_source(n) for n in SERVICES)
# The rule arms record through the one pipeline (milestone 456), where
# The rule arms record through the one pipeline, where
# `source` is the spec's own field — so for them the spec IS the string
# `record_retrieval` receives, and the join key is checked against it.
from scribe.services.retrieval_pipeline import NOTE_ARMS, RULE_ARMS
@@ -81,7 +72,7 @@ def test_every_surface_name_is_a_real_telemetry_source():
def test_no_two_surfaces_share_a_settings_key():
"""One dial, one label. #3860 is what the other way costs."""
"""One dial, one label."""
floors = [s.floor_key for s in rs.SURFACES.values()]
budgets = [s.budget_key for s in rs.SURFACES.values()]
assert len(set(floors)) == len(floors), f"duplicate floor key in {floors}"
@@ -189,6 +180,6 @@ async def test_the_write_path_config_carries_every_arm_it_drives():
"tool_rule_threshold", "tool_rule_top_k",
# Not a surface, and that is why it is spelled out here: it
# has no floor/budget pair to derive from, so nothing else in
# this file would notice it going missing (#4214).
# this file would notice it going missing.
"checkpoint_threshold"):
assert key in cfg, f"{key} missing — its arm will silently no-op"
+6 -7
View File
@@ -41,9 +41,8 @@ def test_service_functions_take_user_id():
def test_agent_and_web_surfaces_stay_at_parity():
"""The MCP tools and the REST routes are two callers of one service; a
capability on one has to exist on the other (rule #33). This guard exists
because they drifted apart once: MCP had no delete or `locations`, and the
web side had no `system_ids` and no near-duplicate gate."""
capability on one has to exist on the other. Two doors maintained
separately drift — a delete on one, a near-duplicate gate on the other."""
from scribe.mcp.tools import snippets as tools
from scribe.routes import snippets as routes
@@ -65,13 +64,13 @@ def test_agent_and_web_surfaces_stay_at_parity():
assert "system_ids" in inspect.getsource(route)
assert "find_duplicate_note" in inspect.getsource(routes.create_snippet_route)
# The drift check (#2086) reaches both: recording a verdict, and filtering
# The drift check reaches both: recording a verdict, and filtering
# the list by one. A verdict an agent records has to be visible to the human
# looking at the same corpus, or the two surfaces disagree about what's rotten.
assert "verification" in inspect.signature(tools.list_snippets).parameters
assert "verification" in inspect.getsource(routes.list_snippets_route)
# The near-duplicate report (#2088) reaches both. The web side can only hand
# The near-duplicate report reaches both. The web side can only hand
# a group to the merge flow if it can get the groups in the first place.
assert callable(getattr(tools, "find_duplicate_snippets", None))
assert callable(getattr(routes, "duplicate_snippets_route", None))
@@ -90,7 +89,7 @@ def test_project_scoping_reaches_every_caller():
def test_location_lookup_reaches_every_caller():
"""The reverse lookup — "what already lives here?" — has to be askable from
both surfaces, or an agent and a human get different answers about the same
file (rule #33)."""
file."""
from scribe.mcp.tools import snippets as tools
from scribe.routes import snippets as routes
from scribe.services import snippets as svc
@@ -102,7 +101,7 @@ def test_location_lookup_reaches_every_caller():
def test_update_field_map_matches_service_kwargs():
"""Every field the PATCH route forwards must be a real update_snippet kwarg
(rule #33 interface-contract parity)."""
(interface-contract parity)."""
from scribe.routes import snippets as routes
from scribe.services import snippets as svc
params = inspect.signature(svc.update_snippet).parameters
+7 -8
View File
@@ -1,13 +1,12 @@
"""Entries in the rule exclusion ledger age out (#3751).
"""Entries in the rule exclusion ledger age out.
WHY THIS EXISTS
#3749 clears the ledger when an EVENT destroys context — a compaction, a
/clear. This covers the case with no event at all: a long session where a rule
was named two hundred turns ago and has simply fallen out of attention. Same
argument #3702 made about tiers (present in context and salient at the
moment are different properties), applied to time instead. The tier itself is
gone since milestone 394; the distinction it taught is what survives.
The ledger is cleared when an EVENT destroys context — a compaction, a /clear.
This covers the case with no event at all: a long session where a rule was
named two hundred turns ago and has simply fallen out of attention. Present in
context and salient at the moment are different properties, and time
separates them as surely as a compaction does.
WHAT IS PINNED, AND WHAT DELIBERATELY IS NOT
@@ -76,7 +75,7 @@ def _write_ledger(ledger: Path, entries: list[tuple[str, int | None]]) -> None:
def test_an_old_entry_ages_out_and_a_recent_one_does_not(tmp_path):
"""BOTH DIRECTIONS IN ONE ASSERTION (rule 167), because either half alone
"""BOTH DIRECTIONS IN ONE ASSERTION, because either half alone
passes against a broken implementation.
A test that only checks the old id is gone passes against a helper that
+10 -11
View File
@@ -1,17 +1,16 @@
"""Named is not read: the ledger's third state (#4100).
"""Named is not read: the ledger's third state.
WHY THIS EXISTS
Milestone 386 made a repeat REFERENCED rather than withheld, and the line it
chose says "You saw it earlier this session". That claim was never checked.
The arms emit a TEASER — title, trigger, `get_rule(N)` — so a session can be
A repeat is REFERENCED rather than withheld, and a line that says "You saw it
earlier this session" claims something nobody checked. The arms emit a TEASER — title, trigger, `get_rule(N)` — so a session can be
shown a rule twenty times and never read a word of it, and a compaction
summarises the teaser away leaving nothing at all. The server was asserting
something about the reader's context it had no way to know.
summarises the teaser away leaving nothing at all. The server would be
asserting something about the reader's context it has no way to know.
`.opened.ids` is the observable half, written by a PostToolUse hook from the
`get_rule` call itself. That is why this is not the self-report 386 rejected:
the objection was to ASKING a model about its own context, and a tool call is
`get_rule` call itself. That is why this is not a self-report: the objection
is to ASKING a model about its own context, and a tool call is
an event the harness reports whether anyone asks or not.
WHAT THIS PINS
@@ -82,8 +81,8 @@ def test_the_three_states_produce_three_different_lines():
def test_held_outranks_seen_regardless_of_kind():
"""`kind` moves the head and the ledger moves the tail; #3497's history is
the two being reasoned about together and one of them being forgotten."""
"""`kind` moves the head and the ledger moves the tail; reasoning about
the two together is how one of them gets forgotten."""
for kind in ("rule", "preference"):
line = _rule_hint_line(_rule(kind=kind), where="here", seen=True, held=True)
assert "opened it earlier" in line
@@ -139,7 +138,7 @@ def test_an_event_with_nothing_usable_records_nothing_and_still_exits_zero(event
# that would be worst to get wrong — is asserted against the running hook in
# tests/test_session_ledger_clear.py, so there is one home for it.
#
# Since #4217 the recorder writes TWO files and only this one is cleared.
# The recorder writes TWO files and only this one is cleared.
# `.opened.ids` says "this context holds rule 156", which a compaction makes
# false, and three hooks read it to decide whether to stay quiet. Its twin
# `.opened.keep.ids` says "rule 156 was opened", which a compaction does not
+10 -11
View File
@@ -1,18 +1,17 @@
"""services/notes.py — the inline embedding moved here from the routes (#2056).
"""services/notes.py — inline embedding lives in the service.
Why it moved: embedding was triggered at each REST route and nowhere else, so a
record created through MCP stayed out of semantic search and auto-inject until
the next restart's backfill. Putting it in the service means every caller — REST,
MCP, recurrence, snippets — gets it by construction rather than by remembering.
Embedding triggered at each REST route and nowhere else would leave a record
created through MCP out of semantic search and auto-inject until the next
restart's backfill. In the service, every caller — REST, MCP, recurrence,
snippets — gets it by construction rather than by remembering.
These test the helper directly. The point of the change is that there is now ONE
place to test.
These test the helper directly: there is ONE place to test.
"""
import pytest
from scribe.services import notes as notes_svc
# --- inline embedding (#2056) -----------------------------------------------
# --- inline embedding -------------------------------------------------------
def test_embed_note_uses_the_OWNER_not_the_caller():
"""LOAD-BEARING for shared records. An embedding belongs to the record; a
@@ -31,14 +30,14 @@ def test_embed_note_uses_the_OWNER_not_the_caller():
upsert.assert_called_once()
assert upsert.call_args.args[0] == 5
assert upsert.call_args.args[1] == 42 # owner, never the caller
# Title and body travel separately since #280 — chunking happens inside
# Title and body travel separately — chunking happens inside
# upsert_note_embedding, the one path every writer shares.
assert upsert.call_args.args[2] == "T"
assert upsert.call_args.args[3] == "B"
def test_embed_note_hands_even_an_empty_record_to_the_one_path():
"""The empty-record decision moved INTO upsert_note_embedding (#280): an
"""The empty-record decision lives IN upsert_note_embedding: an
emptied record must have its stale vectors CLEARED, not merely skipped —
so embed_note schedules the call unconditionally rather than deciding
here. The clearing behaviour itself is pinned in test_chunking.py."""
@@ -73,7 +72,7 @@ def test_embed_note_swallows_an_indexing_failure():
notes_svc.embed_note(note) # must not raise
# --- #3683: a create that names a status is the transition an update is ------
# --- a create that names a status is the transition an update is -------------
_LIFECYCLE = ("started_at", "completed_at", "recurrence_next_spawn_at")
+9 -9
View File
@@ -1,9 +1,9 @@
"""The document a rule is EMBEDDED as (milestone 307 step 4, note 3026).
"""The document a rule is EMBEDDED as.
This shape is measured, not chosen. Note 2485 probed the live corpus and found
the snippet was the only discriminative record in it — a 0.153 top-to-second
gap against 0.010-0.023 for everything else — and that the cause was its shape:
purpose stated twice in a short, single-topic document. These cases pin that
This shape is measured, not chosen. Probed across a corpus, the snippet is the
most discriminative record kind — a 0.153 top-to-second gap against
0.010-0.023 for everything else — and the cause is its shape: purpose stated
twice in a short, single-topic document. These cases pin that
recipe onto rules, and pin the exclusion that matters more than any of it.
"""
import pytest
@@ -27,8 +27,8 @@ def test_the_trigger_appears_twice_which_is_what_makes_a_vector_sharp():
def test_why_is_never_embedded():
"""The exclusion this whole design turns on. `why` is dated incident
narrative — rule 46's runs to 4,300 characters — and long multi-topic prose
is what made sixteen dev-logs mutually indistinguishable: the average lands
narrative — it can run to thousands of characters — and long multi-topic
prose is what makes dev-logs mutually indistinguishable: the average lands
on a centroid they all share. Adding it would not give the vector more to
work with; it would give every rule the SAME thing to work with.
@@ -41,7 +41,7 @@ def test_why_is_never_embedded():
def test_a_rule_with_no_trigger_still_embeds_just_less_sharply():
"""Every rule written before milestone 307 has no trigger. Degrading to
"""A rule can have no trigger. Degrading to
title + statement keeps them findable; it does NOT pad the document with
whatever text is lying around, which would be the tempting fix and the
wrong one."""
@@ -60,7 +60,7 @@ def test_an_empty_rule_yields_no_document_and_therefore_no_vector():
def test_a_long_rule_chunks_and_every_chunk_carries_the_trigger():
"""Rule 46's statement is ~3,600 characters across two headed sections.
"""A ~3,600-character statement across two headed sections.
The existing chunker splits it at headings and prefixes each chunk with the
title — which now CONTAINS the trigger, so each half stays anchored to what
the rule is for. This is why splitting a merged rule costs nothing at
+5 -5
View File
@@ -3,7 +3,7 @@ from scribe.services import snippets as s
def test_the_embedded_title_joins_the_trigger_the_stored_one_does_not_carry():
"""Milestone 427: stored title = name; the trigger joins it at embed time,
"""Stored title = name; the trigger joins it at embed time,
idempotently, so an old composed title comes out the same."""
from scribe.services.embeddings import document_title
@@ -167,7 +167,7 @@ def test_merge_snippet_fields_unions_locations_and_tags():
# extra tags unioned, language + "snippet" markers excluded.
assert extra == ["core", "helper"]
# Attribution (#2165): only what each source ACTUALLY added. src2's location
# Attribution: only what each source ACTUALLY added. src2's location
# is one the target already had, so it contributed nothing — un-merging it
# must not strip a call site the survivor owns in its own right.
assert contributions[0]["locations"] == [{"repo": "b", "path": "b.py", "symbol": "g"}]
@@ -249,7 +249,7 @@ def test_data_and_body_round_trip_to_the_same_fields():
name, when, sig, lang = ("debounce", "rate-limit a callback",
"debounce(fn, ms)", "ts")
locs = [{"repo": "web", "path": "src/util.ts", "symbol": "debounce"}]
# compose_body takes no `name` — the name IS the title (milestone 427) — so
# compose_body takes no `name` — the name IS the title — so
# the two serializers get their own argument lists rather than a shared spread.
title = name
body = s.compose_body(code="const x = 1", language=lang, signature=sig,
@@ -267,7 +267,7 @@ def test_data_and_body_round_trip_to_the_same_fields():
assert from_body[key] == from_data[key], key
# --- merge provenance (#2087) ------------------------------------------------
# --- merge provenance -------------------------------------------------------
def test_normalize_merged_from_keeps_history_order_and_drops_junk():
"""Order is history, not sorting — earlier merges stay first."""
@@ -276,7 +276,7 @@ def test_normalize_merged_from_keeps_history_order_and_drops_junk():
def test_normalize_merged_from_carries_per_source_attribution():
"""The shape that makes un-merge exact (#2165): each entry holds what THAT
"""The shape that makes un-merge exact: each entry holds what THAT
source contributed, so reversing one can't strip what the survivor owns."""
loc = {"repo": "a", "path": "a.py", "symbol": "f"}
out = s._normalize_merged_from([{"id": 7, "locations": [loc], "tags": ["x"]}])
+6 -7
View File
@@ -1,4 +1,4 @@
"""The Settings UI shows the default the server actually uses (#3927).
"""The Settings UI shows the default the server actually uses.
WHY THIS EXISTS
@@ -17,8 +17,7 @@ WHAT THIS PINS
The RELATIONSHIP, never the number: for each threshold, the Vue ref's initial
string equals the Python default. Retuning either stays free as long as both
move — which is the point, because these are tuning values and #3853 moved one
of them the day this was written.
move — which is the point, because these are tuning values and they move.
The Vue side is read from SOURCE, because that file cannot be imported at all.
The Python side is read from source for a loose constant and IMPORTED for a
@@ -42,7 +41,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
_SERVICES = ROOT / "src" / "scribe" / "services"
_VUE = ROOT / "frontend" / "src" / "views" / "SettingsView.vue"
# THE SIX RETRIEVAL FLOORS now live in one registry (#4102), so their side of
# THE SIX RETRIEVAL FLOORS live in one registry, so their side of
# the pairing is a SURFACE NAME rather than a module constant. That is strictly
# better for this guard: the surface name is also the telemetry source, so a row
# here names the same arm the readout does, and a floor that moved cannot be
@@ -62,7 +61,7 @@ _SURFACE_PAIRS = (
# exist on both sides, so a rename fails loudly rather than silently dropping
# that threshold from the check.
_CONSTANT_PAIRS = (
# The plan gate (milestone 415): it blocks a create, so a form showing a
# The plan gate: it blocks a create, so a form showing a
# looser bar than the one in force would be the more misleading drift. Not
# a push surface, so it has no registry entry and keeps the older shape.
("dedup.py", "PLAN_MATCH_DEFAULT_THRESHOLD", "kbPlanMatchThreshold"),
@@ -87,7 +86,7 @@ def _vue_default(ref_name: str) -> float:
assert m, (
f"{ref_name} is no longer a `ref(\"<number>\")` in SettingsView.vue. "
f"If the control was renamed, update _PAIRS; if it was removed, the "
f"setting has lost its UI and that is the thing to fix (rule 25)."
f"setting has lost its UI and that is the thing to fix."
)
return float(m.group(1))
@@ -112,7 +111,7 @@ def test_the_form_shows_the_floor_the_surface_uses(surface, ref_name):
def test_every_tunable_surface_has_a_control():
"""Rule 25/27: a number an operator may need different has a UI or it is
"""A number an operator may need different has a UI or it is
not shipped. Derived from the registry, so a seventh surface arrives here
as a failure rather than as a setting only the model can reach."""
from scribe.services.retrieval_surfaces import surface_names
+5 -5
View File
@@ -1,8 +1,8 @@
"""Tests for the snippet location reverse lookup (#2083).
"""Tests for the snippet location reverse lookup.
Covers the pure half of the predicate — `location_parts`, `location_matches`,
`location_jsonpath` — plus the parameter plumbing across the three surfaces
(service / REST route / MCP tool), which rule #33 requires to agree on names and
(service / REST route / MCP tool), which have to agree on names and
defaults.
The SQL half can only be proved against real Postgres — see
@@ -56,7 +56,7 @@ def test_no_parts_matches_everything():
def test_matches_exact_repo_path_and_symbol():
data = _data({"repo": "Scribe", "path": "src/scribe/x.py", "symbol": "helper"})
assert location_matches(data, {"repo": "Scribe"})
assert location_matches(data, {"repo": "scribe"}) # repo names: case never distinguishes (#2874)
assert location_matches(data, {"repo": "scribe"}) # repo names: case never distinguishes
assert location_matches(data, {"path": "src/scribe/x.py"})
assert location_matches(data, {"symbol": "helper"})
assert location_matches(data, {"repo": "Scribe", "symbol": "helper"})
@@ -102,7 +102,7 @@ def test_blank_recorded_part_does_not_match_a_requested_one():
def test_jsonpath_filters_within_one_locations_entry():
expr = location_jsonpath({"repo": "Scribe", "symbol": "helper"})
assert expr.startswith("$.locations[*] ? (")
# repo: anchored, case-insensitive (#2874) — mirrors location_matches.
# repo: anchored, case-insensitive — mirrors location_matches.
assert '@.repo like_regex "^Scribe$" flag "i"' in expr
assert '@.symbol == "helper"' in expr
assert " && " in expr
@@ -149,7 +149,7 @@ def test_location_clause_renders_a_jsonpath_containment():
assert "data" in sql
# --- surface plumbing (rule #33) ---------------------------------------------
# --- surface plumbing ---------------------------------------------------------
def test_agent_and_service_surfaces_take_the_same_location_params():
from scribe.mcp.tools.snippets import list_snippets as mcp_list
+7 -7
View File
@@ -1,10 +1,10 @@
"""Body provenance — the cache-with-provenance half of the pointer model (#2688).
"""Body provenance — the cache-with-provenance half of the pointer model.
Decision #2686: the recorded location is the source of truth for a snippet's
The recorded location is the source of truth for a snippet's
code and the stored body is a CACHE of it. `data.provenance` records what that
cache is a cache OF — the commit the body was read at, and when — so staleness
becomes judgeable instead of guessed, and so the forge fetch (milestone 288
step 5) has something to refresh.
becomes judgeable instead of guessed, and so the forge fetch has something to
refresh.
The rules under test, because each has a way to rot silently:
@@ -18,8 +18,8 @@ The rules under test, because each has a way to rot silently:
cached body matches the source there.
Unit tests cover the compose/carry logic; the integration section runs the
same rules through the real service paths on real Postgres (the #2663 lesson —
a DB-touching path with only mocked coverage is a path with no coverage).
same rules through the real service paths on real Postgres (a DB-touching path
with only mocked coverage is a path with no coverage).
"""
import pytest
import pytest_asyncio
@@ -49,7 +49,7 @@ def test_compose_provenance_stamps_sha_and_time():
def test_compose_provenance_without_a_sha_is_none_not_an_empty_record():
# Absent provenance must stay ABSENT (the pre-#2688 semantics), never an
# Absent provenance must stay ABSENT, never an
# empty dict that readers would have to distinguish from a real one.
assert compose_provenance(commit_sha="") is None
assert compose_provenance(commit_sha=" ") is None
+4 -5
View File
@@ -1,4 +1,4 @@
"""Systems become findable by meaning — the charter as an ANSWER (#4251).
"""Systems become findable by meaning — the charter as an ANSWER.
A System's `description` is a charter: several hundred words saying what
belongs in that area and what does not. It is the answer to "where does this
@@ -20,7 +20,7 @@ from tests.helpers import make_mock_session
def _system(system_id=3, name="Retrieval & recall", description="the charter"):
s = MagicMock(id=system_id, project_id=2, description=description)
s.name = name # never via the constructor — see note #2833
s.name = name # never via the constructor: `name` names the mock
return s
@@ -65,8 +65,7 @@ def _rows_ctx(rows):
async def test_the_search_collapses_to_best_chunk_and_reports_which_one_won():
"""A charter is long, and a result shows its NAME — so the paragraph that
actually decides where a record belongs has to come back with it, or the
caller judges from two words that cannot say (#4243). Published from the
start here rather than retrofitted, which is what #4251 asked for."""
caller judges from two words that cannot say."""
a, b = _system(3), _system(4, name="Data Model")
rows = [
(a, 0.10, 2, "retrieval telemetry belongs here"),
@@ -94,7 +93,7 @@ async def test_the_search_collapses_to_best_chunk_and_reports_which_one_won():
@pytest.mark.asyncio
async def test_a_project_the_caller_cannot_read_returns_nothing():
"""Rule 78 — the charter of a project someone was not given is not an
"""The charter of a project someone was not given is not an
answer to any question they are entitled to ask."""
_session, ctx = _rows_ctx([])
with (
+10 -11
View File
@@ -1,4 +1,4 @@
"""Timestamp filters must bind datetimes, never strings (#1727, #2254).
"""Timestamp filters must bind datetimes, never strings.
`AppLog.created_at` is `timestamp with time zone`. asyncpg binds a Python `str`
as VARCHAR, and Postgres has no `timestamptz >= text` operator — so comparing
@@ -22,7 +22,7 @@ from pathlib import Path
SRC = Path(__file__).resolve().parents[1] / "src" / "scribe"
# --- #1727: the reminder dedup window ----------------------------------------
# --- the reminder dedup window ----------------------------------------------
def test_utc_day_start_is_an_aware_datetime_not_a_string():
from scribe.services.notifications import utc_day_start
@@ -38,7 +38,7 @@ def test_utc_day_start_is_an_aware_datetime_not_a_string():
assert (start.hour, start.minute, start.second, start.microsecond) == (0, 0, 0, 0)
# --- #2254: the admin log viewer's date filters ------------------------------
# --- the admin log viewer's date filters ------------------------------------
def test_filter_datetime_parses_dates_and_datetimes_to_aware_utc():
from scribe.services.logging import parse_filter_datetime
@@ -83,17 +83,16 @@ def test_filter_datetime_rejects_garbage_instead_of_raising():
def _timestamp_comparison_offenders() -> list[str]:
"""Every place a `*_at` column is compared against a known-string name.
TWO shapes, because the two real bugs had different ones and a guard built
from only the first would have missed the second:
TWO shapes, because a guard built from only the first misses the second:
1. a local bound to `.isoformat()` — #1727, `today_str = today.isoformat()`
2. a parameter annotated `str` — #2254, `date_from: str | None`, straight
off `request.args`, never touching
isoformat at all
1. a local bound to `.isoformat()` — `today_str = today.isoformat()`
2. a parameter annotated `str` — `date_from: str | None`, straight
off `request.args`, never touching
isoformat at all
Shape 2 is the nastier one: nothing in the file looks date-ish, so grepping
for isoformat finds nothing. It was caught by reading, not by tooling —
which is exactly why it belongs in tooling now.
for isoformat finds nothing. Only reading catches it — which is exactly
why it belongs in tooling.
Deliberately narrow on both. A blanket "no strings near SQL" rule would fire
on every to_dict() in the codebase and get muted, and a muted guard is worse
+8 -10
View File
@@ -1,11 +1,9 @@
"""`/api/version` reports three values, and never folds them together.
WHAT THIS IS ABOUT (rule 149). Until 2026-08-31 the endpoint returned
`{"version": "main"}` — CI set `BUILD_VERSION` to the CHANNEL, so a running
instance answered the question "which build are you?" with the name of a
branch. The cost was concrete rather than theoretical: during #3244's live
acceptance a deploy was behaving as though it held older code, and the one
endpoint whose job is to settle that could not.
WHAT THIS IS ABOUT. A CI that sets `BUILD_VERSION` to the CHANNEL makes a
running instance answer "which build are you?" with the name of a branch — and
when a deploy behaves as though it holds older code, the one endpoint whose job
is to settle that cannot.
The three values answer different questions and so cannot be one value:
@@ -78,7 +76,7 @@ def test_the_channel_is_never_inside_the_name():
})
assert NAME_RE.match(out["version"]), (
f"the version name is {out['version']!r} — not YYYY.MM.DD.HHMM. A "
f"channel or branch name here is the 2026-08-31 bug returning."
f"channel or branch name here is the channel folded into the version."
)
assert "stable" not in out["version"]
@@ -120,7 +118,7 @@ def test_a_malformed_ordering_key_is_dropped_not_passed_through():
def test_the_channel_is_reported_verbatim():
"""Never validated against an enum — a build claiming something
unexpected is better shown than dropped (rule 149)."""
unexpected is better shown than dropped."""
out = _version_payload({"APP_VERSION": "dev", "APP_CHANNEL": "canary"})
assert out["channel"] == "canary"
@@ -138,7 +136,7 @@ def test_ci_does_not_stamp_the_channel_as_the_version():
for wrong in ('BUILD_VERSION="main"', 'BUILD_VERSION="dev"'):
assert wrong not in text, (
f"CI sets {wrong} — that is the channel in the version field, "
f"which is the 2026-08-31 regression."
f"folding the three values into one."
)
@@ -168,7 +166,7 @@ def test_ci_passes_all_three_plus_the_commit():
@pytest.mark.parametrize("commit_epoch,expected", [
# Midnight, where a naive formatter drops the leading zeros and yields
# "2026.01.05.0" — rule 149 names this case specifically.
# "2026.01.05.0" — the case the padding exists for.
(datetime(2026, 1, 5, 0, 0, tzinfo=timezone.utc), "2026.01.05.0000"),
(datetime(2026, 1, 5, 0, 7, tzinfo=timezone.utc), "2026.01.05.0007"),
(datetime(2026, 12, 31, 23, 59, tzinfo=timezone.utc), "2026.12.31.2359"),
+10 -11
View File
@@ -6,11 +6,10 @@ shells. They are deliberately few and deliberately about ONE property each,
because a grep-shaped test that asserts a whole file's contents fails on every
refactor and gets deleted.
WHY THIS FILE EXISTS. #3298: 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. The value was fixed then. This is the other
half — the value reaching a person — and #3127 checklist 12 is specific about
the way it goes wrong: *never let a blank stand in for `unknown`*. A readout
WHY THIS FILE EXISTS. With a deploy misbehaving, the instance has to be able to
say which commit is serving it. `/api/version` answers that; this is the other
half — the value reaching a person — and it goes wrong one way in particular:
*never let a blank stand in for `unknown`*. A readout
that renders a plausible value it never received is worse than one that renders
nothing, because it ends the investigation instead of starting it.
"""
@@ -25,9 +24,9 @@ FRONTEND = Path(__file__).resolve().parents[1] / "frontend" / "src"
def test_something_actually_reads_the_version_endpoint():
"""The endpoint is not enough; something must ask it.
`/api/version` answered correctly for weeks with no caller — an endpoint
reachable only by someone who already knew to curl it. Rule 27: a
capability with no surface the operator can touch is not shipped.
An endpoint with no caller is reachable only by someone who already knows
to curl it, and a capability with no surface the operator can touch is
not shipped.
"""
hits = [p for p in FRONTEND.rglob("*.ts") if "/api/version" in p.read_text()]
assert hits, "nothing under frontend/src fetches /api/version"
@@ -52,8 +51,8 @@ def test_the_footer_does_not_default_to_a_plausible_version():
decl = match.group(0)
assert '"dev"' not in decl and "'dev'" not in decl, (
f"appVersion defaults to a version-shaped literal: {decl}\n"
"A failed fetch would render as a real-looking version (#3127 "
"checklist 12). Start from a not-answered-yet value instead."
"A failed fetch would render as a real-looking version. "
"Start from a not-answered-yet value instead."
)
@@ -75,7 +74,7 @@ def test_optional_version_fields_are_read_by_absence_not_falsiness():
def test_the_version_request_carries_a_deadline():
"""Rule 156. A wait with no deadline cannot report that it failed.
"""A wait with no deadline cannot report that it failed.
This readout is consulted when an instance is misbehaving, which is exactly
when it may never answer. Without a deadline the surface sits on "still