diff --git a/alembic/versions/0111_lesson_rule_links.py b/alembic/versions/0111_lesson_rule_links.py new file mode 100644 index 0000000..96a2b35 --- /dev/null +++ b/alembic/versions/0111_lesson_rule_links.py @@ -0,0 +1,54 @@ +"""lesson_rule_links — a lesson points at the rule it is an instance of +(milestone 440 step 1, #4196) + +Revision ID: 0111 +Revises: 0110 +Create Date: 2026-10-01 + +One row per (lesson, rule) pair with a state: `suggested` while evidence +accumulates, `confirmed` or `rejected` once a judgment is made. Only a +confirmed link changes what surfaces; a rejected one is kept so the pair is not +proposed again. CASCADE on both ends — a link to a record that no longer exists +says nothing. No backfill: no lesson has ever been linked, and inventing a link +would assert a judgment nobody made. +""" +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from alembic import op + +revision = "0111" +down_revision = "0110" +branch_labels = None +depends_on = None + +# One place, so the CHECK and the model's LINK_STATES cannot drift (rule 36: +# a new value later means DROP + ADD CONSTRAINT in the same migration). +_STATES = ("suggested", "confirmed", "rejected") + + +def upgrade() -> None: + op.create_table( + "lesson_rule_links", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column("lesson_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False), + sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False), + sa.Column("state", sa.Text(), nullable=False, server_default="suggested"), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("evidence", postgresql.JSONB(), nullable=True), + sa.Column("judged_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.UniqueConstraint("lesson_id", "rule_id", name="uq_lesson_rule_links_pair"), + ) + op.create_check_constraint( + "ck_lesson_rule_links_state", "lesson_rule_links", + "state IN (" + ", ".join(f"'{s}'" for s in _STATES) + ")", + ) + op.create_index("ix_lesson_rule_links_lesson_id", "lesson_rule_links", ["lesson_id"]) + op.create_index("ix_lesson_rule_links_rule_id", "lesson_rule_links", ["rule_id"]) + + +def downgrade() -> None: + op.drop_index("ix_lesson_rule_links_rule_id", table_name="lesson_rule_links") + op.drop_index("ix_lesson_rule_links_lesson_id", table_name="lesson_rule_links") + op.drop_constraint("ck_lesson_rule_links_state", "lesson_rule_links", type_="check") + op.drop_table("lesson_rule_links") diff --git a/src/scribe/mcp/tools/lessons.py b/src/scribe/mcp/tools/lessons.py index 0a35b1f..9e11293 100644 --- a/src/scribe/mcp/tools/lessons.py +++ b/src/scribe/mcp/tools/lessons.py @@ -13,6 +13,7 @@ from scribe.mcp._context import current_user_id from scribe.services import access as access_svc from scribe.services import dedup as dedup_svc from scribe.services import knowledge as knowledge_svc +from scribe.services import lesson_rules as lesson_rules_svc from scribe.services import lessons as lessons_svc from scribe.services import systems as systems_svc from scribe.services import trash as trash_svc @@ -89,6 +90,7 @@ async def create_lesson( tags: list[str] | None = None, project_id: int = 0, system_ids: list[int] | None = None, + rule_ids: list[int] | None = None, force: bool = False, ) -> dict: """Record something you LEARNED, so a later session meets it at the moment @@ -145,6 +147,12 @@ async def create_lesson( reach: a lesson is retrievable from every project (that is the point of the kind). 0 = none. system_ids: Systems (subsystems/areas) to file it under. + rule_ids: The rule(s) or preference(s) this lesson is an instance of — + the binding choice its situation falls under. A lesson never + becomes a rule; it points at the one that governs it, and the rule + is then reachable through the situation the lesson describes + (milestone 440). Naming a rule here confirms the link. Leave it + empty when no rule governs this situation. force: Create even if a near-duplicate exists. Returns the created lesson. On a near-duplicate, returns the existing id @@ -161,6 +169,9 @@ async def create_lesson( ) sources = lessons_svc.normalize_sources(learned_from) + # Validated before anything is written, so a lesson naming a rule the + # caller cannot read fails whole rather than saving half-linked. + linked = await lesson_rules_svc.require_rules(uid, rule_ids) title, body = lessons_svc.lesson_document( what, when_to_apply, insight, sources, ) @@ -179,8 +190,11 @@ async def create_lesson( ) if system_ids: await systems_svc.set_record_systems(uid, note.id, system_ids) + if linked: + await lesson_rules_svc.set_lesson_rules(uid, note.id, linked) data = _to_dict(note) await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) + await lesson_rules_svc.attach_lesson_rules(uid, [data]) return data @@ -214,6 +228,7 @@ async def get_lesson(lesson_id: int, project_id: int = 0) -> dict: # one that was true when it asked — otherwise every first read of a lesson # reports a pull that is its own. await attach_usage([out]) + await lesson_rules_svc.attach_lesson_rules(uid, [out]) record_pulled( user_id=uid, note_id=int(note.id), source="mcp_get_lesson", project_id=project_id, @@ -229,6 +244,7 @@ async def update_lesson( learned_from: list[int] | None = None, tags: list[str] | None = None, system_ids: list[int] | None = None, + rule_ids: list[int] | None = None, ) -> dict: """Update a lesson. Empty fields are left unchanged. @@ -258,8 +274,16 @@ async def update_lesson( when that argument gets dropped (#4249). A System tag is how `list_system_records` gathers an area's pile, so an untagged lesson is reachable by search and by nothing else. + rule_ids: Replace the rule(s) this lesson is an instance of. None + leaves unchanged; pass the FULL list. A rule that was linked and + is left out is recorded as REJECTED — "not an instance of this + one" — so the pair is not proposed again; `[]` rejects them all. """ uid = current_user_id() + linked = ( + await lesson_rules_svc.require_rules(uid, rule_ids) + if rule_ids is not None else None + ) note = await lessons_svc.update_lesson( uid, lesson_id, what=what or None, @@ -276,7 +300,30 @@ async def update_lesson( if system_ids is not None: await systems_svc.set_record_systems(uid, lesson_id, system_ids) note = await lessons_svc.get_lesson(uid, lesson_id) or note - return _to_dict(note) + if linked is not None: + await lesson_rules_svc.set_lesson_rules(uid, lesson_id, linked) + out = _to_dict(note) + await lesson_rules_svc.attach_lesson_rules(uid, [out]) + return out + + +async def judge_lesson_link( + lesson_id: int, rule_id: int, verdict: str, note: str = "", +) -> dict: + """Say whether a lesson is an instance of a rule: `verdict` is "confirm" + or "reject". + + Reach for this when Scribe proposes a pair — a lesson and a rule that keep + arriving together in different situations — or whenever you are reading a + lesson and recognise the rule it falls under. Confirming makes the rule + reachable through the situation the lesson describes; rejecting records + that it is not, so the pair is not proposed again. `note` is the why, and + the next reader judges the link by it. + + Returns the link: {lesson_id, rule_id, state, note, evidence, judged_at}. + """ + uid = current_user_id() + return await lesson_rules_svc.judge_link(uid, lesson_id, rule_id, verdict, note) async def delete_lesson(lesson_id: int) -> dict: @@ -312,5 +359,5 @@ async def delete_lesson(lesson_id: int) -> dict: def register(mcp) -> None: for fn in (list_lessons, create_lesson, get_lesson, update_lesson, - delete_lesson): + delete_lesson, judge_lesson_link): mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index abdcec7..4af89b9 100644 --- a/src/scribe/models/__init__.py +++ b/src/scribe/models/__init__.py @@ -77,6 +77,8 @@ from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401 from scribe.models.rulebook import ( # noqa: E402, F401 Rulebook, RulebookTopic, Rule, RuleRelation, rule_systems, ) +# After notes and rules: it foreign-keys both (milestone 440). +from scribe.models.lesson_rule_link import LessonRuleLink # noqa: E402, F401 from scribe.models.repo_binding import RepoBinding # noqa: E402, F401 from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401 from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401 diff --git a/src/scribe/models/lesson_rule_link.py b/src/scribe/models/lesson_rule_link.py new file mode 100644 index 0000000..f9177d7 --- /dev/null +++ b/src/scribe/models/lesson_rule_link.py @@ -0,0 +1,77 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from scribe.models import Base +from scribe.models.base import CreatedAtMixin, iso + +SUGGESTED = "suggested" +CONFIRMED = "confirmed" +REJECTED = "rejected" +# CHECK ck_lesson_rule_links_state (migration 0111, rule 36). +LINK_STATES = (SUGGESTED, CONFIRMED, REJECTED) + + +class LessonRuleLink(Base, CreatedAtMixin): + """A lesson says it is an instance of a rule (milestone 440, #4196). + + A lesson records one concrete situation; a rule records the binding choice + for a class of them. The link lets the rule be reached through the + situations that keep proving it, and lets a situation with lessons and no + rule be noticed. A lesson never BECOMES a rule — it points at one. + + ONE ROW PER PAIR, WITH A STATE, because the operator's design forms a link + in two stages ("a sort of soft and then hard link once it's been proven"): + + - ``suggested`` — evidence is accumulating that the two belong together + (they keep arriving in the same request, in distinct situations). It + carries nothing in retrieval: a suggested link that brought its rule + along would manufacture the co-surfacing it counts, and prove itself. + - ``confirmed`` — a judgment said the lesson is an instance of the rule. + The only state that changes what surfaces. + - ``rejected`` — a judgment said it is not. Kept, evidence and all, so the + pair is never proposed again and the reason stays readable. + + A table rather than a list on the lesson's `data`: the link needs a foreign + key on both ends (a deleted rule must not leave a lesson pointing at + nothing), a reverse index (a rule lists its lessons), and an id remap at + restore — none of which a JSON list gives. + """ + + __tablename__ = "lesson_rule_links" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + lesson_id: Mapped[int] = mapped_column( + Integer, ForeignKey("notes.id", ondelete="CASCADE"), index=True, + ) + rule_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True, + ) + state: Mapped[str] = mapped_column(Text, default=SUGGESTED, server_default=SUGGESTED) + # Why it was confirmed or rejected — the reasoning a later reader needs to + # decide whether it still holds, as a rule relation's `note` is. + note: Mapped[str | None] = mapped_column(Text, nullable=True) + # What the suggestion rests on (filled by the co-surfacing recorder, #4637). + # Kept after a judgment so a confirmation can be read beside its evidence. + evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + # When a judgment moved it out of `suggested`. Null while suggested. + judged_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, + ) + + __table_args__ = ( + UniqueConstraint("lesson_id", "rule_id", name="uq_lesson_rule_links_pair"), + ) + + def to_dict(self) -> dict: + return { + "lesson_id": self.lesson_id, + "rule_id": self.rule_id, + "state": self.state, + "note": self.note or "", + "evidence": self.evidence or {}, + "judged_at": iso(self.judged_at), + "created_at": iso(self.created_at), + } diff --git a/src/scribe/routes/lessons.py b/src/scribe/routes/lessons.py index c9241a3..0335b07 100644 --- a/src/scribe/routes/lessons.py +++ b/src/scribe/routes/lessons.py @@ -28,6 +28,7 @@ from scribe.auth import get_current_user_id, login_required from scribe.routes.utils import not_found, parse_pagination from scribe.services import dedup as dedup_svc from scribe.services import knowledge as knowledge_svc +from scribe.services import lesson_rules as lesson_rules_svc from scribe.services import lessons as lessons_svc from scribe.services import systems as systems_svc from scribe.services import trash as trash_svc @@ -136,6 +137,12 @@ async def create_lesson_route(): project_id = data.get("project_id") or None learned_from = data.get("learned_from") or [] + # The rules this lesson is an instance of (milestone 440). Validated before + # anything is written, as the MCP door does, so a bad id saves nothing. + try: + linked = await lesson_rules_svc.require_rules(uid, data.get("rule_ids") or []) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 # The same near-duplicate gate the MCP create path applies. Two lessons # under one trigger compete in a single ranked list for one reserved slot, @@ -165,10 +172,13 @@ async def create_lesson_route(): ) if data.get("system_ids") is not None: await systems_svc.set_record_systems(uid, note.id, data["system_ids"]) + if linked: + await lesson_rules_svc.set_lesson_rules(uid, note.id, linked) out = lessons_svc.lesson_to_dict(note) out["systems"] = [ s.to_dict() for s in await systems_svc.list_record_systems(uid, note.id) ] + await lesson_rules_svc.attach_lesson_rules(uid, [out]) return jsonify(out), 201 @@ -194,6 +204,7 @@ async def get_lesson_route(lesson_id: int): ) out.update(await describe_provenance(uid, note)) await attach_usage([out]) + await lesson_rules_svc.attach_lesson_rules(uid, [out]) # Opening the detail view IS a pull — the operator chose to look. Tagged # apart from the MCP sources so "an agent was handed it" and "a human read # it" stay distinguishable; they mean different things for pruning (#2085). @@ -233,9 +244,20 @@ async def update_lesson_route(lesson_id: int): ), }), 400 + linked = None + if data.get("rule_ids") is not None: + try: + linked = await lesson_rules_svc.require_rules(uid, data["rule_ids"]) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + updated = await lessons_svc.update_lesson(owner_uid, lesson_id, **kwargs) if updated is None: return not_found("Lesson") + if linked is not None: + # The CALLER, for the reason system_ids below uses it: the rules named + # must be ones the person making the edit can read. + await lesson_rules_svc.set_lesson_rules(uid, lesson_id, linked) if data.get("system_ids") is not None: # The CALLER, not owner_uid (#4249). `set_record_systems` runs its own # `can_write_note` and links only Systems the acting user can read; @@ -249,9 +271,28 @@ async def update_lesson_route(lesson_id: int): s.to_dict() for s in await systems_svc.list_record_systems(owner_uid, lesson_id) ] + await lesson_rules_svc.attach_lesson_rules(uid, [out]) return jsonify(out) +@lessons_bp.route("//rules/", methods=["PUT"]) +@login_required +async def judge_lesson_link_route(lesson_id: int, rule_id: int): + """Confirm or reject one lesson→rule link — the REST twin of the MCP + `judge_lesson_link`. Body: {"verdict": "confirm" | "reject", "note": "…"}.""" + uid = get_current_user_id() + data = await request.get_json() or {} + try: + link = await lesson_rules_svc.judge_link( + uid, lesson_id, rule_id, data.get("verdict", ""), data.get("note", ""), + ) + except PermissionError: + return jsonify({"error": "Permission denied"}), 403 + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify(link) + + @lessons_bp.route("/", methods=["DELETE"]) @login_required async def delete_lesson_route(lesson_id: int): diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index 81e470c..335327e 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -16,6 +16,7 @@ from scribe.models.rule_usage import RuleUsageEvent from scribe.models.retrieval_tuning import RetrievalTuningEvent from scribe.models.canonical_system import CanonicalSystem from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t +from scribe.models.lesson_rule_link import LessonRuleLink from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse from scribe.models.project import Project from scribe.models.repo_binding import RepoBinding @@ -80,8 +81,12 @@ logger = logging.getLogger(__name__) # say whether it still measures anything. Both travel NULLABLE and unfilled — # a row written before the stamp existed restores unstamped, because inventing # the model it was measured under would turn "unknown" into a stated fact. +# v18 (2026-10) added lesson_rule_links (milestone 440): which rule each lesson +# is an instance of, and the judgments that confirmed or rejected each pair. A +# confirmed link is a judgment nothing else records, and a rejected one is +# what stops the pair being proposed again — losing either undoes work. # Bump when the serialized schema changes. -BACKUP_VERSION = 17 +BACKUP_VERSION = 18 # Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # below, these two lists must together account for the entire schema — which is @@ -119,6 +124,8 @@ _BACKED_UP = [ # on the operator's behalf, a restore that kept the numbers and dropped the # reasons would leave an install tuned by nobody it can name. "retrieval_tuning_events", + # v18 (2026-10): lesson → rule links and their judgments (milestone 440). + "lesson_rule_links", ] # Tables intentionally NOT in the backup, surfaced in the payload so the gap is @@ -212,6 +219,8 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = { "record_systems": {"id", "created_at"}, "note_supersessions": {"id", "created_at"}, "rule_relations": {"id", "created_at"}, + # The pair is the row; everything else is the judgment and its evidence. + "lesson_rule_links": {"id"}, "note_usage_events": {"id"}, # Same as the note twin: the surrogate key is re-issued on insert. "rule_usage_events": {"id"}, @@ -299,6 +308,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = { "record_systems": {"id", "created_at"}, "note_supersessions": {"id", "created_at"}, "rule_relations": {"id", "created_at"}, + "lesson_rule_links": {"id"}, "note_usage_events": {"id"}, "rule_usage_events": {"id"}, "retrieval_tuning_events": {"id"}, @@ -692,6 +702,21 @@ def _rule_relation_rows(rows) -> list[dict]: ] +def _lesson_rule_link_rows(rows) -> list[dict]: + """Which rule each lesson is an instance of, with the judgment's state, + reason and evidence (milestone 440). Ids are SOURCE ids, remapped through + the note and rule maps at restore.""" + return [ + { + "lesson_id": r.lesson_id, "rule_id": r.rule_id, "state": r.state, + "note": r.note, "evidence": r.evidence, + "judged_at": r.judged_at.isoformat() if r.judged_at else None, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in rows + ] + + def _rule_rows(rows) -> list[dict]: return [ { @@ -739,6 +764,7 @@ async def export_full_backup() -> dict: .join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id) )).all() rule_relations = (await session.execute(select(RuleRelation))).scalars().all() + lesson_rule_links = (await session.execute(select(LessonRuleLink))).scalars().all() record_systems = (await session.execute(select(RecordSystem))).scalars().all() supersessions = ( await session.execute(select(NoteSupersession)) @@ -797,6 +823,7 @@ async def export_full_backup() -> dict: "canonical_systems": _canonical_system_rows(canonical_systems), "rule_systems": _rule_system_rows(rule_system_rows), "rule_relations": _rule_relation_rows(rule_relations), + "lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links), "systems": _system_rows( systems, {c.id: c.slug for c in canonical_systems} ), @@ -957,6 +984,14 @@ async def export_user_backup(user_id: int) -> dict: RuleRelation.to_rule_id.in_(_rule_ids), ) )).scalars().all() if _rule_ids else [] + # Both ends in THIS user's export, for the supersession reason: a link + # to a lesson or rule the import will not create restores as nothing. + lesson_rule_links = (await session.execute( + select(LessonRuleLink).where( + LessonRuleLink.lesson_id.in_(note_ids), + LessonRuleLink.rule_id.in_(_rule_ids), + ) + )).scalars().all() if (_rule_ids and note_ids) else [] return { "version": BACKUP_VERSION, @@ -984,6 +1019,7 @@ async def export_user_backup(user_id: int) -> dict: "canonical_systems": _canonical_system_rows(canonical_systems), "rule_systems": _rule_system_rows(rule_system_rows), "rule_relations": _rule_relation_rows(rule_relations), + "lesson_rule_links": _lesson_rule_link_rows(lesson_rule_links), "systems": _system_rows( systems, {c.id: c.slug for c in canonical_systems} ), @@ -1329,6 +1365,26 @@ def _build_rule_relation(row: dict, maps: _Maps) -> RuleRelation | None: ) +def _build_lesson_rule_link(row: dict, maps: _Maps) -> LessonRuleLink | None: + """Both ends must map: a link whose lesson or rule did not restore points + at whatever took that number in the destination.""" + lesson = maps.notes.get(row.get("lesson_id", 0)) + rule = maps.rules.get(row.get("rule_id", 0)) + if lesson is None or rule is None: + return None + return LessonRuleLink( + lesson_id=lesson, + rule_id=rule, + state=row.get("state") or "suggested", + note=row.get("note") or None, + evidence=row.get("evidence"), + # Kept absent when absent: a suggested link was never judged, and + # stamping it with the restore time would say it was. + judged_at=_dt_or_none(row.get("judged_at")), + created_at=_dt(row.get("created_at")), + ) + + def _build_rule_version(row: dict, maps: _Maps) -> RuleVersion | None: rid = maps.rules.get(row.get("rule_id", 0)) if rid is None: @@ -1725,7 +1781,7 @@ async def _restore_v2(data: dict) -> dict: "note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0, "code_shape_uses": 0, "canonical_systems": 0, "rule_systems": 0, "rule_relations": 0, "rule_versions": 0, - "retrieval_tuning_events": 0, + "retrieval_tuning_events": 0, "lesson_rule_links": 0, } async with async_session() as session: @@ -1920,6 +1976,16 @@ async def _restore_v2(data: dict) -> dict: session.add(relation) stats["rule_relations"] += 1 + # Lesson → rule links (milestone 440): after both notes and rules are + # mapped, which is why they sit beside the rule edges. Archives before + # v18 carry no section and restore with none. + for lr in data.get("lesson_rule_links", []): + link = _build_lesson_rule_link(lr, maps) + if link is None: + continue + session.add(link) + stats["lesson_rule_links"] += 1 + # A rule's edit history (milestone 323). Must come after the rules # themselves — the rule map is only populated above — and both ids are # ids in the SOURCE database, which is #3182's arose_from_id trap. diff --git a/src/scribe/services/lesson_rules.py b/src/scribe/services/lesson_rules.py new file mode 100644 index 0000000..63c27ad --- /dev/null +++ b/src/scribe/services/lesson_rules.py @@ -0,0 +1,244 @@ +"""Lessons point at rules (milestone 440, #4196). + +A lesson is a non-binding record of one situation; a rule is the binding +choice for a class of them. This service owns the link between the two — which +rule a lesson is an instance of — and both directions of reading it. + +THE STATES (models/lesson_rule_link.py says why each exists): +`suggested` while evidence accumulates, `confirmed` or `rejected` once a +judgment is made. Every write here is a JUDGMENT, so every write lands as +confirmed or rejected; `suggested` rows come from the co-surfacing recorder +(#4637), never from a caller naming a rule. + +ACL (rule 78). Linking changes what a lesson says about itself, so it needs +WRITE on the lesson (`access.can_write_note`, share-aware). It names a rule, so +it needs the rule to be one the caller may read — rules are owner-scoped, and +`rulebooks._fetch_owned_rule` / `_owned_rules_clause` are that check's two +forms. A read shows only the rules the READER owns: a lesson shared with +someone must not hand them the titles of its owner's private rules. +""" +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from sqlalchemy import select + +from scribe.models import async_session +from scribe.models.lesson_rule_link import CONFIRMED, REJECTED, SUGGESTED, LessonRuleLink +from scribe.models.note import Note +from scribe.models.rulebook import Rule + +logger = logging.getLogger(__name__) + +# What a caller says to judge one pair. Words rather than the stored states, +# because "confirm" and "reject" are acts and the states are their results. +VERDICTS = {"confirm": CONFIRMED, "reject": REJECTED} + +# Recorded on a link that set-semantics removed, so a reader of the rejected +# row can tell an explicit "not this rule" from a link dropped by a rewrite. +_REMOVED_NOTE = "removed from the lesson's rules by an update" + + +def _ids(values) -> list[int]: + """Positive ints, de-duplicated, in the order given.""" + out: list[int] = [] + for v in values or []: + try: + i = int(v) + except (TypeError, ValueError): + raise ValueError(f"rule id {v!r} is not an integer") + if i > 0 and i not in out: + out.append(i) + return out + + +async def _require_lesson_writable(user_id: int, lesson_id: int) -> None: + from scribe.services import access + from scribe.services import lessons as lessons_svc + + note = await lessons_svc.get_lesson(user_id, lesson_id) + if note is None: + raise ValueError(f"lesson {lesson_id} not found") + if not await access.can_write_note(user_id, lesson_id): + raise PermissionError(f"lesson {lesson_id} is not yours to change") + + +async def require_rules(user_id: int, rule_ids) -> list[int]: + """The ids, validated as rules the caller owns — all of them or none. + + Checked BEFORE anything is written, by every caller, so a lesson create + that names a rule it cannot see fails without leaving a half-linked + lesson behind. + """ + from scribe.services import rulebooks as rulebooks_svc + + wanted = _ids(rule_ids) + missing = [rid for rid in wanted if await rulebooks_svc.get_rule(rid, user_id) is None] + if missing: + raise ValueError( + f"rule(s) {missing} not found — a lesson can point only at a rule " + "you can read. Nothing was linked." + ) + return wanted + + +async def _upsert(session, lesson_id: int, rule_id: int, state: str, note: str) -> None: + now = datetime.now(timezone.utc) + row = (await session.execute( + select(LessonRuleLink).where( + LessonRuleLink.lesson_id == lesson_id, + LessonRuleLink.rule_id == rule_id, + ) + )).scalar_one_or_none() + if row is None: + session.add(LessonRuleLink( + lesson_id=lesson_id, rule_id=rule_id, state=state, + note=note or None, judged_at=now, + )) + return + # Evidence is kept across a judgment: a confirmation reads best beside + # what it rested on. + row.state = state + row.note = note or row.note + row.judged_at = now + + +async def set_lesson_rules( + user_id: int, lesson_id: int, rule_ids, *, note: str = "", +) -> None: + """Make the lesson's CONFIRMED rules exactly `rule_ids` (set-semantics). + + A rule named here is confirmed, whatever state it was in: the writer of a + lesson saying "this is an instance of rule N" is the judgment the + suggested state waits for, so it needs no evidence bar. + + A rule that WAS confirmed and is no longer named becomes `rejected`, not + deleted. Dropping it is a judgment that the lesson is not an instance of + that rule, and a deleted row would let the co-surfacing recorder propose + the same pair again. + """ + await _require_lesson_writable(user_id, lesson_id) + wanted = await require_rules(user_id, rule_ids) + async with async_session() as session: + current = (await session.execute( + select(LessonRuleLink).where( + LessonRuleLink.lesson_id == lesson_id, + LessonRuleLink.state == CONFIRMED, + ) + )).scalars().all() + for row in current: + if row.rule_id not in wanted: + row.state = REJECTED + row.note = _REMOVED_NOTE + row.judged_at = datetime.now(timezone.utc) + for rid in wanted: + await _upsert(session, lesson_id, rid, CONFIRMED, note) + await session.commit() + + +async def judge_link( + user_id: int, lesson_id: int, rule_id: int, verdict: str, note: str = "", +) -> dict: + """Confirm or reject one (lesson, rule) pair, suggested or not.""" + state = VERDICTS.get((verdict or "").strip().lower()) + if state is None: + raise ValueError(f"verdict must be one of {sorted(VERDICTS)}, got {verdict!r}") + await _require_lesson_writable(user_id, lesson_id) + [rid] = await require_rules(user_id, [rule_id]) + async with async_session() as session: + await _upsert(session, lesson_id, rid, state, note) + await session.commit() + row = (await session.execute( + select(LessonRuleLink).where( + LessonRuleLink.lesson_id == lesson_id, + LessonRuleLink.rule_id == rid, + ) + )).scalar_one() + return row.to_dict() + + +async def rules_for_lessons(user_id: int, lesson_ids) -> dict[int, list[dict]]: + """{lesson_id: [{id, title, kind, state, note}]}, for rules the READER owns. + + One query for the whole set — a lesson list would otherwise be N+1. + Confirmed first, then suggested, then rejected: the order a reader cares + about them in. + """ + from scribe.services.rulebooks import _owned_rules_clause + + ids = [int(i) for i in lesson_ids or []] + out: dict[int, list[dict]] = {i: [] for i in ids} + if not ids: + return out + order = {s: n for n, s in enumerate((CONFIRMED, SUGGESTED, REJECTED))} + async with async_session() as session: + rows = (await session.execute( + select(LessonRuleLink, Rule) + .join(Rule, Rule.id == LessonRuleLink.rule_id) + .where(LessonRuleLink.lesson_id.in_(ids)) + .where(_owned_rules_clause(user_id)) + )).all() + for link, rule in sorted(rows, key=lambda r: (order.get(r[0].state, 9), r[1].id)): + out[link.lesson_id].append({ + "id": rule.id, "title": rule.title, "kind": rule.kind, + "state": link.state, "note": link.note or "", + }) + return out + + +async def lessons_for_rule(user_id: int, rule_id: int) -> list[dict]: + """The lessons that point at one rule, readable by the caller (share-aware). + + The reverse direction, and the one a rule's page needs: the concrete + situations that have been judged instances of it. + """ + from scribe.services.access import readable_notes_clause + + async with async_session() as session: + rows = (await session.execute( + select(LessonRuleLink, Note) + .join(Note, Note.id == LessonRuleLink.lesson_id) + .where(LessonRuleLink.rule_id == int(rule_id)) + .where(Note.deleted_at.is_(None)) + .where(readable_notes_clause(user_id)) + )).all() + order = {s: n for n, s in enumerate((CONFIRMED, SUGGESTED, REJECTED))} + return [ + {"id": note.id, "title": note.title, "state": link.state, "note": link.note or ""} + for link, note in sorted(rows, key=lambda r: (order.get(r[0].state, 9), r[1].id)) + ] + + +async def attach_lesson_rules(user_id: int, rows: list[dict], *, key: str = "id") -> None: + """Add `rules` to each lesson payload row, in place — one query per page. + + Fail-open (snippet #4286): this decorates a lesson the caller already has, + so a failed lookup leaves the key off and logs, rather than refusing the + lesson. An absent key reads as "not attached", never as "no rule". + """ + ids = [int(r[key]) for r in rows if isinstance(r.get(key), int)] + try: + found = await rules_for_lessons(user_id, ids) + except Exception: + logger.warning("lesson→rule links could not be read", exc_info=True) + return + for r in rows: + if isinstance(r.get(key), int): + r["rules"] = found.get(r[key], []) + + +async def attach_rule_lessons(user_id: int, data: dict, rule_id: int) -> None: + """Add `lessons` to a rule payload, in place, when there are any. + + Present-only, as a rule's `systems` and `relations` are (#2483), and + fail-open for the reason `attach_lesson_rules` gives. + """ + try: + lessons = await lessons_for_rule(user_id, rule_id) + except Exception: + logger.warning("rule→lesson links could not be read", exc_info=True) + return + if lessons: + data["lessons"] = lessons + diff --git a/src/scribe/services/rulebooks.py b/src/scribe/services/rulebooks.py index 57b37c1..02557a4 100644 --- a/src/scribe/services/rulebooks.py +++ b/src/scribe/services/rulebooks.py @@ -455,6 +455,11 @@ async def rule_detail(user_id: int, rule: Rule, system_ids: list[int] | None = N data["systems"] = systems if relations: data["relations"] = relations + # The concrete situations judged (or proposed) to be instances of this + # rule — milestone 440. Same present-only convention as the two above. + from scribe.services.lesson_rules import attach_rule_lessons + + await attach_rule_lessons(user_id, data, rule.id) return data diff --git a/tests/conftest.py b/tests/conftest.py index c5ce140..b64e527 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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). diff --git a/tests/test_integration_lesson_rule_links.py b/tests/test_integration_lesson_rule_links.py new file mode 100644 index 0000000..5156bcb --- /dev/null +++ b/tests/test_integration_lesson_rule_links.py @@ -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() diff --git a/tests/test_lesson_rule_link_doors.py b/tests/test_lesson_rule_link_doors.py new file mode 100644 index 0000000..8715fad --- /dev/null +++ b/tests/test_lesson_rule_link_doors.py @@ -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 diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index cc45bae..56a6978 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -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] == []