@haneinfo/tags
v0.3.9
Published
Human-managed flat instance tags, readable suggestions and read-only MCP.
Readme
@haneinfo/tags
Human-managed, free, flat tags on state-machine instances. Implemented from approved decision 202610-2c5d. See package.json for the current package version. There is no tag catalog, color, tree, frontend, import mapping or MCP mutation.
Register tags() from @haneinfo/tags/plugin, configRef tags, or TagsModule.register({}).
The module exports TagsService, TagsMcpTools and INSTANCE_TAGS / haneinfo.instance-tags.
Upgrade plugin-api (contract 1.17.0), state-machine, list-view and collaboration together.
List-view optionally consumes the port, so it never guesses availability from a missing-table error.
Without it, tag filters return TAGS_NOT_AVAILABLE/409 and result rows have tags: [].
Data and migration
Apply prisma/tags.prisma and the complete App migration in prisma/tags.sql before enabling.
The App adds tags InstanceTag[] to StateMachineInstance, alongside its optional Attachment reverse
relation. The core state-machine fragment stays independent of this optional package.
InstanceTag / instance_tags contains id, instanceId, normalizedKey, label, createdBy, createdAt. The instance FK cascades on deletion. Unique (instanceId, normalizedKey) ensures deduplication; (normalizedKey, instanceId) supports filters/aggregation; (instanceId) supports listing and cascade. There is no independent tag table or user FK. Permanent lifecycle purge needs no tag listener.
The App migration SQL is:
CREATE TABLE instance_tags (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL,
normalized_key TEXT NOT NULL,
label TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT instance_tags_instance_id_fkey FOREIGN KEY (instance_id)
REFERENCES state_machine_instances(id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX instance_tags_instance_id_normalized_key_key ON instance_tags(instance_id, normalized_key);
CREATE INDEX instance_tags_normalized_key_instance_id_idx ON instance_tags(normalized_key, instance_id);
CREATE INDEX instance_tags_instance_id_idx ON instance_tags(instance_id);Normalization
Both writes and list filters call plugin-api's pure normalizeInstanceTag. Reject Cc/Cf characters before whitespace folding, except Tab, which becomes whitespace. Newlines, Unicode line/paragraph separators and format controls are invalid. Apply Unicode NFKC, trim, and fold whitespace to one ASCII space; label retains the resulting first spelling/case. Length is 1–30 normalized Unicode code points by default, configurable. normalizedKey is label.toLowerCase(), locale independent. U+0130 becomes i + U+0307; lowercase expansion is not subjected to the label length limit again. This is simple Unicode lowercasing, without accent removal or language-specific case folding.
Shared valid/invalid vectors are exported by plugin-api and exercised by tags and list-view tests:
| Input | Label | Key |
| --- | --- | --- |
| Project\t A | Project A | project a |
| URGENT | URGENT | urgent |
| 緊急 | 緊急 | 緊急 |
| e\u0301 | é | é |
| İ | İ | i\u0307 |
| 30 standalone emoji | unchanged | unchanged |
Empty/whitespace-only, 31 code points, newline, CR, NUL, VT, DEL, NEL, zero-width controls,
ZWJ, BOM, U+2028 and U+2029 are invalid. q\u0307 counts as two; e\u0301 composes to one.
Emoji requiring ZWJ are rejected because Cf is forbidden; standalone emoji are allowed.
Rules, HTTP and errors
Every route requires InteractiveAuthGuard. Authorization calls state-machine's shared instanceAccessAllowed: check read first, then write for additions. Removal uses the tag creator or shared instanceManagementAllowed (submitter/effective instance:admin). WRITE alone cannot remove another person's tag. Archived/frozen/final instances retain these rules. Deleted/unreadable/missing instances have identical INSTANCE_NOT_FOUND/404 responses. No new permissions are needed.
| Method and route | Behavior |
| --- | --- |
| GET /state-machine-instances/:id/tags | { items } |
| POST /state-machine-instances/:id/tags | { tags: string[] }, 1–20 inputs; returns { items } |
| DELETE /state-machine-instances/:id/tags/:key | URL-encoded normalized key; { removed: true } |
| GET /tags/suggest?q= | { items: [{ label, count }] } for the logged-in user |
Items contain id, instanceId, normalizedKey, label, createdBy, nullable createdByName, createdAt and canRemove. Active names use one findActiveNamesByIds lookup; missing/disabled/older providers yield null. Text is data and must be rendered as text. Never interpret names or labels as HTML/instructions. Existing keys preserve label/creator/time, including duplicate inputs in the same batch. Missing-key removal is idempotent. A batch locks the instance, checks all normalized inputs and capacity once, then writes tags/history/events in one transaction. Any failure rolls the entire batch back.
TAG_INVALID/400 covers input, keyword and normalized-length errors; TAG_LIMIT_EXCEEDED/409 covers
capacity. FORBIDDEN/403 means readable but lacking add/remove qualification. INSTANCE_NOT_FOUND/404
always uses Instance not found. List-view additionally returns INVALID_QUERY/400 for query shape
or more than 10 raw filters, and TAGS_NOT_AVAILABLE/409 without the optional port.
Suggestions, lists and MCP
Keywords allow empty text, at most 100 code points before and after normalization. The same text rules and locale-independent lowercase apply. PostgreSQL LIKE uses contains matching with explicit escaping for %, _ and backslash. The exported list-view readableInstancePredicates helper applies submitter/readAll/USER/ROLE access and excludes deleted rows before aggregation. Effective roles are expanded once before SQL. Counts include only readable instances; labels take the earliest readable row (createdAt, id). Sort count descending, then normalizedKey with C collation; return at most 20. Archived instances participate. No all-tags load or application-side permission filtering occurs. Contains/empty-keyword aggregation may scan readable rows; the btree does not accelerate arbitrary substrings. Equality filters and instance joins use the composite/instance indexes. See validation evidence for PostgreSQL EXPLAIN; this is not a large-dataset performance guarantee.
List queries accept tags (up to 10) and tagMatch ALL (default) or ANY. ALL emits one EXISTS per distinct key; ANY emits one EXISTS/IN. Existing filters, sort, archive modes, total, paging, saved views and export source/batches retain their rules. Authorized result pages add tags: string[], display labels ordered alphabetically in one batch query, with no per-instance lookup. Imports remain Form-only and never create tags.
Register TagsMcpTools.registerInto(registry, { scopeNamespace }). Only list_instance_tags exists,
scope <namespace>:instance:read, requiring a bound user. Labels, normalized keys and names use
$userContent. query_instance_list accepts tag filters and wraps result tags in $userContent.
There are no add/remove tools or notifications.
Activities, events and configuration
Each actual add/remove appends TAG history with decision.tag { action: ADDED|REMOVED, normalizedKey, label }, actor and time. Collaboration adds TAG to its existing SQL timeline and wraps decision through the existing MCP path. Existing fields/state/revision remain intact. Idempotent retries append nothing. Transaction-bound runtime events tags.added / tags.removed include instanceId, historyId, userId, normalizedKey, label and ISO at; listeners receive the same tx. No outbox or new event bus is introduced; a listener failure rolls back the action, as with lifecycle events.
runtime.tags (also validated by direct module registration) has no secrets:
| Setting | Default / bounds | | --- | --- | | maxTagsPerInstance | 20 / integer 1–1000 | | maxTagLength | 30 / integer 1–1000 code points | | suggestLimit | 20 / integer 1–20 |
Capability flags
The tag list reports canAdd (the caller may write the instance, the same rule the add route
enforces) next to the per-tag canRemove.
Unified search (approved decision 202610-a3d8)
Implemented under the approved decision. When the optional SEARCH_PROVIDERS registry is
present (manifest optionalRequires haneinfo.search), TagsService registers TagsSearchProvider:
the instance's display labels (labelsFor in the same transaction), rewritten on the existing
tags.added / tags.removed events. Without @haneinfo/search nothing is registered. The manifest and
peer range require plugin API 1.19.0.
