Skip to content

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.

Two event-sourced entities, no relational pivot and no JOIN:

  • tag (read_tags) — the per-tenant catalog. name, optional color (hex or token, render-only — no enforcement), optional scope (entity-type, empty = global). CRUD via create-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.

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.

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.

Mount the client plugin once (for the components + i18n), then drop the pieces where you need them:

client.tsx
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 once
createKumikoApp({ clientFeatures: [tagsClient()] });

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");

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 } } }

A note's edit screen with the TagSection extension: colored chips and an Edit-tags button

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 } } }

A note list with tag chips on each row and the drop-in TagFilter in its toolbar header

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.

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} />

The feature is driven entirely by dispatching its handlers — nothing is wired into the tagged entity.

HandlerPayloadNotes
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>" }

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 model
createTagsFeature({ access: { openToAll: true } });

Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine — no host-side hook.

  • 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 select field 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.