diff --git a/alembic/versions/0088_rule_trigger_tier_tags_relations.py b/alembic/versions/0088_rule_trigger_tier_tags_relations.py new file mode 100644 index 0000000..502f147 --- /dev/null +++ b/alembic/versions/0088_rule_trigger_tier_tags_relations.py @@ -0,0 +1,105 @@ +"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307 +step 3, decision note 3026) + +Revision ID: 0088 +Revises: 0087 +Create Date: 2026-08-26 + +A rule could not say WHEN it applies, WHICH area it is about, or WHAT other +rule it belongs with. All three were being written as prose instead — a +project's System description restating rule text, a rule's `why` naming the +note that caused it, and two halves of one shape merged into a single row +because either could surface without the other. + +Four additions, each replacing something that was already being said in words: + +- `when_to_apply` — the trigger. Nullable HERE and required at the service + layer, because existing rules have none and a migration cannot invent one. +- `tier` — `always_on` (preloaded, as everything is today) or `conditional` + (reachable, surfaced when its trigger fires). Defaults to `always_on`, so + this migration changes NOTHING about which rules bind: an install upgrades + and every rule keeps arriving exactly as it did. +- `arose_from_id` — the record that caused the rule, the edge notes and tasks + already have. +- `rule_systems` / `rule_relations` — the canon tag and the typed edges. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0088" +down_revision = "0087" +branch_labels = None +depends_on = None + +# Kept in one place so upgrade and the CHECK agree by construction (rule 36: +# a whitelisted value means DROP + ADD CONSTRAINT in the same migration — +# there is no prior constraint here, so the pair is created together). +_TIERS = ("always_on", "conditional") +_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates") + + +def _in_list(column: str, values: tuple[str, ...]) -> str: + return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")" + + +def upgrade() -> None: + op.add_column("rules", sa.Column("when_to_apply", sa.Text(), nullable=True)) + op.add_column( + "rules", + sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"), + ) + op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS)) + + # SET NULL, not CASCADE: the record that prompted a rule can be trashed + # without taking the rule with it — provenance is a claim about history, + # and losing the source does not repeal the rule. + op.add_column("rules", sa.Column("arose_from_id", sa.BigInteger(), nullable=True)) + op.create_foreign_key( + "fk_rules_arose_from_id", "rules", "notes", + ["arose_from_id"], ["id"], ondelete="SET NULL", + ) + + # Which global AREA a rule is about. Points at the canonical catalog, never + # at a project's `systems` row — a rule that spans projects cannot be + # chained to one project's vocabulary (0087). + op.create_table( + "rule_systems", + sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True), + sa.Column("canonical_id", sa.Integer(), sa.ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + ) + op.create_index("ix_rule_systems_canonical_id", "rule_systems", ["canonical_id"]) + + # Typed edges between rules. Each kind exists because its absence forced a + # workaround: co_surfaces (merging two rules into one row), overrides (a + # stricter project rule written as a duplicate), elaborates (a local + # addendum sitting beside its parent with nothing to say it is one). + op.create_table( + "rule_relations", + sa.Column("id", sa.BigInteger(), primary_key=True), + sa.Column("from_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False), + sa.Column("to_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False), + sa.Column("kind", sa.Text(), nullable=False), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.CheckConstraint(_in_list("kind", _RELATION_KINDS), name="ck_rule_relations_kind"), + # A rule cannot relate to itself, and one pair carries a given kind + # once — a second row would surface the same rule twice. + sa.CheckConstraint("from_rule_id <> to_rule_id", name="ck_rule_relations_not_self"), + sa.UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"), + ) + op.create_index("ix_rule_relations_from", "rule_relations", ["from_rule_id"]) + op.create_index("ix_rule_relations_to", "rule_relations", ["to_rule_id"]) + + +def downgrade() -> None: + op.drop_index("ix_rule_relations_to", table_name="rule_relations") + op.drop_index("ix_rule_relations_from", table_name="rule_relations") + op.drop_table("rule_relations") + op.drop_index("ix_rule_systems_canonical_id", table_name="rule_systems") + op.drop_table("rule_systems") + op.drop_constraint("fk_rules_arose_from_id", "rules", type_="foreignkey") + op.drop_column("rules", "arose_from_id") + op.drop_constraint("ck_rules_tier", "rules", type_="check") + op.drop_column("rules", "tier") + op.drop_column("rules", "when_to_apply") diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py index eb06774..549a984 100644 --- a/src/scribe/models/__init__.py +++ b/src/scribe/models/__init__.py @@ -39,12 +39,14 @@ from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401 from scribe.models.notification import Notification # noqa: E402, F401 from scribe.models.api_key import ApiKey # noqa: E402, F401 from scribe.models.user_profile import UserProfile # noqa: E402, F401 +# Imported before rulebook: rule_systems foreign-keys canonical_systems. +from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401 from scribe.models.rulebook import ( # noqa: E402, F401 - Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions, + Rulebook, RulebookTopic, Rule, RuleRelation, project_rulebook_subscriptions, + rule_systems, ) 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 from scribe.models.system import System, RecordSystem # noqa: E402, F401 -from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401 from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401 diff --git a/src/scribe/models/rulebook.py b/src/scribe/models/rulebook.py index 8217999..d05da94 100644 --- a/src/scribe/models/rulebook.py +++ b/src/scribe/models/rulebook.py @@ -1,10 +1,13 @@ from datetime import datetime, timezone -from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text +from sqlalchemy import ( + BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, + Text, UniqueConstraint, text, +) from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base -from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso +from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso class Rulebook(Base, TimestampMixin, SoftDeleteMixin): @@ -90,8 +93,26 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin): ) title: Mapped[str] = mapped_column(Text) statement: Mapped[str] = mapped_column(Text) + # WHEN this rule applies — the trigger, not the instruction. Required of + # new rules at the service layer and nullable here, because rules written + # before migration 0088 have none and a migration cannot invent one. + # It carries three jobs at once (note 3026): it is the tier test made + # concrete, the readable form of the canon tag, and the half of the + # document that makes a rule findable by meaning. + when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True) + # always_on = preloaded into every session, as every rule is today. + # conditional = reachable, and surfaced when its trigger fires. The + # default preserves existing behaviour exactly: nothing stops binding + # because of an upgrade. CHECK ck_rules_tier (migration 0088, rule 36). + tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on") why: Mapped[str | None] = mapped_column(Text, nullable=True) how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True) + # The record that caused this rule — the edge notes and tasks already + # have. Rule 46's `why` names note 2813 in prose; this is that link as a + # field, so it survives a rewording of the paragraph. + arose_from_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True + ) order_index: Mapped[int] = mapped_column(Integer, default=0) def to_dict(self) -> dict: @@ -101,14 +122,78 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin): "project_id": self.project_id, "title": self.title, "statement": self.statement, + "when_to_apply": self.when_to_apply or "", + "tier": self.tier, "why": self.why or "", "how_to_apply": self.how_to_apply or "", + "arose_from_id": self.arose_from_id, "order_index": self.order_index, "created_at": iso(self.created_at), "updated_at": iso(self.updated_at), } +# Which global AREA a rule is about (milestone 307). Points at the canonical +# catalog, NEVER at a project's `systems` row: a rule that spans projects +# cannot be chained to one project's vocabulary. This is the edge three +# projects were drawing by hand, as rule text copied into a System's charter. +rule_systems = Table( + "rule_systems", + Base.metadata, + Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True), + Column("canonical_id", Integer, ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True), + Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)), +) + + +class RuleRelation(Base, CreatedAtMixin): + """A typed edge between two rules. Each kind exists because its ABSENCE + forced a workaround somewhere in the operator's rulebook (note 3026). + + - ``co_surfaces`` — these fail together, so they must arrive together. + Without it, the only way to guarantee that was to merge them into one + row, which is what happened to rule 46: split into 144, folded back the + same day because "either rule could surface without the other." + Symmetric in meaning; stored once and read both ways. + - ``overrides`` — this rule supersedes that one for its scope. Only + *suppression* existed, so an override had to be written as a parallel + rule that then drifts from its parent. + - ``elaborates`` — this rule adds local specifics to that one; surfacing + the parent brings the addendum with it. + + ``note`` records WHY the edge was drawn, for the same reason a rule + carries `why`: a later reader deciding whether it still holds needs the + reasoning, not just the fact. + """ + + __tablename__ = "rule_relations" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + from_rule_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True + ) + to_rule_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True + ) + # CHECK ck_rule_relations_kind (migration 0088, rule 36). + kind: Mapped[str] = mapped_column(Text) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"), + ) + + def to_dict(self) -> dict: + return { + "id": self.id, + "from_rule_id": self.from_rule_id, + "to_rule_id": self.to_rule_id, + "kind": self.kind, + "note": self.note or "", + "created_at": iso(self.created_at), + } + + # Pure many-to-many — no model class, just the join table. project_rulebook_subscriptions = Table( "project_rulebook_subscriptions", diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index f4e1360..eaec68f 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -12,6 +12,7 @@ from scribe.models.note_version import NoteVersion from scribe.models.design_system import DesignSystem, DesignToken from scribe.models.note_usage import NoteUsageEvent from scribe.models.canonical_system import CanonicalSystem +from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse from scribe.models.project import Project from scribe.models.repo_binding import RepoBinding @@ -74,6 +75,8 @@ _BACKED_UP = [ # user-scoped, so it rides in EVERY export — including a single-user # one, whose Systems would otherwise restore unmapped. "canonical_systems", + # v10 (2026-08): a rule's area tag and its typed edges (milestone 307). + "rule_systems", "rule_relations", ] # Tables intentionally NOT in the backup, surfaced in the payload so the gap is @@ -354,12 +357,34 @@ def _topic_rows(rows) -> list[dict]: ] +def _rule_system_rows(rows) -> list[dict]: + """A rule's area tags, carried by canonical SLUG for the same reason the + Systems are: the catalog is global and its ids are per-install.""" + return [{"rule_id": rule_id, "canonical_slug": slug} for rule_id, slug in rows] + + +def _rule_relation_rows(rows) -> list[dict]: + """The typed edges between rules. Carried because they are a JUDGEMENT — + someone decided these two fail together, or that one supersedes the other, + and nothing in either rule's text records the decision. Lose them and a + split rule silently starts arriving half at a time again.""" + return [ + { + "from_rule_id": r.from_rule_id, "to_rule_id": r.to_rule_id, + "kind": r.kind, "note": r.note, + } + for r in rows + ] + + def _rule_rows(rows) -> list[dict]: return [ { "id": r.id, "topic_id": r.topic_id, "project_id": r.project_id, "title": r.title, "statement": r.statement, "why": r.why, "how_to_apply": r.how_to_apply, "order_index": r.order_index, + "when_to_apply": r.when_to_apply, "tier": r.tier, + "arose_from_id": r.arose_from_id, "created_at": r.created_at.isoformat(), "updated_at": r.updated_at.isoformat(), } @@ -389,6 +414,11 @@ async def export_full_backup() -> dict: select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None)) .order_by(CanonicalSystem.order_index) )).scalars().all() + rule_system_rows = (await session.execute( + select(rule_systems_t.c.rule_id, CanonicalSystem.slug) + .join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id) + )).all() + rule_relations = (await session.execute(select(RuleRelation))).scalars().all() record_systems = (await session.execute(select(RecordSystem))).scalars().all() supersessions = ( await session.execute(select(NoteSupersession)) @@ -451,6 +481,8 @@ async def export_full_backup() -> dict: "topic_suppressions": _topic_suppression_rows(topic_suppressions), "rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions), "canonical_systems": _canonical_system_rows(canonical_systems), + "rule_systems": _rule_system_rows(rule_system_rows), + "rule_relations": _rule_relation_rows(rule_relations), "systems": _system_rows( systems, {c.id: c.slug for c in canonical_systems} ), @@ -568,6 +600,20 @@ async def export_user_backup(user_id: int) -> dict: rules = (await session.execute( select(Rule).where(or_(*rule_filters)) )).scalars().all() if rule_filters else [] + # Scoped to the rules this export already carries: an edge whose far + # end is absent would restore pointing at nothing. + _rule_ids = [r.id for r in rules] + rule_system_rows = (await session.execute( + select(rule_systems_t.c.rule_id, CanonicalSystem.slug) + .join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id) + .where(rule_systems_t.c.rule_id.in_(_rule_ids)) + )).all() if _rule_ids else [] + rule_relations = (await session.execute( + select(RuleRelation).where( + RuleRelation.from_rule_id.in_(_rule_ids), + RuleRelation.to_rule_id.in_(_rule_ids), + ) + )).scalars().all() if _rule_ids else [] if project_ids: subscriptions = (await session.execute( select(project_rulebook_subscriptions).where( @@ -619,6 +665,8 @@ async def export_user_backup(user_id: int) -> dict: "topic_suppressions": _topic_suppression_rows(topic_suppressions), "rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions), "canonical_systems": _canonical_system_rows(canonical_systems), + "rule_systems": _rule_system_rows(rule_system_rows), + "rule_relations": _rule_relation_rows(rule_relations), "systems": _system_rows( systems, {c.id: c.slug for c in canonical_systems} ), @@ -736,6 +784,7 @@ async def _restore_v2(data: dict) -> dict: "design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0, "note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0, "code_shape_uses": 0, "canonical_systems": 0, + "rule_systems": 0, "rule_relations": 0, } async with async_session() as session: @@ -952,6 +1001,11 @@ async def _restore_v2(data: dict) -> dict: statement=r_data.get("statement", ""), why=r_data.get("why") or None, how_to_apply=r_data.get("how_to_apply") or None, + when_to_apply=r_data.get("when_to_apply") or None, + # A file written before migration 0088 has no tier. always_on + # is the pre-0088 behaviour, so an old backup restores rules + # that bind exactly as they did when it was taken. + tier=r_data.get("tier") or "always_on", order_index=r_data.get("order_index", 0), created_at=_dt(r_data.get("created_at")), updated_at=_dt(r_data.get("updated_at")), @@ -1008,9 +1062,9 @@ async def _restore_v2(data: dict) -> dict: # --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4 # payload restores without them rather than failing on an absent key. - # 15. Systems system_id_map: dict[int, int] = {} - # 14b. The global area catalog, matched on SLUG. This install already + + # 14c. The global area catalog, matched on SLUG. This install already # has the standard vocabulary from its migrations, so the common case # adds nothing and simply learns which local id each slug is; only an # entry an admin added on the source instance is created here. Runs @@ -1035,6 +1089,31 @@ async def _restore_v2(data: dict) -> dict: canonical_id_by_slug[slug] = entry.id stats["canonical_systems"] += 1 + # 14d. A rule's area tags and its typed edges. Runs HERE, not beside the + # rules in section 11, because it needs both maps: the rule ids from + # there and the canonical slugs from 14c just above. + for rs in data.get("rule_systems", []): + mapped_rule = rule_id_map.get(rs.get("rule_id", 0)) + canonical_id = canonical_id_by_slug.get(rs.get("canonical_slug") or "") + if mapped_rule is None or canonical_id is None: + continue + await session.execute(rule_systems_t.insert().values( + rule_id=mapped_rule, canonical_id=canonical_id, + )) + stats["rule_systems"] += 1 + + for rr in data.get("rule_relations", []): + mapped_from = rule_id_map.get(rr.get("from_rule_id", 0)) + mapped_to = rule_id_map.get(rr.get("to_rule_id", 0)) + if mapped_from is None or mapped_to is None or mapped_from == mapped_to: + continue + session.add(RuleRelation( + from_rule_id=mapped_from, to_rule_id=mapped_to, + kind=rr.get("kind", "co_surfaces"), note=rr.get("note") or None, + )) + stats["rule_relations"] += 1 + + # 15. Systems for sy_data in data.get("systems", []): mapped_uid = user_id_map.get(sy_data.get("user_id", 0)) mapped_pid = project_id_map.get(sy_data.get("project_id", 0)) diff --git a/src/scribe/services/rulebooks.py b/src/scribe/services/rulebooks.py index 787b935..1d7de8c 100644 --- a/src/scribe/services/rulebooks.py +++ b/src/scribe/services/rulebooks.py @@ -10,7 +10,7 @@ from __future__ import annotations import logging from typing import Optional -from sqlalchemy import select +from sqlalchemy import delete as sql_delete, insert, select from scribe.models import async_session from scribe.models.rulebook import Rulebook @@ -223,7 +223,7 @@ async def delete_topic(topic_id: int, user_id: int) -> None: # ── Rule CRUD ────────────────────────────────────────────────────────── -from scribe.models.rulebook import Rule +from scribe.models.rulebook import Rule, RuleRelation async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None: @@ -280,9 +280,64 @@ async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> No raise ValueError(f"rule {rule_id} not found or not a rulebook rule") +# The vocabularies migration 0088's CHECK constraints enforce. Named here so +# a caller can be corrected before the database refuses it (rule 36 keeps the +# two in step; this keeps the error readable). +TIERS = ("always_on", "conditional") +RELATION_KINDS = ("co_surfaces", "overrides", "elaborates") + + +def _valid_tier(tier: str) -> str: + """An unrecognised tier falls back to always_on — the SAFE direction. + + Getting this wrong the other way would silently stop a rule binding, which + is the one failure this whole milestone exists to prevent. A rule that + preloads when it did not need to costs context; a rule that quietly stops + preloading costs the behaviour it was written for. + """ + return tier if tier in TIERS else "always_on" + + +def rule_brief(rule: Rule, **extra) -> dict: + """The shape a rule takes when it is SURFACED rather than opened. + + One builder for every payload that hands rules to an agent, because there + were three copies of this dict and they had already diverged — two carried + `topic_id`, one didn't, and none carried the timestamps the model has held + all along. That omission is why a rule written before the capability it + duplicates was indistinguishable, at read time, from one still doing work + (the FabledCurator case, note 3026). + + `updated_at` is a DATE, not a stamp: the question it answers is "how old + is this?", and a full ISO string across an always-on set is ~2k characters + of payload for a precision nobody reads. + + `why` and `how_to_apply` are deliberately NOT here — they are the depth a + caller gets from get_rule, and putting them in every listing is the bloat + this milestone is about. + """ + out = { + "id": rule.id, + "title": rule.title, + "statement": rule.statement, + "topic_id": rule.topic_id, + "tier": rule.tier, + "updated_at": rule.updated_at.date().isoformat() if rule.updated_at else None, + } + # Attached only when present (#2483: never a null key that reads as a + # capability the record doesn't have). + if rule.when_to_apply: + out["when_to_apply"] = rule.when_to_apply + if rule.arose_from_id: + out["arose_from_id"] = rule.arose_from_id + out.update({k: v for k, v in extra.items() if v is not None}) + return out + + async def create_rule( topic_id: int, user_id: int, title: str, statement: str, why: str = "", how_to_apply: str = "", order_index: int = 0, + when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0, ) -> Rule: async with async_session() as session: await _assert_topic_owned(session, topic_id, user_id) @@ -290,8 +345,11 @@ async def create_rule( topic_id=topic_id, title=title, statement=statement, + when_to_apply=when_to_apply or None, + tier=_valid_tier(tier), why=why or None, how_to_apply=how_to_apply or None, + arose_from_id=arose_from_id or None, order_index=order_index, ) session.add(rule) @@ -303,6 +361,7 @@ async def create_rule( async def create_project_rule( project_id: int, user_id: int, title: str, statement: str, why: str = "", how_to_apply: str = "", order_index: int = 0, + when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0, ) -> Rule: """Create a rule scoped to a single project (no rulebook ceremony). @@ -316,8 +375,11 @@ async def create_project_rule( project_id=project_id, title=title, statement=statement, + when_to_apply=when_to_apply or None, + tier=_valid_tier(tier), why=why or None, how_to_apply=how_to_apply or None, + arose_from_id=arose_from_id or None, order_index=order_index, ) session.add(rule) @@ -513,15 +575,174 @@ async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]: rule = await _fetch_owned_rule(session, rule_id, user_id) if rule is None: return None - allowed = {"title", "statement", "why", "how_to_apply", "order_index"} + allowed = { + "title", "statement", "why", "how_to_apply", "order_index", + "when_to_apply", "tier", "arose_from_id", + } for key, value in fields.items(): if key in allowed and value is not None: - setattr(rule, key, value) + setattr(rule, key, _valid_tier(value) if key == "tier" else value) await session.commit() await session.refresh(rule) return rule +# ── Canon tags + typed edges (milestone 307) ─────────────────────────── + +async def set_rule_systems( + rule_id: int, user_id: int, canonical_ids: list[int], +) -> list[int] | None: + """Replace which global AREAS a rule is about. None if not owned. + + Set-semantics like set_record_systems: the list given IS the state after, + so an empty list clears the tags. Points at the canonical catalog, never a + project's System — a family rule tagged to one project's row would bind + itself to that project's vocabulary. + """ + from scribe.models.canonical_system import CanonicalSystem + from scribe.models.rulebook import rule_systems as rule_systems_t + + async with async_session() as session: + rule = await _fetch_owned_rule(session, rule_id, user_id) + if rule is None: + return None + wanted = set(canonical_ids or []) + if wanted: + live = set((await session.execute( + select(CanonicalSystem.id).where( + CanonicalSystem.id.in_(wanted), + CanonicalSystem.deleted_at.is_(None), + ) + )).scalars().all()) + # Silently dropping an unknown id would leave the caller believing + # a tag exists; keep only the live ones and report what stuck. + wanted &= live + await session.execute( + sql_delete(rule_systems_t).where(rule_systems_t.c.rule_id == rule_id) + ) + for canonical_id in sorted(wanted): + await session.execute( + insert(rule_systems_t).values(rule_id=rule_id, canonical_id=canonical_id) + ) + await session.commit() + return sorted(wanted) + + +async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]: + """The canon tags for a batch of rules, keyed by rule id. + + Batched on purpose: the surfacing paths ask about a whole payload of rules + at once, and one query per rule would turn every session start into an + N+1. + """ + from scribe.models.canonical_system import CanonicalSystem + from scribe.models.rulebook import rule_systems as rule_systems_t + + if not rule_ids: + return {} + async with async_session() as session: + rows = (await session.execute( + select(rule_systems_t.c.rule_id, CanonicalSystem.id, CanonicalSystem.name) + .join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id) + .where( + rule_systems_t.c.rule_id.in_(rule_ids), + CanonicalSystem.deleted_at.is_(None), + ) + .order_by(CanonicalSystem.order_index) + )).all() + out: dict[int, list[dict]] = {} + for rule_id, canonical_id, name in rows: + out.setdefault(rule_id, []).append({"id": canonical_id, "name": name}) + return out + + +async def add_rule_relation( + user_id: int, from_rule_id: int, to_rule_id: int, kind: str, note: str = "", +) -> RuleRelation | None: + """Draw a typed edge between two rules. None if either isn't owned. + + Both ends are ownership-checked: an edge is only meaningful if the drawer + can see both rules, and a one-sided edge would surface a rule the caller + has no business reading. + + Idempotent — re-drawing an existing edge returns it rather than raising, so + a true-up pass can be re-run without cleaning up first. + """ + if kind not in RELATION_KINDS: + raise ValueError(f"kind must be one of {RELATION_KINDS}, got {kind!r}") + if from_rule_id == to_rule_id: + raise ValueError("a rule cannot relate to itself") + async with async_session() as session: + for rid in (from_rule_id, to_rule_id): + if await _fetch_owned_rule(session, rid, user_id) is None: + return None + existing = await session.scalar( + select(RuleRelation).where( + RuleRelation.from_rule_id == from_rule_id, + RuleRelation.to_rule_id == to_rule_id, + RuleRelation.kind == kind, + ) + ) + if existing is not None: + return existing + relation = RuleRelation( + from_rule_id=from_rule_id, to_rule_id=to_rule_id, + kind=kind, note=note or None, + ) + session.add(relation) + await session.commit() + await session.refresh(relation) + return relation + + +async def remove_rule_relation(user_id: int, relation_id: int) -> bool: + async with async_session() as session: + relation = await session.get(RuleRelation, relation_id) + if relation is None: + return False + if await _fetch_owned_rule(session, relation.from_rule_id, user_id) is None: + return False + await session.delete(relation) + await session.commit() + return True + + +async def list_rule_relations(rule_ids: list[int]) -> dict[int, list[dict]]: + """Edges touching a batch of rules, keyed by rule id. + + `co_surfaces` is reported from BOTH ends off a single stored row — it means + "these fail together", which is not a claim with a direction. The other two + are directional and are reported as stored, with `direction` naming which + end this rule is: an override read from the wrong end would invert what it + says. + """ + if not rule_ids: + return {} + async with async_session() as session: + rows = (await session.execute( + select(RuleRelation).where( + (RuleRelation.from_rule_id.in_(rule_ids)) + | (RuleRelation.to_rule_id.in_(rule_ids)) + ) + )).scalars().all() + out: dict[int, list[dict]] = {} + wanted = set(rule_ids) + for relation in rows: + if relation.from_rule_id in wanted: + out.setdefault(relation.from_rule_id, []).append({ + "id": relation.id, "kind": relation.kind, + "rule_id": relation.to_rule_id, + "direction": "outgoing", "note": relation.note or "", + }) + if relation.to_rule_id in wanted: + out.setdefault(relation.to_rule_id, []).append({ + "id": relation.id, "kind": relation.kind, + "rule_id": relation.from_rule_id, + "direction": "incoming", "note": relation.note or "", + }) + return out + + async def delete_rule(rule_id: int, user_id: int) -> None: async with async_session() as session: rule = await _fetch_owned_rule(session, rule_id, user_id) @@ -533,7 +754,6 @@ async def delete_rule(rule_id: int, user_id: int) -> None: # ── Subscriptions + get_applicable_rules ─────────────────────────────── -from sqlalchemy import insert, delete as sql_delete from sqlalchemy.exc import IntegrityError @@ -802,10 +1022,13 @@ async def get_applicable_rules( # Applicable rules (limit + 1 so we can detect truncation). Filter # in SQL so truncation reflects the post-suppression count, not the # raw subscription count. + # Selects the ENTITY, not a column list: rule_brief is the one place + # that decides which fields a surfaced rule carries, and a column list + # here would be a second such decision to keep in step. The row count + # is bounded by `limit`, so this is a listing, not a scan. rules_q = ( select( - Rule.id, Rule.title, Rule.statement, - RulebookTopic.id.label("topic_id"), + Rule, RulebookTopic.title.label("topic_title"), Rulebook.id.label("rulebook_id"), Rulebook.title.label("rulebook_title"), @@ -838,18 +1061,14 @@ async def get_applicable_rules( rule_rows = (await session.execute(rules_q)).all() truncated = len(rule_rows) > limit rules = [ - { - "id": rid, "title": rtitle, "statement": stmt, - "topic_id": ti, "topic_title": tt, - "rulebook_id": rbi, "rulebook_title": rbt, - } - for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit] + rule_brief(rule, topic_title=tt, rulebook_id=rbi, rulebook_title=rbt) + for rule, tt, rbi, rbt in rule_rows[:limit] ] # Project-scoped rules — verifies ownership via Project.user_id. from scribe.models.project import Project proj_rules_q = ( - select(Rule.id, Rule.title, Rule.statement) + select(Rule) .join(Project, Rule.project_id == Project.id) .where( Project.user_id == user_id, @@ -860,10 +1079,7 @@ async def get_applicable_rules( .order_by(Rule.order_index, Rule.title) ) proj_rule_rows = (await session.execute(proj_rules_q)).all() - project_rules = [ - {"id": rid, "title": rtitle, "statement": stmt} - for rid, rtitle, stmt in proj_rule_rows - ] + project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows] return { "rules": rules,