From fd2ebf4c1380e4a175fecffac309940f366c66d8 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 1 Oct 2026 15:03:41 -0400
Subject: [PATCH 1/4] feat(rules): a rule surfaces through the lessons
confirmed as its instances (milestone 440 step 4, #4633)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On all three rule arms (prompt_rule, pre_tool_rule, write_path_rule), after
the direct match, a lesson matching the moment at the notes menu's own bar
brings the rule(s) it is CONFIRMED to be an instance of, rendered in rule
voice with "Reached through lesson #N “…”, a recorded instance of it."
- Confirmed links only (lesson_rules.confirmed_lessons /
confirmed_rules_in_scope). A suggested link carrying its rule would
manufacture the co-arrival #4637 counts and prove itself.
- Scope kept: a lesson surfaces everywhere, but the rule it brings must be
global or this project's own — milestone 414's boundary, not reopened
through a side door.
- Suppression is by RULE: anything the direct band named or the session
ledger holds is skipped, whichever lesson reached it.
- Its own slot (VIA_LESSON_LIMIT = 1), not a rule slot — a stated default,
since the plan's "decide by measurement" has nothing to measure until
links are confirmed (#4632). Its own source, rule_via_lesson: registered,
RANKED for pull-through, logged whenever it searches.
- Nothing searched at all while no lesson carries a confirmed link, which is
every install until one is judged.
- build_prompt_rule_hint / build_tool_rule_hint are now thin wrappers over
the direct arms (_prompt_rule_hint / _tool_rule_hint); the arm leaves its
query in `_via_query` only when it ran, so a disabled arm or a blank prompt
brings no rule in either, and the key never leaves the server.
- Via-lesson rules are not fed to the #4637 co-surfacing recorder: only
direct matches are evidence.
Co-Authored-By: Claude Opus 5.5
---
src/scribe/services/lesson_rules.py | 72 +++++++-
src/scribe/services/plugin_context.py | 180 ++++++++++++++++++++
src/scribe/services/retrieval_registry.py | 6 +
src/scribe/services/rule_usage.py | 4 +
tests/conftest.py | 9 +-
tests/test_integration_lesson_rule_links.py | 33 ++++
tests/test_rule_via_lesson.py | 147 ++++++++++++++++
7 files changed, 448 insertions(+), 3 deletions(-)
create mode 100644 tests/test_rule_via_lesson.py
diff --git a/src/scribe/services/lesson_rules.py b/src/scribe/services/lesson_rules.py
index 742d0c2..eab1778 100644
--- a/src/scribe/services/lesson_rules.py
+++ b/src/scribe/services/lesson_rules.py
@@ -39,7 +39,7 @@ import logging
import re
from datetime import datetime, timedelta, timezone
-from sqlalchemy import and_, delete, exists, func, not_, select
+from sqlalchemy import and_, delete, exists, func, not_, or_, select
from sqlalchemy.exc import IntegrityError
from scribe.models import async_session
@@ -650,3 +650,73 @@ async def _co_surfaced(user_id, lesson_ids, rule_ids, arm, situation, project_id
)).scalar_one_or_none() or ""
rule = owned[rid]
return _proposal_line(lid, lesson_title, rid, rule.title, rule.kind or "rule", ev)
+
+
+# ── Reaching a rule through its lessons (#4633) ──────────────────────────────
+
+
+async def confirmed_lessons(user_id: int) -> set[int]:
+ """The lessons, readable by the caller, that carry a CONFIRMED link to a
+ rule the caller owns — the only lessons that can bring a rule along.
+
+ CONFIRMED ONLY, and the reason is the soft link's: a suggested link that
+ carried its rule would put the two side by side on every prompt that
+ found the lesson, which is exactly the co-arrival #4637 counts as
+ evidence — the suggestion would manufacture its own proof.
+
+ Asked first and cheaply, so an arm on an install with no confirmed links
+ — every install, until someone judges one — never pays for a search.
+ """
+ from scribe.services.access import readable_notes_clause
+ from scribe.services.rulebooks import _owned_rules_clause
+
+ async with async_session() as session:
+ rows = (await session.execute(
+ select(LessonRuleLink.lesson_id)
+ .join(Rule, Rule.id == LessonRuleLink.rule_id)
+ .join(Note, Note.id == LessonRuleLink.lesson_id)
+ .where(LessonRuleLink.state == CONFIRMED)
+ .where(Rule.deleted_at.is_(None))
+ .where(_owned_rules_clause(user_id))
+ .where(Note.deleted_at.is_(None))
+ .where(readable_notes_clause(user_id))
+ .distinct()
+ )).scalars().all()
+ return {int(i) for i in rows}
+
+
+async def confirmed_rules_in_scope(
+ user_id: int, lesson_ids, project_id: int | None,
+) -> dict[int, list]:
+ """{lesson_id: [Rule]} — each lesson's CONFIRMED rules that may surface here.
+
+ "May surface here" is the rule arms' own scope (milestone 414): a global
+ rule, or a rule of THIS project. A lesson is project-independent and
+ surfaces everywhere, so without this a lesson linked to project A's rule
+ would carry that rule into project B's sessions — the leak milestone 414
+ closed for the direct arms, reopened through a side door.
+ """
+ from scribe.services.rulebooks import _owned_rules_clause
+
+ ids = [int(i) for i in lesson_ids or []]
+ if not ids:
+ return {}
+ home = (
+ or_(Rule.topic_id.isnot(None), Rule.project_id == project_id)
+ if project_id else Rule.topic_id.isnot(None)
+ )
+ async with async_session() as session:
+ rows = (await session.execute(
+ select(LessonRuleLink.lesson_id, Rule)
+ .join(Rule, Rule.id == LessonRuleLink.rule_id)
+ .where(LessonRuleLink.lesson_id.in_(ids))
+ .where(LessonRuleLink.state == CONFIRMED)
+ .where(Rule.deleted_at.is_(None))
+ .where(_owned_rules_clause(user_id))
+ .where(home)
+ .order_by(Rule.id)
+ )).all()
+ out: dict[int, list] = {}
+ for lesson_id, rule in rows:
+ out.setdefault(int(lesson_id), []).append(rule)
+ return out
diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py
index 957da98..45bba79 100644
--- a/src/scribe/services/plugin_context.py
+++ b/src/scribe/services/plugin_context.py
@@ -1390,6 +1390,126 @@ async def _reserve_slot_for_preference(
return hits + slot, slot_id
+# ── A rule reached through its lessons (milestone 440, #4633) ──────────────
+#
+# A rule's own document is written in the rule's words, which are general by
+# design; the situations that keep proving it are often closer to what a
+# session is actually doing. A lesson JUDGED to be an instance of a rule (a
+# CONFIRMED link) carries that situation, so a lesson matching the moment
+# brings its rule along — in rule voice, naming the lesson that reached it.
+#
+# ITS OWN SLOT, NOT A RULE SLOT, as a stated default. The plan asked for this
+# to be settled by measurement, and there is nothing to measure until links
+# are confirmed (#4632). Until then the asymmetry decides it: a via-lesson
+# line that took a rule slot could push out a rule the ranker matched
+# DIRECTLY, a stronger claim displaced by a weaker one, while an extra line
+# costs one line. `rule_via_lesson` is logged as its own source so the
+# question can be answered from data later (#4636).
+VIA_LESSON_LIMIT = 1
+# Lessons fetched before keeping only the linked ones. The search cannot be
+# told "linked lessons only", so it overfetches and filters; on a corpus where
+# most lessons are unlinked, a fetch of one would almost always be spent on a
+# lesson that carries nothing.
+_VIA_LESSON_OVERFETCH = 10
+
+
+async def _rules_via_lessons(
+ user_id: int, query: str, *, project_id: int | None, skip: set[int],
+ held: set[int], where: str,
+) -> tuple[list[str], list[int]]:
+ """Rule lines reached through a matching lesson's CONFIRMED links.
+
+ The lesson bar is the notes menu's own threshold — the bar a lesson has to
+ clear to be shown at all — so a lesson too weak to surface cannot carry a
+ rule in. `skip` is every rule this response already names plus the
+ session ledger: suppression applies to the RULE, whichever lesson reached
+ it. Returns (lines, rule ids shown). Fails open, like every arm.
+ """
+ try:
+ confirmed = await lesson_rules_svc.confirmed_lessons(user_id)
+ if not confirmed:
+ return [], []
+ bar = (await get_autoinject_config(user_id))["threshold"]
+ t0 = time.perf_counter()
+ _rep: dict = {}
+ found = await semantic_search_notes(
+ user_id, query, limit=_VIA_LESSON_OVERFETCH, threshold=bar,
+ project_id=project_id, note_type=(LESSON_NOTE_TYPE,),
+ include_global_kinds=True, scope="browse", report=_rep,
+ )
+ matched = [(s, n) for s, n in found if int(n.id) in confirmed]
+ by_lesson = await lesson_rules_svc.confirmed_rules_in_scope(
+ user_id, [int(n.id) for _s, n in matched], project_id,
+ )
+ candidates = [
+ (s, rule, n) for s, n in matched for rule in by_lesson.get(int(n.id), [])
+ ]
+ chosen: list = []
+ taken: set[int] = set(skip)
+ for s, rule, n in candidates:
+ if rule.id in taken:
+ continue
+ taken.add(rule.id)
+ chosen.append((s, rule, n))
+ if len(chosen) >= VIA_LESSON_LIMIT:
+ break
+ # Logged whenever a search ran, results or not — the #3497 guard. The
+ # score is the LESSON's, because the lesson is what was matched; the
+ # best-available id is left off for the same reason, since the row's
+ # results are rules and an id beside them would read as a rule id.
+ record_retrieval(
+ user_id=user_id, source="rule_via_lesson", query=query,
+ threshold=bar, limit=VIA_LESSON_LIMIT, project_id=project_id,
+ is_task=None, results=[(s, rule) for s, rule, _n in chosen],
+ duration_ms=(time.perf_counter() - t0) * 1000.0,
+ best_available=_rep.get("best_available_score"),
+ searched=bool(_rep.get("searched", True)),
+ suppressed=len({r.id for _s, r, _n in candidates}) - len(chosen),
+ )
+ if not chosen:
+ return [], []
+ rule_ids = [rule.id for _s, rule, _n in chosen]
+ record_rule_surfaced(user_id=user_id, rule_ids=rule_ids, source="rule_via_lesson")
+ lines = [
+ _rule_hint_line(rule, where=where, seen=False, held=rule.id in held)
+ + f" Reached through lesson #{n.id} "
+ + f"\u201c{_menu_name(n.title, n.note_type, n.data, n.body)}\u201d, "
+ + "a recorded instance of it."
+ for _s, rule, n in chosen
+ ]
+ return lines, rule_ids
+ except Exception:
+ logger.debug("rule-via-lesson arm failed", exc_info=True)
+ return [], []
+
+
+async def _add_rules_via_lessons(
+ user_id: int, out: dict, *, project_id: int, exclude_rule_ids, held_rule_ids,
+ where: str,
+) -> dict:
+ """Run the via-lesson step after a direct rule arm, on the query that arm
+ actually searched with.
+
+ The arm leaves `_via_query` in its payload only when it RAN — enabled, and
+ with something to search — so an arm the operator switched off, or a
+ blank prompt, brings no rule in through a side door either. The key is
+ popped here; it never leaves the server.
+ """
+ query = out.pop("_via_query", "")
+ if not query:
+ return out
+ skip = (set(exclude_rule_ids or []) | set(out.get("rule_ids") or [])
+ | set(out.get("shown_rule_ids") or []))
+ lines, ids = await _rules_via_lessons(
+ user_id, query, project_id=project_id or None, skip=skip,
+ held=set(held_rule_ids or []), where=where,
+ )
+ if lines:
+ out["context"] = "\n".join(c for c in (out.get("context") or "", *lines) if c)
+ out["rule_ids"] = list(out.get("rule_ids") or []) + ids
+ return out
+
+
async def build_prompt_rule_hint(
user_id: int,
query: str,
@@ -1398,6 +1518,28 @@ async def build_prompt_rule_hint(
exclude_rule_ids: list[int] | None = None,
held_rule_ids: list[int] | None = None,
context: str = "",
+) -> dict:
+ """Rules and preferences that may apply to what the operator just asked —
+ matched directly (`_prompt_rule_hint`, where the design is written), then
+ reached through a linked lesson (`_rules_via_lessons`)."""
+ out = await _prompt_rule_hint(
+ user_id, query, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
+ held_rule_ids=held_rule_ids, context=context,
+ )
+ return await _add_rules_via_lessons(
+ user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
+ held_rule_ids=held_rule_ids, where="to this request",
+ )
+
+
+async def _prompt_rule_hint(
+ user_id: int,
+ query: str,
+ *,
+ project_id: int = 0,
+ exclude_rule_ids: list[int] | None = None,
+ held_rule_ids: list[int] | None = None,
+ context: str = "",
) -> dict:
"""Rules and preferences that may apply to what the operator just asked.
@@ -1436,6 +1578,9 @@ async def build_prompt_rule_hint(
# names it — and "yes, go ahead" names nothing a trigger can match, while
# the reply it answers ("commit this to dev and push") does.
q = _autoinject_query(q, context)
+ # The query this arm searches with, for the via-lesson step that follows
+ # it (#4633) — set only once the arm is going to run.
+ out["_via_query"] = q
try:
threshold = await floor_for(user_id, "prompt_rule")
@@ -2708,6 +2853,17 @@ async def build_write_path_hint(
except Exception:
logger.debug("write-path rule arm failed", exc_info=True)
+ # A rule reached through a linked lesson (#4633), after the direct band
+ # and never in place of it. Skips what the band already named and what
+ # the session's ledger holds — suppression is by rule.
+ via_lines, via_ids = await _rules_via_lessons(
+ user_id, code or path, project_id=project_id or None,
+ skip=set(exclude_rule_ids or []) | set(shown_rule_ids),
+ held=set(held_rule_ids or []), where="here",
+ )
+ lines.extend(via_lines)
+ rule_ids.extend(via_ids)
+
# A lesson and a rule on this one response (#4637): the soft-link
# recorder counts the pair, keyed on the FILE — every edit to one file is
# one situation — and asks about it once the evidence holds. Fails open.
@@ -2745,6 +2901,28 @@ async def build_tool_rule_hint(
project_id: int = 0,
exclude_rule_ids: list[int] | None = None,
held_rule_ids: list[int] | None = None,
+) -> dict:
+ """Standing rules that may apply to the action about to be taken — matched
+ directly (`_tool_rule_hint`, where the design is written), then reached
+ through a linked lesson (`_rules_via_lessons`, #4633)."""
+ out = await _tool_rule_hint(
+ user_id, tool_name, command, project_id=project_id,
+ exclude_rule_ids=exclude_rule_ids, held_rule_ids=held_rule_ids,
+ )
+ return await _add_rules_via_lessons(
+ user_id, out, project_id=project_id, exclude_rule_ids=exclude_rule_ids,
+ held_rule_ids=held_rule_ids, where=f"to this {tool_name} call",
+ )
+
+
+async def _tool_rule_hint(
+ user_id: int,
+ tool_name: str,
+ command: str,
+ *,
+ project_id: int = 0,
+ exclude_rule_ids: list[int] | None = None,
+ held_rule_ids: list[int] | None = None,
) -> dict:
"""Standing rules that may apply to the ACTION about to be taken (#3476).
@@ -2795,6 +2973,8 @@ async def build_tool_rule_hint(
# embedding window, so it is bounded — the verb and its target sit at
# the front, which is the part a rule is about.
query = command[:_TOOL_QUERY_CHARS]
+ # For the via-lesson step (#4633) — set only once the arm is enabled.
+ out["_via_query"] = query
t0 = time.perf_counter()
_rep_ptr: dict = {}
diff --git a/src/scribe/services/retrieval_registry.py b/src/scribe/services/retrieval_registry.py
index fa4d527..31c30a7 100644
--- a/src/scribe/services/retrieval_registry.py
+++ b/src/scribe/services/retrieval_registry.py
@@ -137,6 +137,12 @@ POINTS: dict[str, Point] = dict([
"the one line reserved for a preference at the prompt boundary"),
_p("reuse_slot", UNBIDDEN, "the one line reserved for a reusable snippet"),
_p("lesson_slot", UNBIDDEN, "the one line reserved for a lesson"),
+ _p("rule_via_lesson", UNBIDDEN,
+ "a rule reached through a lesson confirmed as an instance of it",
+ expects_traffic=False,
+ quiet_because="searches only once some lesson has a confirmed link to "
+ "a rule; an install where none has been judged is "
+ "correctly silent here"),
# `fixed_query`: COMPLETION_QUERY is a module constant in
# services/reply_preferences.py, so this arm's top score is the same number
# on every call — measured at 0.791 across 45 consecutive calls, with p10,
diff --git a/src/scribe/services/rule_usage.py b/src/scribe/services/rule_usage.py
index 6b80a5d..7869c99 100644
--- a/src/scribe/services/rule_usage.py
+++ b/src/scribe/services/rule_usage.py
@@ -110,6 +110,10 @@ RANKED_SOURCES = (
# The completion-report lookup on update_task (milestone 409 step 4). It
# runs its own query and shows only what cleared the bar — a ranker.
"report_preference",
+ # A rule reached through a lesson confirmed as an instance of it (#4633).
+ # Its own source so its pull-through reads apart from the rule's direct
+ # match — whether the lesson route earns its line is #4636's question.
+ "rule_via_lesson",
)
diff --git a/tests/conftest.py b/tests/conftest.py
index 5ff10bf..3ffd59b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -157,7 +157,10 @@ def _no_lesson_rule_links(request):
otherwise ride along with every unit test that creates a lesson. The
co-surfacing recorder (#4637) is stubbed to "no proposal": every hook
builder test that renders a lesson beside a rule would otherwise reach
- for the database to count the pair.
+ for the database to count the pair. And the via-lesson rule step (#4633)
+ is stubbed to "no lesson brought a rule": all three rule arms run it, and
+ its first act is a database read. tests/test_rule_via_lesson.py binds the
+ real function at import time, before this patch runs.
Skipped for integration tests, which exercise the real links against
Postgres (tests/test_integration_lesson_rule_links.py).
@@ -168,7 +171,9 @@ def _no_lesson_rule_links(request):
with patch("scribe.services.lesson_rules.attach_lesson_rules", AsyncMock()), \
patch("scribe.services.lesson_rules.attach_rule_lessons", AsyncMock()), \
patch("scribe.services.lesson_rules.rule_candidates", AsyncMock(return_value=[])), \
- patch("scribe.services.lesson_rules.co_surfaced", AsyncMock(return_value="")):
+ patch("scribe.services.lesson_rules.co_surfaced", AsyncMock(return_value="")), \
+ patch("scribe.services.plugin_context._rules_via_lessons",
+ AsyncMock(return_value=([], []))):
yield
diff --git a/tests/test_integration_lesson_rule_links.py b/tests/test_integration_lesson_rule_links.py
index aed1f23..6ca8b6b 100644
--- a/tests/test_integration_lesson_rule_links.py
+++ b/tests/test_integration_lesson_rule_links.py
@@ -314,3 +314,36 @@ async def test_confirming_a_suggestion_keeps_what_it_rested_on(world):
out = await links_svc.judge_link(world["owner"], world["lesson"], world["r1"], "confirm", "same failure class")
assert out["state"] == "confirmed"
assert len(out["evidence"]["situations"]) == 3
+
+
+# ── #4633: 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."""
+ 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")
+ assert await links_svc.confirmed_lessons(owner) == set()
+ assert await links_svc.confirmed_rules_in_scope(owner, [lesson], world["mine"]) == {}
+
+ await links_svc.judge_link(owner, lesson, world["r1"], "confirm", "same failure class")
+ assert lesson in await links_svc.confirmed_lessons(owner)
+ rules = await links_svc.confirmed_rules_in_scope(owner, [lesson], world["mine"])
+ assert [r.id for r in rules[lesson]] == [world["r1"]]
+
+
+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
+ everywhere, its project rule only in its project."""
+ owner, lesson = world["owner"], world["lesson"]
+ await links_svc.set_lesson_rules(owner, lesson, [world["r1"]])
+ assert await links_svc.confirmed_rules_in_scope(owner, [lesson], world["mine"])
+ assert await links_svc.confirmed_rules_in_scope(owner, [lesson], world["theirs"]) == {}
+ assert await links_svc.confirmed_rules_in_scope(owner, [lesson], None) == {}
+
+
+async def test_a_stranger_reaches_no_rule_through_someone_elses_link(world):
+ await links_svc.set_lesson_rules(world["owner"], world["lesson"], [world["r1"]])
+ assert await links_svc.confirmed_lessons(world["stranger"]) == set()
diff --git a/tests/test_rule_via_lesson.py b/tests/test_rule_via_lesson.py
new file mode 100644
index 0000000..860e9ae
--- /dev/null
+++ b/tests/test_rule_via_lesson.py
@@ -0,0 +1,147 @@
+"""A rule reached through its lessons (milestone 440, #4633).
+
+The step runs after each of the three rule arms. These pin, with the database
+and the embedder stubbed: that nothing is searched when no lesson carries a
+confirmed link; that a matching linked lesson brings its rule in rule voice,
+naming the lesson; that suppression is by RULE; that the slot holds one line;
+and that the call is logged under its own source. That a SUGGESTED link never
+expands — the soft link proving itself — is pinned against Postgres in
+tests/test_integration_lesson_rule_links.py, where the state filter lives.
+"""
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from scribe.services import plugin_context as pc
+from scribe.services import rule_usage
+from scribe.services.retrieval_registry import POINTS
+
+# Bound before conftest's autouse stub replaces the module attribute.
+_REAL = pc._rules_via_lessons
+
+
+def _lesson(lid=41, title="An overrun run usually failed early"):
+ return SimpleNamespace(
+ id=lid, title=title, note_type="lesson", body="",
+ data={"what": title, "when_to_apply": "a CI run overran"},
+ )
+
+
+def _rule(rid, title="Read the job log first", kind="rule"):
+ return SimpleNamespace(id=rid, title=title, kind=kind, when_to_apply="a run overran")
+
+
+def _stubs(*, confirmed, found, by_lesson):
+ log, surfaced = MagicMock(), MagicMock()
+ stack = [
+ patch.object(pc.lesson_rules_svc, "confirmed_lessons", AsyncMock(return_value=confirmed)),
+ patch.object(pc.lesson_rules_svc, "confirmed_rules_in_scope", AsyncMock(return_value=by_lesson)),
+ patch.object(pc, "get_autoinject_config", AsyncMock(return_value={"threshold": 0.5})),
+ patch.object(pc, "semantic_search_notes", AsyncMock(return_value=found)),
+ patch.object(pc, "record_retrieval", log),
+ patch.object(pc, "record_rule_surfaced", surfaced),
+ ]
+ return stack, log, surfaced
+
+
+async def _run(stack, **kw):
+ for p in stack:
+ p.start()
+ try:
+ return await _REAL(1, "the CI run is still going", project_id=2,
+ skip=kw.get("skip", set()), held=set(), where="to this request")
+ finally:
+ for p in stack:
+ p.stop()
+
+
+@pytest.mark.asyncio
+async def test_no_confirmed_link_means_no_search_and_no_row():
+ search = AsyncMock()
+ stack, log, _ = _stubs(confirmed=set(), found=[], by_lesson={})
+ stack[3] = patch.object(pc, "semantic_search_notes", search)
+ assert await _run(stack) == ([], [])
+ search.assert_not_awaited()
+ log.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_a_matching_linked_lesson_brings_its_rule_in_rule_voice():
+ stack, log, surfaced = _stubs(
+ confirmed={41}, found=[(0.71, _lesson())], by_lesson={41: [_rule(7)]},
+ )
+ lines, ids = await _run(stack)
+ assert ids == [7]
+ assert lines[0].startswith("Standing rule")
+ assert "Reached through lesson #41" in lines[0]
+ assert log.call_args.kwargs["source"] == "rule_via_lesson"
+ assert surfaced.call_args.kwargs == {"user_id": 1, "rule_ids": [7], "source": "rule_via_lesson"}
+
+
+@pytest.mark.asyncio
+async def test_a_lesson_without_a_confirmed_link_carries_nothing():
+ """The search found a lesson, but it is not in the confirmed set — a
+ suggested or unlinked lesson brings no rule."""
+ stack, log, surfaced = _stubs(
+ confirmed={99}, found=[(0.9, _lesson(41))], by_lesson={},
+ )
+ assert await _run(stack) == ([], [])
+ surfaced.assert_not_called()
+ assert log.call_args.kwargs["results"] == []
+
+
+@pytest.mark.asyncio
+async def test_suppression_is_by_rule_whichever_lesson_reached_it():
+ stack, log, surfaced = _stubs(
+ confirmed={41}, found=[(0.71, _lesson())], by_lesson={41: [_rule(7)]},
+ )
+ assert await _run(stack, skip={7}) == ([], [])
+ assert log.call_args.kwargs["suppressed"] == 1
+ surfaced.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_the_slot_holds_one_line():
+ stack, _log, _ = _stubs(
+ confirmed={41, 42},
+ found=[(0.8, _lesson(41)), (0.7, _lesson(42, "Another"))],
+ by_lesson={41: [_rule(7), _rule(8)], 42: [_rule(9)]},
+ )
+ lines, ids = await _run(stack)
+ assert ids == [7] and len(lines) == pc.VIA_LESSON_LIMIT == 1
+
+
+@pytest.mark.asyncio
+async def test_a_failure_brings_no_rule_and_raises_nothing():
+ stack, _log, _ = _stubs(confirmed={41}, found=[], by_lesson={})
+ stack[0] = patch.object(pc.lesson_rules_svc, "confirmed_lessons",
+ AsyncMock(side_effect=RuntimeError("db down")))
+ assert await _run(stack) == ([], [])
+
+
+@pytest.mark.asyncio
+async def test_the_step_runs_only_when_the_arm_ran_and_never_leaks_its_key():
+ step = AsyncMock(return_value=(["Standing rule … Reached through lesson #41"], [7]))
+ with patch.object(pc, "_rules_via_lessons", step):
+ idle = await pc._add_rules_via_lessons(
+ 1, {"context": "", "rule_ids": []}, project_id=2,
+ exclude_rule_ids=[3], held_rule_ids=[], where="here",
+ )
+ ran = await pc._add_rules_via_lessons(
+ 1, {"context": "direct line", "rule_ids": [5], "_via_query": "q"},
+ project_id=2, exclude_rule_ids=[3], held_rule_ids=[], where="here",
+ )
+ assert idle == {"context": "", "rule_ids": []}
+ assert step.await_count == 1
+ assert step.await_args.kwargs["skip"] == {3, 5}
+ assert "_via_query" not in ran
+ assert ran["rule_ids"] == [5, 7]
+ assert ran["context"].startswith("direct line\n")
+
+
+def test_the_source_is_ranked_and_registered():
+ assert "rule_via_lesson" in rule_usage.RANKED_SOURCES
+ assert "rule_via_lesson" in POINTS
--
2.54.0
From f34249a2d855cdc596a6aa60e24e6217d0743fae Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 1 Oct 2026 15:11:28 -0400
Subject: [PATCH 2/4] test(rules): locate the pre-tool arm by what it does, not
by its name (#4633)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI run 712 failed test_neither_rule_arm_logs_its_call_behind_a_results_guard
with "min() iterable argument is empty": the guard walked the function named
build_tool_rule_hint, which since fd2ebf4 is a wrapper that searches nothing
— the arm's body moved to _tool_rule_hint. The property it guards (the call
is logged before any early return) still held; the guard had pinned a name.
It now finds the async function that calls semantic_search_rules under the
pre_tool_rule source, so a later rename moves the guard with the arm instead
of emptying it (rule 167).
Co-Authored-By: Claude Opus 5.5
---
tests/test_rule_usage_wiring.py | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/tests/test_rule_usage_wiring.py b/tests/test_rule_usage_wiring.py
index 6b466ae..91dde55 100644
--- a/tests/test_rule_usage_wiring.py
+++ b/tests/test_rule_usage_wiring.py
@@ -908,9 +908,23 @@ def test_neither_rule_arm_logs_its_call_behind_a_results_guard():
#
# The property is positional: between the search and the first guard that
# can return early, the call row has already been written.
+ # The arm's BODY, which since #4633 lives in `_tool_rule_hint`; the public
+ # `build_tool_rule_hint` is a wrapper that adds the via-lesson step and
+ # searches nothing itself. Located by what it does — the function that
+ # calls the rule search under the pre_tool_rule source — so the next
+ # rename moves the guard with it instead of emptying it.
fn = next(
n for n in ast.walk(ast.parse(pc_src))
- if isinstance(n, ast.AsyncFunctionDef) and n.name == "build_tool_rule_hint"
+ if isinstance(n, ast.AsyncFunctionDef)
+ and any(
+ isinstance(c, ast.Call) and getattr(c.func, "id", None) == "semantic_search_rules"
+ for c in ast.walk(n)
+ )
+ and any(
+ isinstance(k, ast.keyword) and k.arg == "source"
+ and isinstance(k.value, ast.Constant) and k.value.value == "pre_tool_rule"
+ for k in ast.walk(n)
+ )
)
search_at = min(
n.lineno for n in ast.walk(fn)
@@ -929,7 +943,7 @@ def test_neither_rule_arm_logs_its_call_behind_a_results_guard():
and any(isinstance(b, ast.Return) for b in n.body)
]
assert bailouts, (
- "no early return found after the search in build_tool_rule_hint — the "
+ "no early return found after the search in the pre-tool arm — the "
"guard has nothing left to protect, which means this test is now "
"passing vacuously rather than the arm being correct"
)
--
2.54.0
From 6d3dca0af5009b30238139a6da90a8bf5e7705ef Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 1 Oct 2026 15:31:15 -0400
Subject: [PATCH 3/4] =?UTF-8?q?feat(lessons):=20convergence=20is=20named?=
=?UTF-8?q?=20at=20the=20write=20=E2=80=94=20no-rule=20lessons=20that=20ke?=
=?UTF-8?q?ep=20landing=20in=20one=20situation=20suggest=20a=20rule=20(mil?=
=?UTF-8?q?estone=20440=20step=205,=20#4634)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a lesson is answered "no rule fits" (create_lesson / update_lesson on
both doors), the response looks for other no-rule lessons it resembles and,
once there are CONVERGENCE_LESSONS (3) of them, carries `convergence`: the
members, their incidents and projects, and a hint to draft the missing rule
with create_rule (operator approval as always) and point each lesson at it —
or to leave them as lessons when no single choice is right every time.
- convergence_group is the pure bar: distinct LESSONS count, incidents never
stand in for them (one broad lesson cannot trigger it), and a group whose
sources all point at one incident is one event written up several times.
- convergence_for searches lessons by the new one's claim + trigger
(trigger_title) at CONVERGENCE_THRESHOLD 0.65 — above the menu's "worth
showing", below the duplicate gate's "same record" — then keeps the ones
with a lesson_no_rule answer. Fail-open. No sweep, no timer (#4183).
- Defaults stated as defaults (rules 32, 115).
- Tests: the bar (pure), the search with stubs, the door, and the no-rule
filter against Postgres; conftest stubs convergence_for for unit tests.
Co-Authored-By: Claude Opus 5.5
---
src/scribe/mcp/tools/lessons.py | 20 +++-
src/scribe/routes/lessons.py | 10 ++
src/scribe/services/lesson_rules.py | 113 ++++++++++++++++++++
tests/conftest.py | 7 +-
tests/test_integration_lesson_rule_links.py | 12 +++
tests/test_lesson_convergence.py | 100 +++++++++++++++++
6 files changed, 259 insertions(+), 3 deletions(-)
create mode 100644 tests/test_lesson_convergence.py
diff --git a/src/scribe/mcp/tools/lessons.py b/src/scribe/mcp/tools/lessons.py
index 3f116eb..2f72a2d 100644
--- a/src/scribe/mcp/tools/lessons.py
+++ b/src/scribe/mcp/tools/lessons.py
@@ -188,6 +188,9 @@ async def create_lesson(
(milestone 440). Naming a rule here confirms the link.
no_rule: The reason no rule governs this situation, in a line — the
other answer to "which rule?". Give one or the other, not both.
+ When other lessons in the same situation also answered "no rule
+ fits", the response carries `convergence`: the group, and the
+ rule it may be missing.
force: Create even if a near-duplicate exists.
Returns the created lesson with its `rules` and `rule_judgment`, plus
@@ -237,9 +240,20 @@ async def create_lesson(
await lesson_rules_svc.attach_lesson_rules(uid, [data])
if not linked and not no_rule.strip():
await _offer_candidates(uid, data, what, when_to_apply, project_id)
+ elif no_rule.strip():
+ await _name_convergence(uid, data, note.id)
return data
+async def _name_convergence(uid: int, data: dict, lesson_id: int) -> None:
+ """A "no rule fits" answer is the moment to notice it is not the first
+ for this situation (#4634) — `convergence` names the group and the rule
+ it may be missing. Absent when there is no group."""
+ group = await lesson_rules_svc.convergence_for(uid, lesson_id)
+ if group:
+ data["convergence"] = group
+
+
async def _offer_candidates(uid: int, data: dict, what: str, trigger: str, project_id: int) -> None:
"""Put the rules an unjudged lesson resembles in front of its writer.
@@ -343,7 +357,9 @@ async def update_lesson(
no_rule: Record that no rule governs this lesson's situation, with the
reason in a line. Any rule still linked is rejected with that
reason. Empty leaves the answer unchanged; give this or a
- non-empty `rule_ids`, not both.
+ non-empty `rule_ids`, not both. When other lessons in the same
+ situation also answered "no rule fits", the response carries
+ `convergence`: the group, and the rule it may be missing.
"""
uid = current_user_id()
linked = (
@@ -373,6 +389,8 @@ async def update_lesson(
await lesson_rules_svc.set_no_rule(uid, lesson_id, no_rule)
out = _to_dict(note)
await lesson_rules_svc.attach_lesson_rules(uid, [out])
+ if no_rule.strip():
+ await _name_convergence(uid, out, lesson_id)
return out
diff --git a/src/scribe/routes/lessons.py b/src/scribe/routes/lessons.py
index a68ced5..aec8181 100644
--- a/src/scribe/routes/lessons.py
+++ b/src/scribe/routes/lessons.py
@@ -188,6 +188,10 @@ async def create_lesson_route():
elif no_rule:
await lesson_rules_svc.set_no_rule(uid, note.id, no_rule)
out = lessons_svc.lesson_to_dict(note)
+ if no_rule:
+ group = await lesson_rules_svc.convergence_for(uid, note.id)
+ if group:
+ out["convergence"] = group
out["systems"] = [
s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id)
]
@@ -292,6 +296,12 @@ async def update_lesson_route(lesson_id: int):
# may edit — it decides WHOSE reach the tagging uses (#47).
await systems_svc.set_record_systems(uid, lesson_id, data["system_ids"])
out = lessons_svc.lesson_to_dict(updated)
+ if no_rule:
+ # The same nudge the MCP door gives (#4634): this answer may complete
+ # a group of no-rule lessons in one situation.
+ group = await lesson_rules_svc.convergence_for(uid, lesson_id)
+ if group:
+ out["convergence"] = group
out["systems"] = [
s.to_dict()
for s in await systems_svc.list_record_systems(owner_uid, lesson_id)
diff --git a/src/scribe/services/lesson_rules.py b/src/scribe/services/lesson_rules.py
index eab1778..f3de539 100644
--- a/src/scribe/services/lesson_rules.py
+++ b/src/scribe/services/lesson_rules.py
@@ -720,3 +720,116 @@ async def confirmed_rules_in_scope(
for lesson_id, rule in rows:
out.setdefault(int(lesson_id), []).append(rule)
return out
+
+
+# ── Convergence: lessons with no rule that keep landing in one place (#4634) ─
+#
+# A lesson answered "no rule fits" is a situation nothing binds. One is a
+# lesson. Several that resemble each other are what a missing rule looks like
+# from the outside — the same moment met again and again, each time written
+# down as advice. This is noticed at the WRITE, when the newest of them is
+# answered, and never by a sweep or a timer (#4183): the reader is in the
+# situation then, and a nudge arriving anywhere else is one nobody acts on.
+#
+# DEFAULTS, stated as defaults (rules 32, 115). Three lessons — the new one
+# and two it resembles — is the smallest group that is a pattern rather than
+# a pair. The similarity bar sits above the notes menu's ("worth showing")
+# and below the duplicate gate's ("the same record"): these lessons should be
+# about one situation without being one lesson written twice, which the
+# duplicate gate already catches.
+CONVERGENCE_LESSONS = 3
+CONVERGENCE_THRESHOLD = 0.65
+# Candidates fetched before keeping the no-rule ones; most lessons near a
+# situation may well have rules.
+_CONVERGENCE_FETCH = 20
+
+
+def convergence_group(members: list[dict]) -> dict | None:
+ """Decide from a candidate group — the new lesson FIRST, then those it
+ resembles — whether it names a missing rule. Pure, so the bar is testable.
+
+ Each member is {id, title, sources, project_id}. The bar counts DISTINCT
+ LESSONS, never incidents: a single broad lesson drawn from many incidents
+ is still one judgment about one situation, and incidents cannot stand in
+ for lessons. And when every member says what taught it and all of them
+ point at the same single incident, that is one event written up several
+ times, not a situation recurring — the group stays quiet.
+ """
+ seen: set[int] = set()
+ group = []
+ for m in members:
+ if m["id"] not in seen:
+ seen.add(m["id"])
+ group.append(m)
+ if len(group) < CONVERGENCE_LESSONS:
+ return None
+ incidents = sorted({s for m in group for s in (m.get("sources") or [])})
+ if all(m.get("sources") for m in group) and len(incidents) < 2:
+ return None
+ projects = sorted({m["project_id"] for m in group if m.get("project_id")})
+ named = ", ".join(f"#{m['id']} “{m['title']}”" for m in group)
+ return {
+ "lessons": [{"id": m["id"], "title": m["title"]} for m in group],
+ "incidents": incidents,
+ "projects": projects,
+ "hint": (
+ f"{len(group)} lessons answered \"no rule fits\" and keep landing "
+ f"in one situation: {named}. A situation met this often may want a "
+ "rule — the binding choice they each circle. Draft it with "
+ "create_rule (it goes to the operator, as every rule does), then "
+ "point each lesson at it with update_lesson(lesson_id, "
+ "rule_ids=[]). If no single choice is right every "
+ "time, the lessons are the right record and nothing more is needed."
+ ),
+ }
+
+
+async def _no_rule_ids(lesson_ids) -> set[int]:
+ ids = [int(i) for i in lesson_ids or []]
+ if not ids:
+ return set()
+ async with async_session() as session:
+ rows = (await session.execute(
+ select(LessonNoRule.lesson_id).where(LessonNoRule.lesson_id.in_(ids))
+ )).scalars().all()
+ return {int(i) for i in rows}
+
+
+async def convergence_for(user_id: int, lesson_id: int) -> dict | None:
+ """The convergence a newly answered no-rule lesson completes, or None.
+
+ Fail-open: this decorates a write that already succeeded, so a failed
+ search leaves the response as it was (snippet #4286's reasoning).
+ """
+ try:
+ return await _convergence_for(user_id, lesson_id)
+ except Exception:
+ logger.warning("lesson convergence could not be checked", exc_info=True)
+ return None
+
+
+async def _convergence_for(user_id: int, lesson_id: int) -> dict | None:
+ from scribe.services import lessons as lessons_svc
+ from scribe.services.embeddings import semantic_search_notes, trigger_title
+
+ lesson = await lessons_svc.get_lesson(user_id, lesson_id)
+ if lesson is None:
+ return None
+ data = lesson.data if isinstance(lesson.data, dict) else {}
+ query = trigger_title(data.get("what") or lesson.title, lessons_svc.lesson_trigger(lesson))
+ found = await semantic_search_notes(
+ user_id, query, exclude_ids={int(lesson.id)}, limit=_CONVERGENCE_FETCH,
+ threshold=CONVERGENCE_THRESHOLD, note_type=(lessons_svc.LESSON_NOTE_TYPE,),
+ include_global_kinds=True, scope="browse",
+ )
+ answered = await _no_rule_ids([int(n.id) for _s, n in found])
+
+ def member(note) -> dict:
+ return {
+ "id": int(note.id), "title": note.title,
+ "sources": lessons_svc.lesson_sources(note), "project_id": note.project_id,
+ }
+
+ return convergence_group(
+ [member(lesson)] + [member(n) for _s, n in found if int(n.id) in answered]
+ )
diff --git a/tests/conftest.py b/tests/conftest.py
index 3ffd59b..06e0fe6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -160,7 +160,9 @@ def _no_lesson_rule_links(request):
for the database to count the pair. And the via-lesson rule step (#4633)
is stubbed to "no lesson brought a rule": all three rule arms run it, and
its first act is a database read. tests/test_rule_via_lesson.py binds the
- real function at import time, before this patch runs.
+ real function at import time, before this patch runs. The convergence
+ check a "no rule fits" answer runs (#4634) is stubbed to "no group" for
+ the same reason; tests/test_lesson_convergence.py binds the real one.
Skipped for integration tests, which exercise the real links against
Postgres (tests/test_integration_lesson_rule_links.py).
@@ -173,7 +175,8 @@ def _no_lesson_rule_links(request):
patch("scribe.services.lesson_rules.rule_candidates", AsyncMock(return_value=[])), \
patch("scribe.services.lesson_rules.co_surfaced", AsyncMock(return_value="")), \
patch("scribe.services.plugin_context._rules_via_lessons",
- AsyncMock(return_value=([], []))):
+ AsyncMock(return_value=([], []))), \
+ patch("scribe.services.lesson_rules.convergence_for", AsyncMock(return_value=None)):
yield
diff --git a/tests/test_integration_lesson_rule_links.py b/tests/test_integration_lesson_rule_links.py
index 6ca8b6b..fab8bb6 100644
--- a/tests/test_integration_lesson_rule_links.py
+++ b/tests/test_integration_lesson_rule_links.py
@@ -347,3 +347,15 @@ async def test_a_project_rule_stays_in_its_project_even_through_a_lesson(world):
async def test_a_stranger_reaches_no_rule_through_someone_elses_link(world):
await links_svc.set_lesson_rules(world["owner"], world["lesson"], [world["r1"]])
assert await links_svc.confirmed_lessons(world["stranger"]) == set()
+
+
+# ── #4634: which lessons count toward convergence ───────────────────────────
+
+
+async def test_only_answered_lessons_are_no_rule_lessons(world):
+ owner = world["owner"]
+ other = await lessons_svc.create_lesson(
+ owner, what="Another overrun", when_to_apply="a deploy job overran",
+ )
+ await links_svc.set_no_rule(owner, world["lesson"], "specific to this host")
+ assert await links_svc._no_rule_ids([world["lesson"], other.id]) == {world["lesson"]}
diff --git a/tests/test_lesson_convergence.py b/tests/test_lesson_convergence.py
new file mode 100644
index 0000000..5d0f444
--- /dev/null
+++ b/tests/test_lesson_convergence.py
@@ -0,0 +1,100 @@
+"""Convergence named at the write (milestone 440, #4634).
+
+When a lesson is answered "no rule fits", the response looks for other
+no-rule lessons in the same situation and, once there are enough, names the
+group and the rule it may be missing. The bar is pinned here as a pure
+function; the search around it with the database and embedder stubbed; the
+doors by what they return.
+"""
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from scribe.mcp._context import _user_id_ctx
+from scribe.mcp.tools import lessons as lesson_tools
+from scribe.services import lesson_rules as links_svc
+from scribe.services import lessons as lessons_svc
+
+# Bound before conftest's autouse stub replaces the module attribute.
+_REAL = links_svc.convergence_for
+
+
+def _m(lid, sources=(), project=None, title=None):
+ return {"id": lid, "title": title or f"lesson {lid}", "sources": list(sources),
+ "project_id": project}
+
+
+def test_below_the_group_size_nothing_is_named():
+ assert links_svc.convergence_group([_m(1), _m(2)]) is None
+
+
+def test_a_group_of_distinct_lessons_names_its_members_and_the_next_step():
+ group = links_svc.convergence_group([_m(1, [10], 2), _m(2, [11], 5), _m(3, [], 2)])
+ assert [m["id"] for m in group["lessons"]] == [1, 2, 3]
+ assert group["incidents"] == [10, 11]
+ assert group["projects"] == [2, 5]
+ assert "create_rule" in group["hint"] and "update_lesson" in group["hint"]
+ assert "#1 “lesson 1”" in group["hint"]
+
+
+def test_one_broad_lesson_cannot_reach_the_bar_alone():
+ """Many incidents behind ONE lesson is still one judgment: incidents never
+ stand in for lessons, and a repeated id is one member."""
+ broad = _m(1, [10, 11, 12, 13, 14])
+ assert links_svc.convergence_group([broad]) is None
+ assert links_svc.convergence_group([broad, broad, broad]) is None
+
+
+def test_one_incident_written_up_three_times_is_not_a_recurring_situation():
+ same = [_m(1, [10]), _m(2, [10]), _m(3, [10])]
+ assert links_svc.convergence_group(same) is None
+
+
+def _note(lid, *, title="t", project=None, sources=()):
+ data = {"what": title, "when_to_apply": "a CI run overran"}
+ if sources:
+ data["taught_by"] = list(sources)
+ return SimpleNamespace(
+ id=lid, title=title, note_type="lesson", body="", data=data,
+ project_id=project, arose_from_id=None, tags=[],
+ created_at=None, updated_at=None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_only_lessons_answered_no_rule_join_the_group():
+ found = [(0.8, _note(2, sources=[11])), (0.7, _note(3, sources=[12])),
+ (0.7, _note(4, sources=[13]))]
+ with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_note(1, sources=[10]))), \
+ patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=found)), \
+ patch.object(links_svc, "_no_rule_ids", AsyncMock(return_value={2})):
+ assert await _REAL(7, 1) is None # only #2 answered: a pair
+ with patch.object(lessons_svc, "get_lesson", AsyncMock(return_value=_note(1, sources=[10]))), \
+ patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=found)), \
+ patch.object(links_svc, "_no_rule_ids", AsyncMock(return_value={2, 4})):
+ group = await _REAL(7, 1)
+ assert [m["id"] for m in group["lessons"]] == [1, 2, 4]
+
+
+@pytest.mark.asyncio
+async def test_a_failed_search_names_nothing_and_raises_nothing():
+ with patch.object(lessons_svc, "get_lesson", AsyncMock(side_effect=RuntimeError("db down"))):
+ assert await _REAL(7, 1) is None
+
+
+@pytest.mark.asyncio
+async def test_the_door_carries_convergence_only_with_a_no_rule_answer():
+ _user_id_ctx.set(7)
+ group = {"lessons": [], "incidents": [], "projects": [], "hint": "h"}
+ named = AsyncMock(return_value=group)
+ with patch.object(lessons_svc, "update_lesson", AsyncMock(return_value=_note(41))), \
+ patch.object(links_svc, "set_no_rule", AsyncMock()), \
+ patch.object(links_svc, "convergence_for", named):
+ out = await lesson_tools.update_lesson(lesson_id=41, no_rule="stands alone")
+ assert out["convergence"] == group
+ out = await lesson_tools.update_lesson(lesson_id=41, what="reworded")
+ assert "convergence" not in out
+ assert named.await_count == 1
--
2.54.0
From 75cefe60e4055c1d6af5e317926067df73aaac78 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 1 Oct 2026 15:46:27 -0400
Subject: [PATCH 4/4] feat(lessons): both records show the link in the web UI
(milestone 440 step 7, #4635)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The lesson page gains an "Instance of" panel that holds one of three answers.
Unjudged is the fall-through, so it is stated rather than left blank:
- the rule(s) it was judged an instance of;
- "No rule — ";
- "Not yet judged".
Suggested links show what they rest on (distinct situations, and projects
when more than one), with Confirm / Not an instance for a reader who can
write. Rejected links stay listed with their reason.
The rule slide-over lists the lessons that are instances of it, plus the
suggestions waiting on a judgment. Each entry links through to the other
record, and kind and state wear the existing .rule-chip.
The write check moves to utils/permission.ts. The copy on the snippet page
looked for "edit", which the server never sends, so shared editors saw a
read-only page (#4640 "The snippet page hid its edit controls from shared
editors"). Guards pin the client's unions and write levels to the
service's, the model's and access.py's own values.
Co-Authored-By: Claude Opus 5.5
---
frontend/src/api/lessons.ts | 48 +++++-
frontend/src/api/rulebooks.ts | 5 +
.../components/rules/RuleEditorSlideOver.vue | 28 ++++
frontend/src/utils/permission.ts | 12 ++
frontend/src/views/LessonDetailView.vue | 158 +++++++++++++++++-
frontend/src/views/SnippetDetailView.vue | 6 +-
tests/test_lesson_rule_link_ui.py | 117 +++++++++++++
7 files changed, 368 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/utils/permission.ts
create mode 100644 tests/test_lesson_rule_link_ui.py
diff --git a/frontend/src/api/lessons.ts b/frontend/src/api/lessons.ts
index 4b66ee8..d3d7d02 100644
--- a/frontend/src/api/lessons.ts
+++ b/frontend/src/api/lessons.ts
@@ -1,6 +1,34 @@
import type { RecordUsage } from "@/types/usage";
-import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
+import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
+import type { RuleKind } from "@/api/rulebooks";
+
+/** Where a lesson stands on "which rule is this an instance of?"
+ * (`services/lesson_rules.py`: LINKED / NO_RULE / UNJUDGED). Three answers,
+ * not two: "looked at and found to stand alone" and "nobody has looked" are
+ * different facts, and the second is the one worth acting on. A rejected link
+ * alone leaves a lesson unjudged — "not that rule" does not say whether
+ * another one fits. */
+export type RuleJudgment = "linked" | "no_rule" | "unjudged";
+
+/** A link's state (`models/lesson_rule_link.py` LINK_STATES). */
+export type LinkState = "suggested" | "confirmed" | "rejected";
+
+/** One link from a lesson to a rule, in the reader's view: only rules the
+ * reader owns are listed (`rules_for_lessons`). */
+export interface LessonRuleLink {
+ id: number;
+ title: string;
+ kind: RuleKind;
+ /** `suggested` carries nothing in retrieval until it is judged; only
+ * `confirmed` changes what surfaces; `rejected` is kept so the pair is
+ * never proposed again. */
+ state: LinkState;
+ /** Why it was confirmed or rejected. */
+ note: string;
+ /** What a suggestion rests on — sent on suggested links only. */
+ evidence?: { situations: number; projects: number; co_surfaced: number };
+}
/** A lesson: a transferable insight, retrievable by the SITUATION it applies
* to rather than by its topic.
@@ -51,6 +79,13 @@ export interface Lesson {
updated_at: string | null;
systems?: { id: number; name: string }[];
usage?: RecordUsage;
+ /** The rules this lesson points at, confirmed first, then suggested, then
+ * rejected. Absent (with `rule_judgment`) when the links could not be read
+ * — which is "not attached", never "no rule". */
+ rules?: LessonRuleLink[];
+ rule_judgment?: RuleJudgment;
+ /** The "no rule fits" answer, present when that is the judgment. */
+ no_rule?: { why: string; judged_at: string | null };
/** Set when another user owns this record. */
shared?: boolean;
owner?: string | null;
@@ -128,6 +163,17 @@ export function updateLesson(
return apiPatch(`/api/lessons/${id}`, payload);
}
+/** Confirm or reject one lesson→rule link. Confirming also clears a "no rule
+ * fits" answer: the two cannot both be the current answer. */
+export function judgeLessonLink(
+ lessonId: number,
+ ruleId: number,
+ verdict: "confirm" | "reject",
+ note = "",
+): Promise {
+ return apiPut(`/api/lessons/${lessonId}/rules/${ruleId}`, { verdict, note });
+}
+
/** Trash, not erase — recoverable. `apiDelete` discards the body, which is the
* established shape here (snippets delete the same way): the batch id is in
* the response, but no caller has needed it and inventing a second delete
diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts
index 331bd34..16c3c25 100644
--- a/frontend/src/api/rulebooks.ts
+++ b/frontend/src/api/rulebooks.ts
@@ -1,6 +1,7 @@
import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
+import type { LinkState } from "@/api/lessons";
/** How a rule reaches a session (milestone 307). */
@@ -82,6 +83,10 @@ export interface Rule {
/** Present only when the rule has them (the server omits empty keys). */
systems?: { id: number; name: string }[];
relations?: RuleRelation[];
+ /** The lessons that point at this rule (milestone 440) — the concrete
+ * situations judged instances of it, plus any suggested and awaiting a
+ * judgment. Readable lessons only; omitted when there are none. */
+ lessons?: { id: number; title: string; state: LinkState; note: string }[];
}
/**
diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue
index 19ebc16..d33b7f9 100644
--- a/frontend/src/components/rules/RuleEditorSlideOver.vue
+++ b/frontend/src/components/rules/RuleEditorSlideOver.vue
@@ -27,6 +27,14 @@ const expiresWhen = ref("");
const relations = computed(() => store.currentRule?.relations ?? []);
+// The lessons that point at this rule (milestone 440): confirmed instances
+// first, then suggestions waiting on a judgment. A rejected lesson does not
+// point at the rule, so it is not listed here — it stays readable on the
+// lesson, where the judgment was made.
+const lessons = computed(() =>
+ (store.currentRule?.lessons ?? []).filter((l) => l.state !== "rejected"),
+);
+
// The label a reader needs to judge an edge, not the stored token.
const RELATION_LABEL: Record = {
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
@@ -279,6 +287,21 @@ watch(() => props.ruleId, load);
+
+
Lessons that are instances of it
+
+
+ {{ l.title }}
+ suggested
+ {{ l.note }}
+
+
+
+ The situations that keep proving this rule. A confirmed lesson brings the rule along
+ when it surfaces; a suggested one waits for a judgment on the lesson's page.
+