"""canonical_systems — the global area vocabulary, promoted from a constant to a table (milestone 307 step 1, decision note 3026) Revision ID: 0087 Revises: 0086 Create Date: 2026-08-26 The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in services/systems.py that milestone 297 seeds into a project at inception. A constant cannot be referenced: a rule that applies across projects has nothing to point at, because `systems.project_id` is NOT NULL and a family rule cannot be chained to one project's row. This makes the vocabulary a table so it can be a foreign key, and adds the nullable `systems.canonical_id` that maps a project's local System onto it. Deliberately no `user_id`: the catalog is GLOBAL so a shared project inherits the vocabulary rather than re-earning it. `record_systems` is untouched — it joins note_id/system_id and never sees this table, so no association data moves, and no System's own `name` is rewritten. The seed rows are written here verbatim rather than imported from the service: a migration is a historical record and must keep running unchanged after the service's list moves on. """ import sqlalchemy as sa from alembic import op revision = "0087" down_revision = "0086" branch_labels = None depends_on = None # (name, slug, description) — the milestone-297 vocabulary, with the slug the # service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics # collapsed to "-"). Charters stay generic on purpose: a project refines its # own System's description, never this one. Nothing here names an app, a repo, # a vendor or a house convention — the catalog ships to every install (rule 115). _SEED = ( ("CI & Release", "ci-and-release", "How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."), ("Auth & Access", "auth-and-access", "Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."), ("Data Model & Storage", "data-model-and-storage", "What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."), ("API Surface", "api-surface", "The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."), ("UI & Design", "ui-and-design", "What people see and touch: views, components, client state, and the design tokens/recipes they are built from."), ("Import & Export", "import-and-export", "Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."), ("Background Jobs", "background-jobs", "Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."), ("Observability", "observability", "How the system reports on itself: logging, metrics, audit trails, health and diagnostics."), ) def upgrade() -> None: canonical_systems = op.create_table( "canonical_systems", sa.Column("id", sa.Integer(), primary_key=True), sa.Column("name", sa.Text(), nullable=False), sa.Column("slug", sa.Text(), nullable=False), sa.Column("description", sa.Text(), nullable=True), sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), sa.Column("deleted_batch_id", sa.Text(), nullable=True), ) # Unique among LIVE rows only, so a soft-deleted entry doesn't block # recreating or restoring the same area (the rules/topics convention). op.create_index( "uq_canonical_systems_slug", "canonical_systems", ["slug"], unique=True, postgresql_where=sa.text("deleted_at IS NULL"), ) op.bulk_insert( canonical_systems, [ {"name": name, "slug": slug, "description": description, "order_index": index} for index, (name, slug, description) in enumerate(_SEED) ], ) op.add_column( "systems", sa.Column("canonical_id", sa.Integer(), nullable=True), ) # SET NULL, not CASCADE: retiring a catalog entry must never delete a # project's System along with it. op.create_foreign_key( "fk_systems_canonical_id", "systems", "canonical_systems", ["canonical_id"], ["id"], ondelete="SET NULL", ) op.create_index("ix_systems_canonical_id", "systems", ["canonical_id"]) # Existing Systems are left UNMAPPED on purpose. An exact-slug match would # be safe, but a near miss ("CI & runners" vs "CI & Release") is a judgment # call — those go through the propose/confirm path so a human approves each # one, rather than being decided by a migration nobody reviews. def downgrade() -> None: op.drop_index("ix_systems_canonical_id", table_name="systems") op.drop_constraint("fk_systems_canonical_id", "systems", type_="foreignkey") op.drop_column("systems", "canonical_id") op.drop_index("uq_canonical_systems_slug", table_name="canonical_systems") op.drop_table("canonical_systems")