Files
FabledGrimoire/src/scribe/models/rulebook.py
T
bvandeusenandClaude Opus 5.5 cad74df190 docs(6206): rulebook model comments state the design, not the history of the rulebook
The relation kinds say what each prevents without the rule-46 split story;
the removed tier, subscriptions and suppressions are stated as what the
model does not have, with the migration that dropped them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:38:01 -04:00

254 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)
def to_dict(self) -> dict:
return {
"id": self.id,
"owner_user_id": self.owner_user_id,
"title": self.title,
"description": self.description or "",
"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: it is the readable form of the canon tag,
# the half of the document that makes a rule findable by meaning, and —
# with no always-on tier — the ONLY thing that decides whether a rule ever reaches a session at all. A rule
# with no trigger is not a quiet rule, it is an unreachable one.
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
# WHAT KIND of instruction this is — the only axis on a rule; delivery
# belongs entirely to retrieval.
# `rule` must be FOLLOWED: ignoring it breaks something or crosses a
# boundary. `preference` is how this person wants work DONE: ignoring it
# costs consistency, not correctness.
#
# The second half is what makes it a kind rather than a softer label — a
# preference is expected to CHANGE as the work teaches it, and the agent
# updates it in the ordinary course of working, where a rule waits for its
# author. So one column decides two behaviours: whether create's approval
# gate fires, and which voice the injected line speaks in.
#
# Lives here and not in its own table because a preference needs exactly
# what a rule has and a note does not — a trigger column, a
# trigger-dominated document, ownership-scoped search, the retrieval arms,
# relations, and `rule_versions`, which is where its drift is recorded.
# Defaults to `rule` so nothing changes force on upgrade.
# CHECK ck_rules_kind (migration 0098).
kind: Mapped[str] = mapped_column(Text, default="rule", server_default="rule")
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. A `why` can name its origin 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 "",
# Unconditional, unlike the `if present` keys below. A reader
# deciding how much force a record carries must never infer it
# from an ABSENT key: "no kind field" and "kind is rule" would be
# the same payload, and that equivalence is the defect shape this
# codebase keeps re-encountering. Twenty bytes buys an answer that
# cannot be misread.
"kind": self.kind or "rule",
"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. 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.
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)),
)
# Which MOMENTS of work a rule arrives at. A rule mounted on a
# moment is delivered whenever that moment happens, by lookup and with no
# score — the route for a rule that is about WHEN something is done rather
# than what it is about, which semantic retrieval structurally misses. The
# moment is a catalog NAME (`services/moments.py`), not a key: the catalog is
# code, so there is nothing to point a foreign key at, and the service refuses
# an unknown name before it is stored.
rule_moments = Table(
"rule_moments",
Base.metadata,
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
Column("moment", Text, 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 without it a
rulebook needs a workaround.
- ``co_surfaces`` — these fail together, so they must arrive together.
Without it, the only way to guarantee that is to merge them into one
row, 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. Without
it, an override is a parallel rule that 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).
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),
}
# A rule's home is its scope: a rule in a rulebook topic is global, a rule on
# a project applies to that project, and retrieval reads that directly. There
# are no per-project subscriptions or suppressions (migration 0101 dropped
# those tables); a project that departs from a global rule writes a project
# rule with an `overrides` relation.