A rulebook holds two kinds of row in one table. A NORM is a decision: no truth value, changes only when its author changes it, and they know they did. A CONSTRAINT asserts a fact about someone else's software, and goes false with nobody present. Milestone 307's audit found nine stale sites; every one was a constraint, and not one norm had rotted. Three nullable columns so a rule can say how to check itself. expires_when is a STATE, not a date — constraints expire when the ground moves, not on a schedule. verified_at NULL means never checked and sorts FIRST in the sweep to come: unexamined outranks examined-long-ago. Most rules set none of the three; a null verify_with is the marker for "this is a decision, there is nothing to go and check," and it only reads that way while it stays honest. Nothing is backfilled and nothing is indexed. A migration cannot invent a check any more than 0088 could invent a trigger, and the sweep reads a whole rulebook — hundreds of rows, on operator demand, never on a request path. Also, in the backup service the fields had to pass through: - Restore now remaps arose_from_id through note_id_map. It has been exported since 0088 and silently dropped on the way back in ever since, so every restore lost every rule's provenance link. - _dt_or_none, because _dt substitutes now() for an absent value. That is right for created_at/updated_at and wrong here: a rule nobody ever checked would restore looking freshly checked and fall to the bottom of the sweep it should top. Column additions do not move BACKUP_VERSION; only new sections do, as when 0088 added when_to_apply/tier/arose_from_id to the same helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
260 lines
11 KiB
Python
260 lines
11 KiB
Python
from datetime import datetime, timezone
|
|
|
|
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 CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
|
|
|
|
|
|
class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
|
|
__tablename__ = "rulebooks"
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
owner_user_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("users.id", ondelete="CASCADE")
|
|
)
|
|
title: Mapped[str] = mapped_column(Text)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
always_on: Mapped[bool] = mapped_column(
|
|
Boolean, default=False, nullable=False, server_default="false"
|
|
)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"owner_user_id": self.owner_user_id,
|
|
"title": self.title,
|
|
"description": self.description or "",
|
|
"always_on": self.always_on,
|
|
"created_at": iso(self.created_at),
|
|
"updated_at": iso(self.updated_at),
|
|
}
|
|
|
|
|
|
class RulebookTopic(Base, TimestampMixin, SoftDeleteMixin):
|
|
__tablename__ = "rulebook_topics"
|
|
# Partial unique: a title is unique among LIVE topics in a rulebook, so a
|
|
# trashed topic doesn't block recreating/restoring the same title.
|
|
__table_args__ = (
|
|
Index(
|
|
"uq_topic_per_rulebook", "rulebook_id", "title",
|
|
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
rulebook_id: Mapped[int] = mapped_column(
|
|
BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE")
|
|
)
|
|
title: Mapped[str] = mapped_column(Text)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"rulebook_id": self.rulebook_id,
|
|
"title": self.title,
|
|
"description": self.description or "",
|
|
"order_index": self.order_index,
|
|
"created_at": iso(self.created_at),
|
|
"updated_at": iso(self.updated_at),
|
|
}
|
|
|
|
|
|
class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
|
__tablename__ = "rules"
|
|
# Partial unique: title unique among LIVE rules in a topic (soft-deleted
|
|
# rules don't block recreating/restoring the same title).
|
|
__table_args__ = (
|
|
Index(
|
|
"uq_rule_per_topic", "topic_id", "title",
|
|
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
|
),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
|
# Exactly one of topic_id / project_id is set — enforced by CHECK
|
|
# constraint ck_rule_topic_xor_project (migration 0059).
|
|
topic_id: Mapped[int | None] = mapped_column(
|
|
BigInteger,
|
|
ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
|
|
nullable=True,
|
|
)
|
|
project_id: Mapped[int | None] = mapped_column(
|
|
BigInteger,
|
|
ForeignKey("projects.id", ondelete="CASCADE"),
|
|
nullable=True,
|
|
index=True,
|
|
)
|
|
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 three fields that tell a CONSTRAINT apart from a NORM (milestone
|
|
# 312). A norm is a decision — no truth value, changes only when its
|
|
# author changes it. A constraint asserts a fact about someone else's
|
|
# software, and goes false with nobody watching: every stale rule the
|
|
# 307 audit found was one, and no norm had rotted.
|
|
#
|
|
# `verify_with` is how to check the rule is still true; `expires_when` is
|
|
# the STATE that ends it, deliberately not a date — constraints expire
|
|
# when the ground moves, not on a schedule. `verified_at` NULL means
|
|
# never checked, and sorts FIRST in the sweep: unexamined outranks
|
|
# examined-long-ago.
|
|
#
|
|
# Most rules should leave all three empty. A null `verify_with` is not a
|
|
# gap — it is the marker for "this is a decision, there is nothing to go
|
|
# and check," and the signal is only worth reading while that stays true.
|
|
verify_with: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
expires_when: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
verified_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), 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:
|
|
return {
|
|
"id": self.id,
|
|
"topic_id": self.topic_id,
|
|
"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 "",
|
|
"verify_with": self.verify_with or "",
|
|
"expires_when": self.expires_when or "",
|
|
"verified_at": iso(self.verified_at),
|
|
"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",
|
|
Base.metadata,
|
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
)
|
|
|
|
# Suppressions — let a project mute individual rules or whole topics from
|
|
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
|
|
# FKs CASCADE so the row vanishes when its parent is removed.
|
|
project_rule_suppressions = Table(
|
|
"project_rule_suppressions",
|
|
Base.metadata,
|
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
)
|
|
|
|
# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the
|
|
# sibling of the two suppression tables below, one level up. Always-on
|
|
# rulebooks bind every project implicitly; an inception decision can exclude
|
|
# specific ones for this project, and get_applicable_rules /
|
|
# list_always_on_rules(project_id) skip them. FKs CASCADE like the others.
|
|
project_rulebook_exclusions = Table(
|
|
"project_rulebook_exclusions",
|
|
Base.metadata,
|
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
)
|
|
|
|
project_topic_suppressions = Table(
|
|
"project_topic_suppressions",
|
|
Base.metadata,
|
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
|
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
|
)
|