CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Successful in 36s
A project's inheritance becomes a decision, not a default (milestone 297).
- projects.inception (JSONB, NULL = undecided): {decided_at, decided_by, via
mcp|ui|legacy, choices {exclude_always_on_rulebooks, subscribe_rulebooks,
design_system_id, seed_systems}}; on to_dict.
- project_rulebook_exclusions: a project's opt-out of a whole always-on
rulebook — the sibling of the rule/topic suppressions, CASCADE both ways.
- services/inception.py (first cut): the vocabulary, validate_inception
(pure, all-or-nothing), normalize_choices, is_decided. Effects come in
step 3.
- Migration 0085 backfills every existing project via="legacy" with its
current standing (no exclusions, its subscriptions, its design_system_id,
no seed) so the ask fires only for projects created after this ships.
- Backup v10: rulebook_exclusions section; project rows carry inception and
design_system_id, restored in a post-pass once rulebooks/design systems are
mapped (design_system_id was not restored before — fixed in passing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
6.1 KiB
Python
152 lines
6.1 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from scribe.models import Base
|
|
from scribe.models.base import 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)
|
|
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
how_to_apply: 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,
|
|
"topic_id": self.topic_id,
|
|
"project_id": self.project_id,
|
|
"title": self.title,
|
|
"statement": self.statement,
|
|
"why": self.why or "",
|
|
"how_to_apply": self.how_to_apply or "",
|
|
"order_index": self.order_index,
|
|
"created_at": iso(self.created_at),
|
|
"updated_at": iso(self.updated_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)),
|
|
)
|