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>
55 lines
2.4 KiB
Python
55 lines
2.4 KiB
Python
"""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")
|