canonical label identity — one stable workspace-scoped label id referenced by tasks
Problem Statement
Today a label's identity is resolved by its mutable row fields — name and (task_id, name) for
assignments — instead of a stable canonical identifier. The label table already scopes labels to
a workspace (workspace_id on every row), but the identity itself is implicit and inconsistent
across surfaces. Concretely:
- Task-level-only labels bypass the management UI entirely. Labels created through the API/MCP
with ataskId(and no workspace definition) create task-level rows that never appear under
Settings > Workspace > Labels (the page keeps onlytaskId === null). This is the primary
management point of labels, yet those labels cannot be seen, renamed, recolored, or deleted
from the web app. That is the biggest incoherence: some labels exist only as task-level rows. - Edited source rows diverge on re-attach. After a task-level source row is renamed or
recolored, attaching the same id again can insert a second, differently-named row next to an
earlier copy, or return a stale-colored row — attach conflict resolution only matches by(task_id, current name). Reproduction: attach a label id, rename it, then attach the same id
to another task — the second row carries the new name while the first task keeps the old one:
one id, two differently-named rows. Reported by the Qodo review on #1730 as "Edited labels
diverge when reattached". - N tasks labelled "bug" produce N rows, and raw-API/CLI flows end up shaped around mutable
row ids instead of a stable label identity.
Proposed Solution
One canonical identifier per label, scoped to its workspace. A label is identified by(workspace_id, label id) — the id never changes, and the name/color become ordinary mutable
display fields. The workspace scoping already exists in the schema; the redesign changes the
identity, not the scope.
- Assignments move to a
task_labelsjoin table (task_id,label_id); label responses expose
the attached tasks as ataskIdsarray, or via a reverseGET /label/{id}/tasks, instead of
holding independent name/color snapshots. - Rename and recolor become safe by construction: they update the canonical label's fields and
follow it everywhere attached — no more cascade-vs-snapshot ambiguity, no stale-colored rows. - Attach/detach/idempotency resolve by the canonical label id, not by the mutable name.
- Every label becomes visible and manageable in Settings > Workspace > Labels, including
previously task-only ones (reconciled into workspace-scoped labels during migration). - Include the label-based task lookup proposed in #1490 (
taskIds/ a reverseGET /label/{id}/tasks), which this redesign makes trivial. - Automated tests for shared label identity, rename and recolor reattachment, and settings-page
management of previously task-only labels, plus a guarded dedupe/reconcile migration for
existing installs.
The trade-off to accept: this is a breaking data-model change (labels and assignments stop being
denormalized into one table), so it needs a migration, dedupe/reconcile scripts for existing
installations, and web/MCP/imports/webhooks updates in the same change. Two decisions the
migration carries: creating a label with a taskId becomes create-or-reuse the workspace
definition then attach (no more task-only creation), and dedupe collisions (same workspace name,
differing colors) need an explicit tie-break — default proposal: an existing workspace definition
wins, otherwise the first-created row wins.
Alternative Solutions
- Relax only the settings-page filter (show task-level rows in Settings > Workspace > Labels):
no API change and it fixes symptom 1's visibility, but nothing else — N duplicate rows still
exist, renames still resolve by mutable name, and the page becomes a list of duplicates rather
than labels. A stopgap, not a fix. - Keep the two-row storage and treat
(workspace_id, name)as a natural key (additive, minor):
every row id resolves to its logical label(workspace_id, name), attach resolves by that
identity, task-only rows get promoted into definitions. Workable if renames are rare and cascade
semantics are documented — but renames remain ambiguous precisely because the name is the key,
which is the fragility this issue is about. - The minimal behavior fix in #1730 (attach adds instead of moves) is complementary and already
removes the data-loss trap, but cannot fix this without the identity work above.
Relevant Context
Follow-up of #1490 — related to the minimal fix in #1730, which does not require this change to be correct.
- Evidence collected while working on #1730 (minimal fix for #1490): the Qodo review finding
"Edited labels diverge when reattached" (rename/recolor reattachment), the settings-page
invisibility of task-only labels (described in the #1490 thread), and the storage analysis above. - Design constraints already visible at
main: deleting a workspace definition cascades to
same-name task-level rows (delete-label.ts), renaming a definition cascades by the old name
(update-label.ts), issue imports prefer the workspace color, and migration 0020
(gitea_dedup_guards.sql) deduplicated rows before adding the constraints. In other words,(workspace_id, name)is already treated as the logical key everywhere except the schema itself —
the redesign would replace that implicit key with an explicit canonical id. - The lookup part (
taskIds/ a reverseGET /label/{id}/tasks/ alist_label_tasksMCP tool)
is already implemented on my side and can land as a PR once this is on the roadmap.
Does this feature align with Kaneo's focus on simplicity?
Yes — one obvious model (a label is a workspace-scoped entity with a stable id, tasks reference it)
instead of an implicit alias system where mutable names act as an identity and some labels are
unmanageable from the primary UI.
Originally requested by @thriz0 on 2026-09-16. Original GitHub request #1731.
ROA-941
Original GitHub comment