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
+94
View File
@@ -0,0 +1,94 @@
"""The parent chain: walking it, and refusing to close it (milestone #254 step 1).
Pure functions over a `{id: parent_id}` literal, so a whole hierarchy is one line
of setup and no database is involved. That is the reason the cascade lives in its
own import-free module — see services/design_cascade.py.
"""
from scribe.services.design_cascade import ancestry, would_cycle
# --- ancestry ---------------------------------------------------------------
def test_ancestry_returns_the_chain_deepest_first():
"""Deepest first because that is the order resolution consumes it in: the
system being resolved wins over its parent, which wins over the root."""
parents = {1: None, 2: 1, 3: 2}
assert ancestry(3, parents) == [3, 2, 1]
def test_a_root_is_its_own_whole_chain():
"""A family system has no parent, and that is an ordinary state — not an
incomplete one. It must resolve to exactly itself."""
assert ancestry(1, {1: None}) == [1]
def test_a_missing_parent_truncates_rather_than_raising():
"""A parent that was soft-deleted (or filtered out of the caller's scope) is
a shorter chain, not a failed request. The alternative — raising — would make
one deleted system break every descendant's rendering."""
assert ancestry(3, {3: 2}) == [3, 2]
def test_a_system_absent_from_the_map_still_yields_itself():
assert ancestry(9, {}) == [9]
def test_ancestry_terminates_on_a_cycle_instead_of_hanging():
"""LOAD-BEARING, and the reason a visited-set exists even though writes are
guarded. A loop introduced by a direct DB edit or a future bug must degrade
to a truncated chain: truncation shows up in the result, a hang shows up as
an outage. Every id appears exactly once."""
parents = {1: 3, 2: 1, 3: 2}
chain = ancestry(1, parents)
assert chain == [1, 3, 2]
assert len(chain) == len(set(chain))
def test_ancestry_terminates_on_a_self_parent():
assert ancestry(1, {1: 1}) == [1]
# --- would_cycle ------------------------------------------------------------
def test_clearing_the_parent_never_cycles():
"""None means "make this a root", which is always safe."""
assert would_cycle(2, None, {1: None, 2: 1}) is False
def test_a_system_cannot_be_its_own_parent():
assert would_cycle(1, 1, {1: None}) is True
def test_an_ordinary_reparent_is_allowed():
"""family <- app is the shape the whole model exists for; it must not trip
the guard."""
assert would_cycle(2, 1, {1: None, 2: None}) is False
def test_a_direct_swap_is_refused():
"""A -> B, then B -> A. The two-system case, and the one a UI produces first
because both systems are on screen together."""
parents = {1: None, 2: 1}
assert would_cycle(1, 2, parents) is True
def test_an_indirect_loop_is_refused():
"""Three deep: root <- mid <- leaf, then root's parent set to leaf. Catching
this is what makes the check a chain walk rather than a parent comparison."""
parents = {1: None, 2: 1, 3: 2}
assert would_cycle(1, 3, parents) is True
def test_reparenting_onto_a_sibling_subtree_is_allowed():
"""Two branches off one root. Moving one under the other is legitimate — the
guard must refuse loops, not reorganisation."""
parents = {1: None, 2: 1, 3: 1}
assert would_cycle(3, 2, parents) is False
def test_the_guard_survives_a_hierarchy_that_is_already_corrupt():
"""If a cycle somehow already exists, the guard still has to answer rather
than spin — the write path is exactly where such a hierarchy gets repaired."""
parents = {1: 2, 2: 1, 3: None}
assert would_cycle(3, 1, parents) is False
assert would_cycle(1, 2, parents) is True