feat(design-systems): the model, the parent chain, and the guard on it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 20s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 27s

Milestone #254 step 1 (#2286). A design system becomes a record Scribe holds
rather than prose in a rulebook: a named set of tokens with an OPTIONAL parent,
so a family system carries the house style and an app system carries only what
it changes. Answering "what does this app alter?" is then `list its tokens` —
nothing to compute.

`parent_id` is the whole model. It replaces both an `always_on` flag (a family
system is one with no parent) and a subscription join table (a project points at
ONE system; the chain supplies the rest) — less schema than the rulebook shape
it mirrors.

Two decisions the task left open, settled here:

- **Token values are JSONB keyed by mode**, not `value_light`/`value_dark`
  columns. The deciding argument was not flexibility, it was ambiguity: in a
  child system an unset mode means "inherit", in a root it means "not
  mode-dependent", and as columns both are NULL and the resolver cannot tell
  them apart. As a map, resolution is `{**parent, **child}` at every level with
  no special case for roots. Against it: queryability — but nothing filters
  tokens by value in SQL, so that buys a query no caller makes.
- **`group_name` is free text, no CHECK enum.** Groupings are each design
  system's own vocabulary; a whitelist would bake one install's kit into the
  schema. No CHECK is introduced anywhere, so rule #36 does not fire.

The cascade lives in `services/design_cascade.py` as pure functions over a
`{id: parent_id}` map, importing nothing — which is what lets both the service
and `access.py` use it without a cycle, and lets a test state a whole hierarchy
in one literal. Cycles are refused on WRITE by walking up from the proposed
parent (the cheap direction), and survived on READ by a visited-set, because a
loop from a direct DB edit must truncate rather than hang.

ACL (rule #78) is deliberately asymmetric: owning a system grants write,
reaching one through a project you can see grants READ ONLY. An editor on a
shared project must not be able to rewrite the family system every other project
in that family resolves through.

Also renames `services/design_system.py` -> `design_rulebook_import.py`. It is
the #251 prose extractor, whose role is already scheduled to become a one-shot
importer (#2288), and leaving it one character away from the new
`design_systems.py` was a trap for every later session.

Rule #115 throughout: nothing seeds a system or implies a default. An install
with zero design systems is ordinary, not degraded.
This commit is contained in:
2026-07-30 16:54:41 -04:00
parent 4ca3ab02c4
commit 03b3998585
13 changed files with 1022 additions and 3 deletions
+3
View File
@@ -43,3 +43,6 @@ from scribe.models.rulebook import ( # noqa: E402, F401
)
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import ( # noqa: E402, F401
BASE_MODE, DesignSystem, DesignToken,
)
+130
View File
@@ -0,0 +1,130 @@
"""Design systems — a stylesheet held as records, inherited family -> app.
A DesignSystem is a named set of design tokens with an OPTIONAL parent, and that
single self-FK is the whole model. A system with no parent is a family system; a
system WITH one holds only what it changes. "What does this app alter?" is
therefore `list its tokens` — nothing to compute, nothing to diff — which is why
inheritance won over a flat family-plus-loose-overrides shape.
Resolution walks the chain and lets the deepest system win by token name. That
is the CSS cascade rather than an analogy to it, which is why the storage model
and the stylesheet model come out the same shape.
`parent_id` also replaces two things the rulebook model needs to express the same
idea: an `always_on` flag (a family system is simply one with no parent) and a
subscription join table (a project points at ONE system, and the chain supplies
the rest). Less schema for more structure.
"""
from sqlalchemy import BigInteger, ForeignKey, Index, Integer, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin
# The mode key that applies when no more specific one does. Emitted CSS puts it
# on the base selector and every other key on a mode selector, mirroring how a
# stylesheet is actually written: light on `:root`, dark layered over it.
BASE_MODE = "base"
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "design_systems"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
owner_user_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True
)
title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# SET NULL, not CASCADE: deleting a family system must not delete every app
# system that inherited from it. Orphaning turns each child into a root that
# still holds its own overrides — recoverable. A cascade would destroy data
# the operator never asked to touch.
parent_id: Mapped[int | None] = mapped_column(
BigInteger,
ForeignKey("design_systems.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
def to_dict(self) -> dict:
return {
"id": self.id,
"owner_user_id": self.owner_user_id,
"title": self.title,
"description": self.description or "",
"parent_id": self.parent_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
"""One custom property in one system: its name, and its value per mode.
`value_by_mode` is a JSONB map of mode -> value, e.g.
`{"base": "#f7f5ef", "dark": "#14171a"}`. Three reasons it beats a pair of
`value_light` / `value_dark` columns here:
- **Absence means one thing.** In a child system an unset mode means
"inherit"; in a root it would have to mean "not mode-dependent". With
columns those are both NULL and the resolver cannot tell them apart. With
a map, resolution is `{**parent_map, **child_map}` at every level —
one rule, no special case for roots.
- **Per-mode overrides are already real.** A palette rule in this operator's
own kit deepens one accent on light backgrounds for contrast while
leaving the dark value alone. Mode is a second override axis, not a
second column.
- **Nothing filters tokens by value in SQL.** Drift comparison resolves the
set first and compares in the client; the importer diffs in Python. The
queryability columns would buy is for a query no caller makes.
The cost is real — a third mode is data rather than schema, so the DB will
not reject a typo'd mode key. That is the trade taken.
NOT NULL with a `{}` default deliberately: a JSONB column otherwise has two
empty states (SQL NULL and JSON null) and code has to test for both.
"""
__tablename__ = "design_tokens"
__table_args__ = (
# Partial unique: a name is unique among LIVE tokens in a system, so a
# trashed token doesn't block recreating the same name. Two live rows
# named `--fs-obsidian` in one system is a duplicate definition, and the
# cascade would pick between them arbitrarily.
Index(
"uq_token_per_design_system", "design_system_id", "name",
unique=True, postgresql_where=text("deleted_at IS NULL"),
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
design_system_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(Text)
# Named for what it holds rather than the bare word `values`, which is
# reserved in SQL — the same reason `group_name` is not `group`.
value_by_mode: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb")
)
# `group` is a reserved word in SQL; `group_name` throughout — model, column
# and payload — so no layer has to remember which spelling it is on.
# Free text, not a CHECK enum: groupings are the design system's own
# vocabulary, and a whitelist would bake one install's kit into the schema.
group_name: Mapped[str | None] = mapped_column(Text, nullable=True)
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
def to_dict(self) -> dict:
return {
"id": self.id,
"design_system_id": self.design_system_id,
"name": self.name,
"value_by_mode": self.value_by_mode or {},
"group_name": self.group_name,
"purpose": self.purpose,
"order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+8 -1
View File
@@ -1,5 +1,5 @@
import enum
from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin
@@ -21,6 +21,12 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
goal: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="active")
color: Mapped[str | None] = mapped_column(Text, nullable=True) # hex color
# The design system this project's UI is built from, or NULL. NULL is the
# ordinary state, not a degraded one — most installs have no design system
# at all and nothing may assume one exists.
design_system_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True
)
def to_dict(self) -> dict:
return {
@@ -31,6 +37,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"goal": self.goal,
"status": self.status,
"color": self.color,
"design_system_id": self.design_system_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}