Documentation

Taxonomies

A taxonomy scheme is a controlled vocabulary your whole organization shares: a named list of concepts with an optional hierarchy, localized labels, and editorial notes. Content fields assign concepts by ID, so labels and structure live in one place. Renaming a concept never rewrites a single content document.

Schemes are organization-level, not per space. They sit beside models and blocks under schemas/, their write routes need schema write permission (developer or admin), and their published versions appear in the same public manifest that generated clients use for drift checks.

Schemes and concepts

A scheme has an ID, a localized label, and a map of concepts keyed by concept ID. IDs use lowercase letters, numbers, underscores, and hyphens. They are stable and are never generated from labels.

json
code
{
  "label": { "en": "Topics", "is": "Umfjöllunarefni" },
  "description": { "en": "Editorial topics" },
  "concepts": {
    "nature": { "prefLabel": { "en": "Nature" }, "broader": [], "order": 0 },
    "geology": {
      "prefLabel": { "en": "Geology" },
      "broader": ["nature"],
      "order": 0
    },
    "volcanoes": {
      "prefLabel": { "en": "Volcanoes", "is": "Eldfjöll" },
      "altLabels": { "en": ["Volcanism"] },
      "hiddenLabels": { "en": ["vulcanoes"] },
      "definition": { "en": "Active and dormant volcanoes." },
      "related": ["glaciers"],
      "broader": ["geology"],
      "order": 1
    }
  }
}

prefLabel is the display label, and every concept needs a non-empty preferred label in the organization default locale. Readers fall back from the requested locale to the scheme's default locale. altLabels are synonyms editors see. hiddenLabels are search-only spellings: put misspellings and retired names there so the picker still finds the concept. related connects concepts without implying hierarchy. broader is the parent, written as an array holding zero or one ID, and order sets editorial order among siblings.

Where a scheme is stored

Each scheme uses one logical key, schemas/taxonomies/{schemeId}.json, in two places. The private authoring copy holds everything, including hidden labels, notes, audit data, tombstones, and unpublished edits. An explicit publish writes a sanitized public projection at the same logical key at the bucket root. The projection is an allowlist: it carries prefLabel, broader, order, and, when set, altLabels, definition, related, and externalUri. Hidden labels, notes, and tombstones never leave the private copy.

The public schemas/manifest.json lists one entry per scheme:

json
code
{
  "taxonomies": {
    "topics": { "version": 3, "conceptCount": 214 }
  }
}

version is the published projection's version, and 0 means the scheme has never been published. No labels or concept IDs appear in the manifest.

The taxonomy field

A taxonomy field points at one scheme and stores concept IDs on the entry.

json
code
{
  "topics": {
    "type": "taxonomy",
    "scheme": "topics",
    "multiple": true,
    "maxItems": 10,
    "rootConcept": "nature",
    "allowedConcepts": ["nature", "culture"]
  }
}

scheme is required. multiple allows more than one concept. maxItems caps the count from 0 to 50. rootConcept restricts the picker to one subtree and allowedConcepts restricts it to several. A rootConcept or allowedConcepts entry that is not a concept of the named scheme is a model validation error. A model may define at most 10 taxonomy fields.

The stored value is always an array, even when multiple is false:

json
code
{ "topics": [{ "scheme": "topics", "id": "volcanoes" }] }

Taxonomy fields cannot be translatable, because concepts are language-independent and the scheme carries the localized labels. They are always included in draft and published summaries whether or not summaryFields lists them, projected as the raw { scheme, id } array with no labels or ancestors added. See Summaries and indexes for why a taxonomy field cannot be a field index or a list-index sort source.

Publishing a scheme is separate from publishing content

Scheme edits are private until you publish the scheme. Publishing content does not publish the scheme, and publishing the scheme does not republish content. A normal state for a scheme with pending edits is "draft v4, published v3", which the dashboard shows on the scheme card.

Because the two are independent, an editor can tag an entry with a brand new concept and publish that entry right away. The publish validators re-check every assignment against the public projection and warn for each concept that is not published yet. They never block. Publish the scheme afterwards and the label appears on the site.

Reading taxonomies from the generated client

Code generation emits a TaxonomyRef<S> type, a Taxonomy projection type, and one concept-ID union per published scheme, so TaxonomyRef<'topics'> narrows its id. A taxonomy field always types as an array.

A scheme is one small file, so the client loads it once and answers every tree question in memory:

ts
code
const topics = await client.getTaxonomy('topics');

