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
@@ -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")
+49 -2
View File
@@ -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)
+2
View File
@@ -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
+77
View File
@@ -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),
}
+41
View File
@@ -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("/<int:lesson_id>/rules/<int:rule_id>", 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("/<int:lesson_id>", methods=["DELETE"])
@login_required
async def delete_lesson_route(lesson_id: int):
+68 -2
View File
@@ -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.
+244
View File
@@ -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
+5
View File
@@ -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
+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] == []