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