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
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user