feat(tags): system tags — is_system column, seeded hygiene tags, protection guards
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Failing after 4m49s

Training hygiene step 1 (milestone #128). Migration 0075 adds
tag.is_system and seeds wip / banner / editor screenshot (kind=general),
ADOPTING an existing same-(name,kind) tag case-insensitively instead of
duplicating. These rows drive the upcoming training exclusions, so they
are protected: rename and merge-away refuse system tags (merge-INTO
stays allowed — folding an operator's old hygiene tag into the system
row is the intended move; merge is the only tag-delete path, so that
guard covers deletion). is_system rides every tag serialization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-02 23:14:49 -04:00
parent 581b778528
commit e9891ee9f3
7 changed files with 187 additions and 7 deletions
+2
View File
@@ -324,6 +324,7 @@ async def get_tag(tag_id: int):
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
"is_system": tag.is_system,
}
)
@@ -390,6 +391,7 @@ async def update_tag(tag_id: int):
"name": tag.name,
"kind": tag.kind.value,
"fandom_id": tag.fandom_id,
"is_system": tag.is_system,
}
)
+10
View File
@@ -10,6 +10,7 @@ from datetime import datetime
from enum import StrEnum
from sqlalchemy import (
Boolean,
CheckConstraint,
Column,
DateTime,
@@ -17,6 +18,7 @@ from sqlalchemy import (
Integer,
String,
Table,
false,
func,
)
from sqlalchemy import (
@@ -74,6 +76,14 @@ class Tag(Base):
fandom_id: Mapped[int | None] = mapped_column(
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True, index=True
)
# System tags ship with FC (wip / banner / editor screenshot, seeded in
# migration 0075) and drive the training-hygiene exclusions: images
# carrying one are excluded from OTHER concepts' head training and from
# CCIP identity references. The mechanism keys on these exact rows, so
# they're protected from rename/merge-away/re-fandom in TagService.
is_system: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
+7 -3
View File
@@ -67,24 +67,28 @@ def fandom_join_alias():
def tag_columns(fandom_alias):
"""The canonical (id, name, kind, fandom_id, fandom_name) column set for a
tag select that outerjoins `fandom_alias` (from `fandom_join_alias()`)."""
"""The canonical (id, name, kind, fandom_id, fandom_name, is_system)
column set for a tag select that outerjoins `fandom_alias` (from
`fandom_join_alias()`)."""
return [
Tag.id,
Tag.name,
Tag.kind,
Tag.fandom_id,
fandom_alias.c.name.label("fandom_name"),
Tag.is_system,
]
def serialize_tag(row) -> dict:
"""Serialize a row carrying .id/.name/.kind/.fandom_id/.fandom_name to the
canonical tag dict. `kind` may be a TagKind enum or a plain string."""
canonical tag dict. `kind` may be a TagKind enum or a plain string.
`is_system` defaults False for callers whose selects predate the column."""
return {
"id": row.id,
"name": row.name,
"kind": row.kind.value if hasattr(row.kind, "value") else row.kind,
"fandom_id": row.fandom_id,
"fandom_name": row.fandom_name,
"is_system": bool(getattr(row, "is_system", False)),
}
+14
View File
@@ -330,6 +330,12 @@ class TagService:
tag = await self.session.get(Tag, tag_id)
if tag is None:
raise TagValidationError(f"Tag {tag_id} not found")
# The training-hygiene machinery keys on system tags by row; a rename
# would silently disarm it (milestone #128).
if tag.is_system:
raise TagValidationError(
f"'{tag.name}' is a system tag and cannot be renamed"
)
# Case-insensitive clash (#701) — renaming onto a differently-cased tag
# is still a merge, not a silent fork.
@@ -528,6 +534,14 @@ class TagService:
source = await self.session.get(Tag, source_id)
if source is None:
raise TagValidationError(f"Tag {source_id} not found")
# Merge deletes the source row — the only tag-delete path — and the
# training-hygiene machinery keys on system rows (milestone #128).
# Merging INTO a system tag stays allowed (adopting an operator's old
# 'wip sketch' tag into the system 'wip' is the intended move).
if source.is_system:
raise TagValidationError(
f"'{source.name}' is a system tag and cannot be merged away"
)
target = await self.session.get(Tag, target_id)
if target is None:
raise TagValidationError(f"Tag {target_id} not found")