conceptLabel(topics, 'volcanoes', 'is');
conceptPath(topics, 'volcanoes', 'en'); // ['Nature', 'Geology', 'Volcanoes']
ancestorsOf(topics, 'volcanoes'); // ['nature', 'geology']
descendantsOf(topics, 'nature'); // ['geology', 'glaciers', 'volcanoes']
childrenOf(topics, 'geology');
rootsOf(topics);

Every helper is pure, synchronous, and guards against a malformed parent cycle. The fetch is cached under the client's cache policy and tagged blobify:taxonomy:{schemeId}.

findOne and findMany accept a taxonomy filter as one concept ID, a list of IDs, or a subtree expansion built by the taxonomy() helper:

ts
code
const articles = await client.findMany(
  'article',
  { topics: taxonomy('nature', { includeDescendants: true }) },
  'en',
);

Expansion happens from the loaded scheme, so a filtered query costs one summary manifest fetch, one shard fetch per model, and one scheme fetch. Items keep raw refs. Ask for labels explicitly with client.resolveTaxonomy(model, item, locale), which returns a per-field map annotated with label, path, ancestors, and missing, without mutating the item and without another fetch.

In the dashboard

Schemes live in the schema hub beside Models and Blocks. Opening one gives a concept tree you can search, reorder among siblings, reparent, and edit per locale using the locale switcher. The Edit tab holds the tree and the concept form. The Entries tab is the usage view: pick a concept and see which entries use it, per space, with a toggle for counting sub-concepts.

In the content list, every taxonomy field on the visible models becomes a filter. Filtering runs in memory over the draft summaries the list already holds, so it costs no request. The filter state lives in a repeatable tx URL parameter that names the field and the scheme, so a filtered view is shareable:

text
code
?tx=topics::subjects:geology,reykjanes   sub-concepts included (the default)
?tx=topics::subjects:geology!exact       that one concept only

Validation keeps content

Exactly one taxonomy rule blocks a write. Everything else warns and keeps the entry usable.

RuleOutcome
More concepts than the field's maxItemsError, blocks the write
More than 50 assignments across all taxonomy fields on one entryWarning, values kept
Value not an array, malformed, wrong scheme, duplicated, or past multiple: falseWarning, value normalized or dropped
Concept ID absent from the draft schemeWarning, ID kept
Concept outside rootConcept or allowedConceptsWarning, ID kept
Scheme cannot be loaded at allWarning, every ID kept
Concept not in the published projection (publish only)Warning, ID kept

Normalization wraps a single value in an array, drops malformed or wrong-scheme values, removes duplicates while keeping order, and keeps only the first value when multiple is false.

Deleting concepts and schemes

Replacing a scheme replaces its whole concept map, so any stored ID the new map omits becomes a permanent tombstone. Dropping an ID is allowed and returns one warning per removed ID: existing assignments keep the ID and warn until an editor replaces them. Tombstoned IDs may never be reused, and a later write naming one is rejected.

Deleting a single concept is refused while it still has children, so reparent or remove those first. On success its ID is also pruned out of every other concept's related list.

Deleting a whole scheme returns 409 Conflict while any model field still names it, listing the offending model.field pairs. ?force=true deletes anyway and returns the same list as warnings. Those fields then point at a scheme that no longer exists, which becomes a warning on the next model save rather than an error, and the field is kept.

Limits

LimitValue
Schemes per organization20
Concepts per organization6,000
Concepts per scheme2,000
Tombstones per scheme2,000
Hierarchy depth5, roots at depth 1
Parents per concept1
Related concepts per concept20
Alternate and hidden labels per locale10 each
Characters per label256
Characters per definition or note2,000, with 10 notes per locale
Published projection per scheme5 MiB, checked at publish
Taxonomy fields per model10
Assignments per entry50 recommended, warning only

REST and MCP entry points

text
code
GET    /v1/orgs/{orgId}/schemas/taxonomies
GET    /v1/orgs/{orgId}/schemas/taxonomies/{schemeId}
PUT    /v1/orgs/{orgId}/schemas/taxonomies/{schemeId}
DELETE /v1/orgs/{orgId}/schemas/taxonomies/{schemeId}
POST   /v1/orgs/{orgId}/schemas/taxonomies/{schemeId}/publish

Replacing an existing scheme requires the version token from the read, either as an If-Match header or as expectedEtag in the body. See Automation API for the exact preconditions and for taxonomies inside a schema import bundle.

Over MCP the tools are listTaxonomies, getTaxonomy, upsertTaxonomy, upsertConcept, deleteConcept, and publishTaxonomy. Reads are open to organization members, and every mutation needs developer or admin. Tagging content is not a taxonomy tool: write the field with patchFields, saveDraft, or bulkImport as an array of { scheme, id } objects. findContent matches a taxonomy field by concept ID and accepts includeDescendants. See MCP server.