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