feat(tags): system-tag UI markers + full protection sweep (step 4 of #128)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Failing after 3m33s

UI: shield marker + tooltip on TagChip and TagCard; system tags hide
rename/merge/delete affordances (chip kebab entirely — set-fandom never
applies to their general kind; remove stays, un-tagging is normal use).
Aliases stay available: mapping model outputs ONTO a system tag is
useful. Directory cards carry is_system.

Every destructive path that could take out a system row is now guarded,
found by sweeping run 1891s off-by-three failures — each one was a
surface that would have eaten the seeded tags:
- prune-unused: predicate exempts is_system (they ship with zero
  applications and matched every unused condition)
- reset-content: predicate exempts is_system AND keeps their
  applications — hygiene flags describe the file, not content tagging
- admin tag DELETE: refused with system_tag error
- normalize_existing_tags: scan excludes is_system — canonicalization
  would recase wip -> Wip behind TagService.rename's guard, breaking
  the name-keyed presentation lookup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-03 08:34:16 -04:00
parent 723f023e6a
commit f77e75147d
7 changed files with 121 additions and 8 deletions
+4
View File
@@ -156,6 +156,10 @@ async def tag_delete(tag_id: int):
)
except LookupError:
return _bad("not_found", status=404)
except ValueError as exc:
# System tags (#128) — the training-hygiene machinery keys on
# these rows.
return _bad("system_tag", detail=str(exc))
return jsonify(result)
+13 -3
View File
@@ -17,7 +17,7 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy import and_, delete, func, or_, select, update
from sqlalchemy.orm import Session, aliased
from ..models import (
@@ -203,6 +203,9 @@ def _unused_tag_conditions() -> list:
Tag.id.not_in(used_via_series),
Tag.id.not_in(used_via_chapter),
Tag.id.not_in(used_via_fandom),
# System tags (#128) ship with zero applications and must survive a
# prune — the training-hygiene machinery keys on the rows.
Tag.is_system.is_(False),
]
@@ -402,6 +405,8 @@ def delete_tag(session: Session, *, tag_id: int) -> dict:
tag = session.get(Tag, tag_id)
if tag is None:
raise LookupError(f"tag id not found: {tag_id}")
if tag.is_system:
raise ValueError(f"'{tag.name}' is a system tag and cannot be deleted")
associations_count = count_tag_associations(session, tag_id=tag_id)
info = {"id": tag.id, "name": tag.name, "kind": tag.kind.value}
session.delete(tag)
@@ -731,7 +736,10 @@ def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
heads retrain from whatever the operator re-tags. (The API route gates the
live run behind a preview-derived confirm token for exactly this reason.)
PRESERVED: fandom + series tags and their series_page ordering. CASCADE on
PRESERVED: fandom + series tags and their series_page ordering, AND the
system hygiene tags (#128) WITH their applications — the reset re-tags
CONTENT concepts, while wip/banner flags describe the file itself and
re-flagging hundreds of banners by hand would be pure loss. CASCADE on
image_tag / tag_alias / tag_suggestion_rejection clears each deleted tag's
applications + metadata. Tag.fandom_id is SET NULL, so deleting character
tags never touches the fandom rows. Irreversible except via DB backup
@@ -744,7 +752,9 @@ def reset_content_tagging(session: Session, *, dry_run: bool = False) -> dict:
"sample_names": [first 50],
and on live runs "deleted": total}
"""
predicate = Tag.kind.in_(RESETTABLE_TAG_KINDS)
predicate = and_(
Tag.kind.in_(RESETTABLE_TAG_KINDS), Tag.is_system.is_(False)
)
rows = session.execute(
select(Tag.id, Tag.name, Tag.kind).where(predicate)
).all()
@@ -107,6 +107,7 @@ class TagDirectoryService:
"kind": tag.kind.value if hasattr(tag.kind, "value") else tag.kind,
"fandom_id": tag.fandom_id,
"fandom_name": fandom_name,
"is_system": tag.is_system,
"image_count": int(image_count),
"preview_thumbnails": previews.get(tag.id, []),
}
+4
View File
@@ -806,6 +806,10 @@ async def normalize_existing_tags(
rows = (
await session.execute(
select(Tag.id, Tag.name, Tag.kind, Tag.fandom_id)
# System tags (#128) are exempt: their names are the keys the
# hygiene machinery matches on, and canonicalization would recase
# 'wip' → 'Wip' (this path bypasses TagService.rename's guard).
.where(Tag.is_system.is_(False))
)
).all()
groups = _group_existing_tags(rows)