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 (note 3026): it is the readable form of # the canon tag, the half of the document that makes a rule findable by # meaning, and — since milestone 394 removed the 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. `tier` used to sit beside this and # carry delivery; milestone 394 removed it, so kind is now the only axis # on a rule and 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, rule 36). 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. 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 "", # 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 (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)), ) # `project_rulebook_exclusions` lived here until milestone 394. It recorded a # project's opt-out of a whole always-on rulebook — which only made sense # while a rulebook could bind a project WITHOUT being asked. Subscription is # now the only reach a rulebook has, so declining one is expressed by not # subscribing, and there is nothing left to opt out of. 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)), )