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
+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",