feat(rules): a rule can say when it applies, which area it is about, and what it belongs with (#3029, milestone 307 step 3, schema)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 47s
CI & Build / Build & push image (push) Skipped

A rule could not state its trigger, its area, or its siblings, so all three
were being written as prose instead: a System's charter restating rule text,
a `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.

Migration 0088 adds the four fields those workarounds stood in for:

- `when_to_apply` — the trigger. Nullable in the DB and required at the
  service layer: existing rules have none and a migration cannot invent one.
- `tier` — always_on | conditional, defaulting to always_on. This migration
  therefore changes NOTHING about which rules bind; an install upgrades and
  every rule keeps arriving exactly as before. Getting that backwards is the
  one failure this milestone exists to prevent, so _valid_tier falls back to
  always_on rather than silently un-binding a rule with a typo'd tier.
- `arose_from_id` — the record that caused the rule, the edge notes and tasks
  already have. SET NULL: trashing the source does not repeal the rule.
- `rule_systems` / `rule_relations` — the canon tag and the typed edges
  (co_surfaces / overrides / elaborates), each earned from a workaround its
  absence forced.

rule_brief() replaces the THREE hand-written trim dicts that 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. It now carries updated_at as a DATE: the question is "how old is
this", and a full stamp across the always-on set is ~2k characters for
precision nobody reads. The two callers select the ENTITY rather than a column
list, so rule_brief stays the single place deciding what a surfaced rule says.

Backup: both new tables carried, area tags by canonical SLUG (ids are
per-install). The rule-relation restore runs after ALL rules exist and after
the catalog, because an edge names two rules and a tag names a global row —
sections renumbered so the file reads in dependency order. A pre-0088 payload
restores with tier=always_on, i.e. binding exactly as when it was taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 14:11:32 -04:00
co-authored by Claude Opus 5
parent 67874268bb
commit 6ddb8bf859
5 changed files with 511 additions and 24 deletions
@@ -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")
+4 -2
View File
@@ -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
+87 -2
View File
@@ -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",
+81 -2
View File
@@ -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))
+234 -18
View File
@@ -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,