feat(lessons): a lesson names the rule it is an instance of — lesson_rule_links (milestone 440 step 1, #4630)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m13s
CI & Build / Build & push image (push) Skipped

The link between a lesson (one concrete situation) and the rule that governs
it, with the operator's soft-then-hard design built into its state:
suggested while evidence accumulates, confirmed or rejected once judged. Only
confirmed will carry a rule in retrieval (#4633); rejected is kept so the pair
is never proposed again.

- models/lesson_rule_link.py + migration 0111: one row per (lesson, rule),
  CASCADE on both ends, indexed both ways, CHECK on state (rule 36), evidence
  JSONB and judged_at.
- services/lesson_rules.py: require_rules (validated before any write, so
  a bad id leaves nothing half-linked), set_lesson_rules (set-semantics;
  a dropped rule becomes rejected, not forgotten), judge_link, and the two
  reads. ACL: write on the lesson (share-aware), ownership of the rule; a
  reader sees only rules they own. Decorations are fail-open (#4286).
- MCP: create_lesson / update_lesson take rule_ids; get/create/update return
  `rules`; new judge_lesson_link tool. REST: the same on /api/lessons plus
  PUT /api/lessons/<id>/rules/<rule_id>. Rules: rule_detail carries `lessons`.
- Backup v18: export (full and user-scoped, both ends in scope), builder,
  importer; both column guards register the table.
- Tests: integration (states, set-semantics, judge, ACL all-or-nothing,
  cascade both ways, CHECK, one row per pair); unit (door wiring, judge
  registered, migration/model state agreement, backup skip and unjudged
  stays unjudged). conftest stubs the decorations for unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 12:38:02 -04:00
co-authored by Claude Opus 5.5
parent 2e4c2d9493
commit 41e4fbaba1
12 changed files with 875 additions and 6 deletions
+21
View File
@@ -142,6 +142,27 @@ def _no_task_log_arm():
yield
@pytest.fixture(autouse=True)
def _no_lesson_rule_links(request):
"""Stub the lesson↔rule link decorations (milestone 440).
Every door that returns a lesson or a rule now attaches its links, and the
read is a real database call — so every unit test that opens either would
otherwise reach for the fake DATABASE_URL to learn that nothing is linked.
Both decorations are fail-open, so the cost would be a slow failed connect
per test rather than a failure, which is worse: it would never be noticed.
Skipped for integration tests, which exercise the real links against
Postgres (tests/test_integration_lesson_rule_links.py).
"""
if request.node.get_closest_marker("integration"):
yield
return
with patch("scribe.services.lesson_rules.attach_lesson_rules", AsyncMock()), \
patch("scribe.services.lesson_rules.attach_rule_lessons", AsyncMock()):
yield
@pytest.fixture(autouse=True)
def _no_rule_arm():
"""Stub the write-path hint's standing-RULES arm (milestone 307).
+162
View File
@@ -0,0 +1,162 @@
"""Real-Postgres tests for lesson → rule links (milestone 440 step 1, #4630).
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
be linked (and nothing is half-written when one id is bad), and that each side
reads only what its reader may see.
"""
import uuid
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from scribe.models import async_session
from scribe.models.lesson_rule_link import LessonRuleLink
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.rulebook import Rule
from scribe.services import lesson_rules as links_svc
from scribe.services import lessons as lessons_svc
from scribe.services import rulebooks as rulebooks_svc
from tests.helpers import ensure_user
pytestmark = [
pytest.mark.integration,
pytest.mark.usefixtures("_dispose_engine", "_no_embedding"),
]
@pytest.fixture(autouse=True)
def _no_reindex():
"""Rule writes detach an embedding refresh that outlives the test's loop."""
with patch("scribe.services.rulebooks._refresh_rule_embedding", MagicMock()):
yield
@pytest_asyncio.fixture
async def world():
"""An owner with a lesson and two rules; a stranger with a rule of their own."""
tag = uuid.uuid4().hex[:8]
async with async_session() as s:
owner = await ensure_user(s, f"lrl_owner_{tag}")
stranger = await ensure_user(s, f"lrl_stranger_{tag}")
mine = Project(user_id=owner.id, title="Mine")
theirs = Project(user_id=stranger.id, title="Theirs")
s.add_all([mine, theirs])
await s.flush()
ids = {"owner": owner.id, "stranger": stranger.id,
"mine": mine.id, "theirs": theirs.id}
await s.commit()
owner = ids["owner"]
r1 = await rulebooks_svc.create_project_rule(
ids["mine"], owner, "Read the job log first", "Before waiting longer.",
when_to_apply="a CI run has overrun its usual duration",
)
r2 = await rulebooks_svc.create_project_rule(
ids["mine"], owner, "Probe the system itself", "Not a proxy for it.",
when_to_apply="about to state what version is deployed",
)
foreign = await rulebooks_svc.create_project_rule(
ids["theirs"], ids["stranger"], "Their rule", "Not yours.",
when_to_apply="something only they do",
)
lesson = await lessons_svc.create_lesson(
owner, what="An overrun run usually failed early",
when_to_apply="a CI run is still in_progress far past its usual time",
insight="Read the log; the failure is often minutes old.",
project_id=ids["mine"],
)
ids.update(r1=r1.id, r2=r2.id, foreign=foreign.id, lesson=lesson.id)
return ids
async def _states(lesson_id: int) -> dict[int, str]:
async with async_session() as s:
rows = (await s.execute(
select(LessonRuleLink).where(LessonRuleLink.lesson_id == lesson_id)
)).scalars().all()
return {r.rule_id: r.state for r in rows}
async def test_naming_rules_confirms_them_and_both_sides_read_the_link(world):
owner = world["owner"]
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"], world["r2"]])
assert await _states(world["lesson"]) == {world["r1"]: "confirmed", world["r2"]: "confirmed"}
rules = (await links_svc.rules_for_lessons(owner, [world["lesson"]]))[world["lesson"]]
assert {r["id"] for r in rules} == {world["r1"], world["r2"]}
lessons = await links_svc.lessons_for_rule(owner, world["r1"])
assert [(l["id"], l["state"]) for l in lessons] == [(world["lesson"], "confirmed")]
async def test_a_rule_left_out_of_the_set_is_rejected_not_forgotten(world):
owner = world["owner"]
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"], world["r2"]])
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"]])
assert await _states(world["lesson"]) == {world["r1"]: "confirmed", world["r2"]: "rejected"}
rules = (await links_svc.rules_for_lessons(owner, [world["lesson"]]))[world["lesson"]]
# Confirmed reads first; the rejection keeps its reason.
assert [r["state"] for r in rules] == ["confirmed", "rejected"]
assert rules[1]["note"] == links_svc._REMOVED_NOTE
async def test_judge_moves_a_pair_both_ways_and_refuses_a_nonsense_verdict(world):
owner = world["owner"]
out = await links_svc.judge_link(owner, world["lesson"], world["r1"], "reject", "different failure")
assert (out["state"], out["note"]) == ("rejected", "different failure")
assert out["judged_at"] is not None
out = await links_svc.judge_link(owner, world["lesson"], world["r1"], "confirm")
assert out["state"] == "confirmed"
with pytest.raises(ValueError):
await links_svc.judge_link(owner, world["lesson"], world["r1"], "maybe")
async def test_a_rule_the_caller_cannot_read_links_nothing_at_all(world):
"""All-or-nothing: one unreadable id refuses the whole set, so the readable
one beside it is not linked either."""
with pytest.raises(ValueError):
await links_svc.set_lesson_rules(
world["owner"], world["lesson"], [world["r1"], world["foreign"]],
)
assert await _states(world["lesson"]) == {}
async def test_a_stranger_cannot_link_someone_elses_lesson(world):
with pytest.raises((ValueError, PermissionError)):
await links_svc.set_lesson_rules(world["stranger"], world["lesson"], [world["foreign"]])
assert await _states(world["lesson"]) == {}
async def test_deleting_either_end_takes_the_link_with_it(world):
owner = world["owner"]
await links_svc.set_lesson_rules(owner, world["lesson"], [world["r1"], world["r2"]])
async with async_session() as s:
await s.execute(delete(Rule).where(Rule.id == world["r1"]))
await s.commit()
assert await _states(world["lesson"]) == {world["r2"]: "confirmed"}
async with async_session() as s:
await s.execute(delete(Note).where(Note.id == world["lesson"]))
await s.commit()
assert await _states(world["lesson"]) == {}
async def test_the_check_holds_the_three_states(world):
async with async_session() as s:
s.add(LessonRuleLink(lesson_id=world["lesson"], rule_id=world["r1"], state="maybe"))
with pytest.raises(IntegrityError):
await s.commit()
async def test_one_row_per_pair(world):
async with async_session() as s:
s.add_all([
LessonRuleLink(lesson_id=world["lesson"], rule_id=world["r1"], state="suggested"),
LessonRuleLink(lesson_id=world["lesson"], rule_id=world["r1"], state="confirmed"),
])
with pytest.raises(IntegrityError):
await s.commit()
+145
View File
@@ -0,0 +1,145 @@
"""The lesson → rule link at its doors (milestone 440 step 1, #4630).
The link's own behaviour — states, cascade, ACL — is tested against Postgres in
tests/test_integration_lesson_rule_links.py. These pin the wiring a mock CAN
see: that a bad rule id stops a lesson create before anything is written, that
the doors pass the set through with the semantics their docstrings promise,
that the judge tool is registered, that backup skips a link it cannot map, and
that the migration and the model agree about the states.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
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.models.lesson_rule_link import LINK_STATES
from scribe.services import backup
from scribe.services import lesson_rules as links_svc
from scribe.services import lessons as lessons_svc
TRIGGER = "a CI run is still in_progress far past its usual time"
def _stub_note(**kw):
base = dict(
id=41, title="t", body="b", tags=[], project_id=None,
note_type="lesson", data={}, arose_from_id=None,
created_at=None, updated_at=None,
)
base.update(kw)
return SimpleNamespace(**base)
def _create_patches(created):
return (
patch.object(lessons_svc, "create_lesson", created),
patch("scribe.mcp.tools.lessons.dedup_svc.find_duplicate_note",
AsyncMock(return_value=None)),
patch("scribe.mcp.tools.lessons.systems_tools.attach_systems", AsyncMock()),
)
@pytest.mark.asyncio
async def test_an_unreadable_rule_stops_the_lesson_before_it_is_written():
"""All-or-nothing at the door: the rule ids are validated BEFORE the
lesson exists, so a bad id cannot leave a lesson saved and unlinked."""
_user_id_ctx.set(7)
created = AsyncMock(return_value=_stub_note())
p1, p2, p3 = _create_patches(created)
with p1, p2, p3, patch.object(
links_svc, "require_rules", AsyncMock(side_effect=ValueError("rule(s) [9] not found")),
):
with pytest.raises(ValueError):
await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER, rule_ids=[9])
created.assert_not_awaited()
@pytest.mark.asyncio
async def test_create_links_the_named_rules_to_the_new_lesson():
_user_id_ctx.set(7)
created = AsyncMock(return_value=_stub_note(id=41))
linked = AsyncMock()
p1, p2, p3 = _create_patches(created)
with p1, p2, p3, \
patch.object(links_svc, "require_rules", AsyncMock(return_value=[5, 6])), \
patch.object(links_svc, "set_lesson_rules", linked):
await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER, rule_ids=[5, 6])
linked.assert_awaited_once()
assert linked.await_args.args[1:] == (41, [5, 6])
@pytest.mark.asyncio
async def test_create_without_rules_writes_no_link():
_user_id_ctx.set(7)
linked = AsyncMock()
p1, p2, p3 = _create_patches(AsyncMock(return_value=_stub_note()))
with p1, p2, p3, patch.object(links_svc, "set_lesson_rules", linked):
await lesson_tools.create_lesson(what="x", when_to_apply=TRIGGER)
linked.assert_not_awaited()
@pytest.mark.parametrize("rule_ids, expect_call", [(None, False), ([], True), ([5], True)])
@pytest.mark.asyncio
async def test_update_leaves_links_alone_on_none_and_replaces_on_a_list(rule_ids, expect_call):
"""None is "unchanged"; a list — including [] — is the full new set, which
the service turns into confirmations and rejections."""
_user_id_ctx.set(7)
linked = AsyncMock()
with patch.object(lessons_svc, "update_lesson", AsyncMock(return_value=_stub_note())), \
patch.object(links_svc, "require_rules", AsyncMock(side_effect=lambda uid, ids: list(ids))), \
patch.object(links_svc, "set_lesson_rules", linked):
await lesson_tools.update_lesson(lesson_id=41, rule_ids=rule_ids)
assert linked.await_count == (1 if expect_call else 0)
if expect_call:
assert linked.await_args.args[1:] == (41, rule_ids)
def test_the_judge_tool_is_registered():
names = []
fake = SimpleNamespace(tool=lambda name: (lambda fn: names.append(name) or fn))
lesson_tools.register(fake)
assert "judge_lesson_link" in names
def test_verdicts_name_real_states():
assert set(links_svc.VERDICTS.values()) <= set(LINK_STATES)
def test_the_migration_check_and_the_model_agree_on_the_states():
"""Rule 36's drift, guarded: the CHECK is written from the migration's
tuple and the code from the model's, so they must be the same tuple."""
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0111_lesson_rule_links.py"
spec = importlib.util.spec_from_file_location("m0111", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert tuple(module._STATES) == tuple(LINK_STATES)
@pytest.mark.parametrize("lesson_mapped, rule_mapped", [(False, True), (True, False)])
def test_backup_skips_a_link_whose_end_did_not_restore(lesson_mapped, rule_mapped):
maps = backup._Maps()
if lesson_mapped:
maps.notes[10] = 110
if rule_mapped:
maps.rules[20] = 120
row = {"lesson_id": 10, "rule_id": 20, "state": "confirmed"}
assert backup._build_lesson_rule_link(row, maps) is None
def test_backup_keeps_an_unjudged_link_unjudged():
"""A suggested link was never judged; restoring it with the restore's time
in judged_at would say it was."""
maps = backup._Maps()
maps.notes[10] = 110
maps.rules[20] = 120
built = backup._build_lesson_rule_link(
{"lesson_id": 10, "rule_id": 20, "state": "suggested", "judged_at": None}, maps,
)
assert (built.lesson_id, built.rule_id, built.state) == (110, 120, "suggested")
assert built.judged_at is None
+7 -2
View File
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)"""
assert backup.BACKUP_VERSION == 17
assert backup.BACKUP_VERSION == 18
def _exportable_note(**over):
@@ -131,6 +131,7 @@ def test_a_repo_binding_carries_the_branch_its_ledger_follows():
def _column_guard_targets():
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.lesson_rule_link import LessonRuleLink
from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.milestone import Milestone
from scribe.models.note import Note
@@ -167,6 +168,7 @@ def _column_guard_targets():
"record_systems": (RecordSystem, backup._record_system_rows),
"note_supersessions": (NoteSupersession, backup._note_supersession_rows),
"rule_relations": (RuleRelation, backup._rule_relation_rows),
"lesson_rule_links": (LessonRuleLink, backup._lesson_rule_link_rows),
"note_usage_events": (NoteUsageEvent, backup._usage_event_rows),
"rule_usage_events": (RuleUsageEvent, backup._rule_usage_event_rows),
"retrieval_tuning_events": (
@@ -283,6 +285,7 @@ def _import_guard_targets():
"record_systems": backup._build_record_system,
"note_supersessions": backup._build_note_supersession,
"rule_relations": backup._build_rule_relation,
"lesson_rule_links": backup._build_lesson_rule_link,
"note_usage_events": backup._build_usage_event,
"rule_usage_events": backup._build_rule_usage_event,
"retrieval_tuning_events": backup._build_retrieval_tuning_event,
@@ -534,7 +537,9 @@ async def test_export_full_backup_contains_every_declared_section():
"note_supersessions", "code_shapes", "code_shape_events",
"code_shape_uses",
# v16: the reasons beside the settings they explain.
"retrieval_tuning_events"):
"retrieval_tuning_events",
# v18: which rule each lesson is an instance of.
"lesson_rule_links"):
assert key in out, f"missing export section: {key}"
assert out[key] == []