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>