feat(systems): the area vocabulary becomes a global table so a rule can point at one (#3027, milestone 307 step 1)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped

The eight standard area names already existed — as STANDARD_SYSTEMS, a tuple in
services/systems.py that milestone 297 seeds at inception. A constant cannot be
a foreign key, so nothing outside a project could reference an area: systems.
project_id is NOT NULL, and a rule that spans projects would have to chain
itself to one project's row. And because the list only ever applied on the
inception-seed path, three spellings of one area reached this instance anyway
(CI & runners / CI and Release / CI & release).

- canonical_systems: global, no user_id — a shared project inherits the
  vocabulary instead of re-earning it. Migration 0087 seeds the same eight.
- systems.canonical_id: nullable, SET NULL. Association only — no System is
  renamed and record_systems is untouched, so no record's tags move.
- canonical_slug folds &/and, case and punctuation, so spelling variants map
  mechanically and a real difference ("CI & runners") becomes a proposal a
  human confirms. propose_mappings reports; set_system_canonical is the only
  writer.
- seed_standard_systems now reads the catalog and maps as it mints, so a
  project born standard never needs a reconciliation pass.
- Catalog writes are admin-only; reads are open — a global list anyone can
  extend stops being shared.
- backup: carried by SLUG, not id (ids are per-install). Restore reuses the
  target's own rows and only creates entries an admin added on the source; an
  unknown slug restores unmapped rather than failing.

Rule 22: STANDARD_SYSTEMS is removed, not deprecated. Rule 115: nothing seeded
names an app, repo or house convention. Design in note 3026.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 12:31:18 -04:00
co-authored by Claude Opus 5
parent a8f35e465e
commit a97547fbc6
14 changed files with 800 additions and 38 deletions
+18 -5
View File
@@ -57,9 +57,22 @@ def test_normalize_choices_is_canonical_and_complete():
"design_system_id": None, "seed_systems": False}
def test_standard_systems_vocabulary_is_one_list_for_ask_and_seed():
from scribe.mcp.tools.systems import _STANDARD_SYSTEMS
from scribe.services.systems import STANDARD_SYSTEMS
assert _STANDARD_SYSTEMS == tuple(n for n, _ in STANDARD_SYSTEMS)
assert len(STANDARD_SYSTEMS) == 8 and all(charter for _, charter in STANDARD_SYSTEMS)
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
"""The vocabulary moved from a module constant to the global catalog table
(milestone 307): a constant cannot be a foreign key, so nothing outside a
project could reference an area. The seed and the bootstrap ask must both
read the table, or the list they show and the list they mint diverge."""
import inspect
from scribe.services import systems as systems_svc
assert not hasattr(systems_svc, "STANDARD_SYSTEMS"), (
"the constant is gone — the catalog table is the single source"
)
source = inspect.getsource(systems_svc.seed_standard_systems)
assert "list_canonical_systems" in source
assert "canonical_id=entry.id" in source, (
"a seeded System must be mapped as it is created, or a project born "
"from the standard set still needs a reconciliation pass"
)
+7 -2
View File
@@ -13,6 +13,7 @@ from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
from scribe.services import inception as inception_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import canonical_systems as canonical_svc
from scribe.services import systems as systems_svc
from tests.helpers import ensure_user
@@ -62,7 +63,11 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
})
assert out["effects"]["excluded"] == [seeded["always"]]
assert out["effects"]["subscribed"] == [seeded["other"]]
assert len(out["effects"]["systems_seeded"]) == len(systems_svc.STANDARD_SYSTEMS)
catalog = await canonical_svc.list_canonical_systems()
assert len(out["effects"]["systems_seeded"]) == len(catalog)
# Seeded Systems come out mapped, not needing a later reconciliation.
seeded_systems = await systems_svc.list_systems(owner, pid)
assert all(s.canonical_id is not None for s in seeded_systems)
# The exclusion is total: the project's always-on set is empty, the
# departure is named, the subscription binds.
@@ -81,7 +86,7 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == []
assert len(await systems_svc.list_systems(owner, pid)) == len(systems_svc.STANDARD_SYSTEMS)
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
+64
View File
@@ -0,0 +1,64 @@
"""The canonical-slug matcher — the line between what maps mechanically and
what a human is asked to confirm (milestone 307, note 3026).
These cases are the real drift found across the author's own instance: one area
carrying three spellings, and two areas that LOOK alike and are not the same.
Getting the boundary wrong in either direction is a silent failure — a missed
mapping nobody thinks to make again, or a cross-project record surfacing in the
wrong project.
"""
import pytest
from scribe.services.canonical_systems import canonical_slug
@pytest.mark.parametrize("name", ["CI & Release", "CI and Release", "CI & release",
" ci and release "])
def test_spelling_variants_of_one_area_collapse_to_one_key(name):
"""Case, spacing, punctuation and "&" vs "and" are not real differences.
All three of the first spellings were live in different projects at once."""
assert canonical_slug(name) == "ci-and-release"
@pytest.mark.parametrize("name,expected", [
("Auth & Access", "auth-and-access"),
("Data Model & Storage", "data-model-and-storage"),
("UI & Design", "ui-and-design"),
("Background Jobs", "background-jobs"),
("Observability", "observability"),
])
def test_the_seeded_vocabulary_slugs_match_the_migration(name, expected):
"""Migration 0087 writes these slugs literally. If the function and the
migration disagree, every seeded entry becomes unreachable by exact match
and every mapping silently degrades to a proposal."""
assert canonical_slug(name) == expected
@pytest.mark.parametrize("a,b", [
("CI & Release", "CI & runners"),
("Auth & Access", "Auth & Accounts"),
("UI & Design", "Frontend (Vue app)"),
("UI & Design", "Web Shell and Theme"),
])
def test_genuinely_different_names_do_not_collapse(a, b):
"""These pairs may or may not be the same area — that is a judgment call,
so they must NOT map automatically. They reach the operator as proposals."""
assert canonical_slug(a) != canonical_slug(b)
def test_a_nameless_system_yields_no_key():
"""An empty slug is the one value callers must special-case: two unnameable
Systems must not map onto each other. Both propose_mappings and find_by_name
bail on a falsy slug for this reason."""
assert canonical_slug("") == ""
assert canonical_slug(" ") == ""
assert canonical_slug("---") == ""
def test_a_punctuation_separator_is_not_read_as_the_word_and():
""""CI/Release" is a real spelling and it does NOT collapse onto
"CI & Release" — only "&" carries that meaning. It still reaches the
operator through the overlap path, where both words match; what it must not
do is map itself silently."""
assert canonical_slug("CI/Release") == "ci-release"
assert canonical_slug("CI/Release") != canonical_slug("CI & Release")