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
+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):