Tags
A bundled feature that turns any entity into a taggable one — without adding a column, a join table, or a migration. Tags are colored, optionally scoped to an entity type, managed from one shared screen, and filterable from any list. GitLab labels for your entities.
Status: ✅ Stable
What: A per-tenant tag catalog plus host-agnostic assignments. A tag
carries a name, an optional color (rendered as a contrast-aware chip),
and an optional scope (empty = global, or an entity type). Attach any
number of tags to any entity by (entityType, entityId); read them back by
listing assignments. Manage the catalog from a shared screen, edit an
entity’s tags from a drop-in section, and filter any list by tag.
When you reach for it: You want a shared, free-form labelling vocabulary across records — priorities, topics, statuses — that tenants curate themselves, render in color, and filter on, without you hand-rolling a join table and a tag UI per feature.
How it works
Section titled “How it works”Two event-sourced entities, no relational pivot and no JOIN:
tag(read_tags) — the per-tenant catalog.name, optionalcolor(hex or token, render-only — no enforcement), optionalscope(entity-type, empty = global). CRUD viacreate-tag,update-tag,delete-tag.tag-assignment(read_tag_assignments) — host-agnostic join rows keyed by(entityType, entityId). The framework projects both tables from their own CRUD events, so tagging adds no column to the host entity.
Cross-entity views compose in the read-layer — which tags does this entity
have? lists assignments filtered on entityId; which entities carry this
tag? filters on tagId. No JOIN, no pivot.
Idempotent assignment
Section titled “Idempotent assignment”An assignment’s aggregate-id is derived deterministically from
(tenantId, tagId, entityType, entityId), so there is exactly one row per
(tag, entity) and assigning twice is a no-op. Removing is a soft-delete; a
later re-assign restores the stream rather than colliding with it.
Global vs scoped
Section titled “Global vs scoped”A tag with empty scope is global — offered on every entity. A scope
of an entity type (e.g. note) restricts the tag to that type in the
picker. This is GitLab’s group-vs-project label parity: pick the breadth per
tag, not per catalog. Scope is a picker hint only — there is no enforcement
on assign.
Web UI
Section titled “Web UI”Mount the client plugin once (for the components + i18n), then drop the pieces where you need them:
import { runDevApp } from "@cosmicdrift/kumiko-dev-server";import { createTagsFeature } from "@cosmicdrift/kumiko-bundled-features/tags";import { tagsClient } from "@cosmicdrift/kumiko-bundled-features/tags/web";
// server: mount the feature (roles default to TenantAdmin/TenantMember)await runDevApp({ features: [createTagsFeature()] });
// client: register the components + translations oncecreateKumikoApp({ clientFeatures: [tagsClient()] });The management screen
Section titled “The management screen”tagsClient() registers a custom tag-list screen rendering TagManager —
the GitLab-style catalog: every label with its color, scope, and live usage
count, plus create / recolor / re-scope / delete. Place it in your nav:
r.nav("tags:screen:tag-list");TagSection — edit an entity’s tags
Section titled “TagSection — edit an entity’s tags”A drop-in editor: colored chips for the assigned tags plus an Edit tags
button that opens the shared TagPicker modal (pick existing + create new +
manage). Applying the picker diffs the selection and runs idempotent
assign/remove writes — it owns its own state, it is not part of the host
form’s save. Mount it standalone or as an entityEdit extension section:
import { TagSection, TAGS_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/tags/web";
// standalone — drop it into any screen:<TagSection entityName="note" entityId={noteId} />
// or as an extension section in an entityEdit screen schema:{ kind: "extension", title: "Tags", component: { react: { __component: TAGS_SECTION_EXTENSION_NAME } } }
TagFilter — filter any list by tag
Section titled “TagFilter — filter any list by tag”A drop-in header-slot control. Register it as an entityList’s header slot
and picking tags narrows the list to the rows carrying them — via an id-set
url-filter, with no host-schema change. The picked tags show as colored
chips with a clear button, so the active filter stays visible. Any list opts in
by mounting it:
import { TAGS_FILTER_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/tags/web";
// in an entityList screen schema:slots: { header: { react: { __component: TAGS_FILTER_EXTENSION_NAME } } }
TagsCell — tags inline on list rows
Section titled “TagsCell — tags inline on list rows”Show an entity’s tags as colored chips right in its entityList rows. TagsCell
is a reusable column renderer registered by tagsClient(); mount it as a
virtual labeled column — no host-schema change, no new entity field:
import { TAGS_COLUMN_RENDERER_NAME } from "@cosmicdrift/kumiko-bundled-features/tags/web";
// in an entityList screen's columns:columns: [ "title", { field: "tags", label: "Tags", renderer: { react: { __component: TAGS_COLUMN_RENDERER_NAME } } },]The labeled virtual-column mechanism is generic — any feature can render a component column (status badges, avatars, …) the same way, not just tags.
EntityTags — read-only chips
Section titled “EntityTags — read-only chips”For cards and detail views: a read-only colored chip row for an entity’s tags. Renders nothing when the entity has none.
import { EntityTags } from "@cosmicdrift/kumiko-bundled-features/tags/web";
<EntityTags entityName="note" entityId={noteId} />Handlers
Section titled “Handlers”The feature is driven entirely by dispatching its handlers — nothing is wired into the tagged entity.
| Handler | Payload | Notes |
|---|---|---|
tags:write:create-tag | { name, color?, scope? } | Mints a fresh tag id |
tags:write:update-tag | { id, version, name?, color?, scope? } | Optimistic-locked rename / recolor / re-scope |
tags:write:delete-tag | { id } | Cascades over the tag’s assignments |
tags:write:assign-tag | { tagId, entityType, entityId } | Idempotent |
tags:write:remove-tag | { tagId, entityType, entityId } | Idempotent |
Reads are the two list queries — tags:query:tag:list (the catalog) and
tags:query:tag-assignment:list (filter on entityId or tagId).
// Create a colored, note-scoped tag …{ "name": "important", "color": "#2563eb", "scope": "note" }
// … then attach it to any entity by (type, id) — no column on that entity{ "tagId": "<tag-uuid>", "entityType": "note", "entityId": "<note-uuid>" }Access control
Section titled “Access control”Every tag write/read path uses one access rule. The default is
{ roles: ["TenantAdmin", "TenantMember"] }. Apps with a different role
vocabulary adopt their own model — otherwise the hard-wired handlers are
access_denied for their users:
// pin roles (e.g. a global SystemAdmin operator manages tags) …createTagsFeature({ roles: ["TenantAdmin", "TenantMember", "SystemAdmin"] });
// … or adopt the host's whole modelcreateTagsFeature({ access: { openToAll: true } });Pass { toggleable: { default: false } } to make the whole feature
tier-gatable via the tier-engine — no
host-side hook.
When not to use tags
Section titled “When not to use tags”- The label drives business logic (
if status === "blocked" then …) — that’s a first-class field on the entity, not a free-form tag. - You need a fixed, enforced set per entity type — tags are a free list;
use a
selectfield with an enum if the vocabulary is closed and required. - You need cross-entity DB joins on the tag — assignments are an event-sourced projection composed in the read-layer, not a foreign key.
See also
Section titled “See also”- Bundled-features overview
- Recipe
recipes-tags-basic— the full create → assign → read → remove flow against a plainnoteentity.