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
+93
View File
@@ -0,0 +1,93 @@
"""Canonical-system routes — the GLOBAL area vocabulary, and the mapping of a
project's Systems onto it (milestone 307, decision note 3026).
Two shapes live here because they are two halves of one idea:
- `/api/canonical-systems` — the catalog itself. Readable by any signed-in
user (it is shared vocabulary, not user data); writable only by an admin,
since a global list anyone can extend stops being a shared list.
- the mapping endpoints — authorised by the PROJECT, because mapping writes a
project's own System row. The service enforces both; these are thin wrappers.
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import admin_required, get_current_user_id, login_required
from scribe.routes.utils import not_found
from scribe.services import canonical_systems as canonical_svc
from scribe.services.projects import get_project_for_user
logger = logging.getLogger(__name__)
canonical_systems_bp = Blueprint("canonical_systems", __name__, url_prefix="/api")
@canonical_systems_bp.route("/canonical-systems", methods=["GET"])
@login_required
async def list_canonical_systems_route():
entries = await canonical_svc.list_canonical_systems()
return jsonify({"canonical_systems": [e.to_dict() for e in entries]})
@canonical_systems_bp.route("/canonical-systems", methods=["POST"])
@admin_required
async def create_canonical_system_route():
uid = get_current_user_id()
data = await request.get_json() or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "name is required"}), 400
entry = await canonical_svc.create_canonical_system(
uid, data["name"], description=data.get("description"),
)
if entry is None:
return jsonify({"error": "Permission denied"}), 403
# The slug duplicate gate answers with the entry that already covers the
# area rather than minting a second spelling of it — 409, not a silent
# second row (the whole point of the table).
if isinstance(entry, dict):
return jsonify(entry), 409
return jsonify(entry.to_dict()), 201
@canonical_systems_bp.route("/canonical-systems/<int:canonical_id>", methods=["PATCH"])
@admin_required
async def update_canonical_system_route(canonical_id: int):
uid = get_current_user_id()
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("name", "description", "order_index")}
entry = await canonical_svc.update_canonical_system(uid, canonical_id, **fields)
if entry is None:
return not_found("Canonical system")
return jsonify(entry.to_dict())
@canonical_systems_bp.route(
"/projects/<int:project_id>/canonical-proposals", methods=["GET"]
)
@login_required
async def propose_canonical_mappings_route(project_id: int):
"""Proposals only — this endpoint writes nothing. The PUT below applies one."""
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
return jsonify({"proposals": await canonical_svc.propose_mappings(uid, project_id)})
@canonical_systems_bp.route("/systems/<int:system_id>/canonical", methods=["PUT"])
@login_required
async def map_system_to_canonical_route(system_id: int):
"""Map or unmap one System. Body: {"canonical_id": <id>|null}.
Sets that column and nothing else — no rename, no change to which records
are tagged to the System.
"""
uid = get_current_user_id()
data = await request.get_json() or {}
canonical_id = data.get("canonical_id")
if canonical_id is not None and not isinstance(canonical_id, int):
return jsonify({"error": "canonical_id must be an integer or null"}), 400
system = await canonical_svc.set_system_canonical(uid, system_id, canonical_id)
if system is None:
return not_found("System or canonical system")
return jsonify(system.to_dict())