Back to Blog

From Fixed Levels to L(n): Building an Extensible Taxonomy System

By · 9 min read
Taxonomy TypeScript YAML GCS Zod React Admin UI

The platform I work on generates training data through AI agents. Every run is scoped to a domain taxonomy — the sector, the kind of work, the function within that work — so agents know what kind of task to produce and evaluators know how to score it. When I joined the project, that taxonomy was a hardcoded two-level hierarchy: Sector and Occupation. It worked for the first wave of use cases. It did not survive contact with scale.

Retail needed sub-verticals like Softlines and Hardlines. Technology needed frontend/backend function splits. Operations wanted to add roles without a developer deploy. Every new level meant schema migrations, type rewrites, and UI refactors — with no admin surface to make changes safely. Over one intensive week I replaced the fixed model with an L(n) extensible taxonomy system: arbitrary depth, YAML-seeded defaults, GCS-backed compare-and-swap storage, a full admin tree editor, backward-compatible API adapters, and cascading UI controls. This post is the architecture story behind that migration.

The problem with fixed levels

The original taxonomy encoded domain knowledge directly into the application shape. Database columns, Zod schemas, React prop types, and agent prompt templates all assumed exactly two levels. That coupling was invisible until new verticals arrived.

"Retail" as a sector is not granular enough when Softlines and Hardlines need different agent behaviors. "Software Engineer" as a single occupation cannot express frontend versus backend task generation. Each new dimension forced the same ritual: alter the schema, migrate rows, update every form, redeploy, and hope nothing in the pipeline still assumed the old shape.

A taxonomy that requires a deploy to add a sub-vertical is not a taxonomy — it is a configuration file disguised as a database schema.

Worse, there was no admin UI. Product operators who understood the domain could not edit the tree themselves. They filed requests, waited for engineering capacity, and watched stale taxonomy block new data generation campaigns. The system needed a generic tree model, validated seed data that ships with the codebase, safe concurrent writes, and a self-service editor — all while every existing API consumer kept working without modification.

L(n) extensible type system

The core insight is that a taxonomy is a tree, and trees are best stored as flat adjacency lists with a reconstruction step — not as nested JSON columns with a fixed depth. I introduced a generic TaxonomyNode type and replaced every hardcoded sector/occupation reference with tree operations.

Each node carries: id (stable UUID), slug (URL-safe identifier used in API payloads), label (human display name), parentId (null for roots), level (zero-indexed depth), and levelLabel (semantic name for that depth, such as "Sector", "Sub Vertical", "Function", or "Role"). The default configuration ships with four levels, but nothing in the type system caps depth — a fifth or sixth level is just another row in the adjacency list.

export interface TaxonomyNode {
  id: string;
  slug: string;
  label: string;
  parentId: string | null;
  level: number;
  levelLabel: string;
}

export interface TaxonomyTreeNode extends TaxonomyNode {
  children: TaxonomyTreeNode[];
}

export interface TaxonomyDocument {
  version: number;
  levelLabels: string[];
  nodes: TaxonomyNode[];
  updatedAt: string;
}

tree-builder.ts contains pure functions for reconstructing nested trees from the flat list. buildTree indexes nodes by id, attaches children to parents, and returns roots sorted by label. getDescendants, getAncestors, and getNodesAtLevel are the primitives every UI dropdown and API validator calls. Keeping these as pure functions made the test surface straightforward: feed an adjacency list, assert the tree shape, no GCS or database required.

taxonomy-schema.ts defines Zod schemas with structural integrity validation on top of per-field parsing. The validator rejects orphan nodes (a parentId pointing to a missing id), cycles (a node that is its own ancestor), inconsistent level labels (a level-2 node labeled "Sector"), and slug collisions within the same parent. These checks run on every write path — YAML seed import, admin save, and API mutation — so corrupt trees never reach storage.

export const TaxonomyNodeSchema = z.object({
  id: z.string().uuid(),
  slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
  label: z.string().min(1).max(120),
  parentId: z.string().uuid().nullable(),
  level: z.number().int().min(0),
  levelLabel: z.string().min(1),
});

export const TaxonomyDocumentSchema = TaxonomyNodeSchema
  .array()
  .transform((nodes) => ({ version: 0, levelLabels: [], nodes, updatedAt: "" }))
  .pipe(
    z.object({
      version: z.number().int().positive(),
      levelLabels: z.array(z.string().min(1)),
      nodes: z.array(TaxonomyNodeSchema),
      updatedAt: z.string().datetime(),
    })
  )
  .superRefine(validateNoOrphans)
  .superRefine(validateNoCycles)
  .superRefine(validateLevelConsistency);

YAML seeding with version-gated deployment

Bootstrapping an empty environment with a taxonomy editor and no taxonomy is a chicken-and-egg problem. The solution is vendored seed data: a bundled taxonomy.yaml checked into the repository with nine sectors, their sub-verticals, functions, and roles. The YAML ships with the code, reviews in pull requests, and diffs like any other configuration artifact.

yaml-seed-provider.ts reads and validates the YAML at application startup. Parsing goes through the same Zod schemas as runtime writes, so a malformed seed file fails the build rather than silently corrupting production. The seed document includes a monotonically increasing version field that drives deployment behavior.

Version-gated seeding is the critical safety valve. The provider only writes to cloud storage if no taxonomy document exists yet, or if the stored version is older than the bundled seed version. If an admin has edited the live taxonomy to version 14 and the bundled seed is version 12, startup is a no-op. Admin edits are never overwritten by a redeploy. If we ship seed version 15 with two new sectors, every environment running version 14 or below picks up the additions automatically on next deploy.

export async function maybeSeedTaxonomy(
  storage: TaxonomyStorageProvider,
  seed: TaxonomyDocument
): Promise<SeedResult> {
  const existing = await storage.read();

  if (!existing) {
    await storage.write(seed, { ifGenerationMatch: 0 });
    return { action: "created", version: seed.version };
  }

  if (existing.version >= seed.version) {
    return { action: "skipped", version: existing.version };
  }

  await storage.write(seed, { ifGenerationMatch: existing.generation });
  return { action: "upgraded", version: seed.version };
}

GCS storage with CAS protection

gcs-taxonomy-provider.ts implements a TaxonomyStorageProvider interface — the same hot-swappable pattern used elsewhere in the platform for task manifests and run metadata. The concrete GCS provider stores the validated document as a single JSON object in a known bucket path. All writes use compare-and-swap semantics via GCS conditional generation-match preconditions.

When an admin saves the taxonomy, the client sends the document along with the generation number it read. The server attempts a conditional write: succeed only if the object's generation still matches. If two admins edit simultaneously, the second write fails with a conflict, the UI surfaces the stale state, and the loser merges or retries. No last-write-wins corruption. No silent clobbering of another operator's subtree reorganization.

The provider interface is deliberately narrow — read, write, listHistory — so a future migration to a relational database is an implementation swap, not a product rewrite. Services depend on the interface; routes depend on services; the GCS bucket path never leaks past the provider boundary.

Admin CRUD UI

TaxonomyEditor.tsx is the operator-facing surface: roughly 1,100 lines of tree editor supporting add, edit, delete, parent reassignment, and CAS-protected saves with optimistic concurrency. On conflict, the editor fetches the latest document and lets the operator reconcile before retrying. It lives under Admin > Auto-Seeder, with audit history exposed via /api/admin/taxonomy/history — prior generations with timestamps and actor metadata, so operators can trace when a slug entered production.

Backward compatibility layer

Existing API consumers — run creation, batch intake, pipeline workers, reporting dashboards — all spoke the old sector/occupation dialect. Breaking them during a taxonomy migration would have blocked data generation for days. compat.ts provides bidirectional mapping between legacy fields and the new taxonomy map keyed by level label.

export function legacyToTaxonomyMap(
  sector: string,
  occupation: string,
  tree: TaxonomyTreeNode[]
): Record<string, string> | null {
  const sectorNode = findBySlug(tree, sector);
  if (!sectorNode) return null;

  const occupationNode = sectorNode.children.find((c) => c.slug === occupation);
  if (!occupationNode) return null;

  return {
    Sector: sectorNode.slug,
    Occupation: occupationNode.slug,
  };
}

export function taxonomyMapToLegacy(
  taxonomy: Record<string, string>
): { sector: string; occupation: string } | null {
  const sector = taxonomy["Sector"];
  const occupation = taxonomy["Occupation"] ?? taxonomy["Role"];
  if (!sector || !occupation) return null;
  return { sector, occupation };
}

Run creation API accepts both formats. Zod schemas use .refine() to ensure at least one is present, and a transform normalizes everything to the canonical taxonomy map before the service layer sees it. Pipeline workers that still read sector and occupation from older manifests get populated fields via the reverse mapping. Zero consumer changes required on day one.

Cascading UI controls

The data model change only matters if operators can navigate the tree intuitively. I rewrote new-run-dialog.tsx with dynamic cascading selects: choosing a sector filters the sub-vertical dropdown to children of that node, which filters functions, which filters roles. At the Function level, multi-select chips let operators tag a run with several functions under the same sub-vertical without creating duplicate rows.

bulk-run-form.tsx received the same treatment — each row in the bulk intake table gets independent L(n) cascading dropdowns driven by the live taxonomy document, not a hardcoded two-level registry. The dropdown component is generic: pass a parentId and a levelLabel, get the filtered options. When admins add a fifth level next quarter, the forms do not need a rewrite — they render another cascade automatically if the level label is present in the document.

Agent prompt enrichment

Downstream of the UI, the worker payload schema gained a taxonomy field carrying the full level map — not just sector and occupation. Agent prompts now receive context like { Sector: "retail", "Sub Vertical": "softlines", Function: "inventory-management" } instead of a flat occupation string that obscures the sub-vertical.

That enrichment directly affects task quality. A generic "Retail" prompt produces shelf-stocking scenarios; a Softlines inventory-management prompt produces size-run complexity and seasonal markdown logic that evaluators actually want to score.

Companion work in the same sprint

A change this wide rarely ships alone. The same week included several companion improvements that kept the platform stable while the taxonomy migration landed:

  • Pipeline terminal node — Added a "Finished" terminal node to the pipeline visualization with an emerald checkmark when a run completes, so operators have a clear visual endpoint instead of an ambiguous last-step state.
  • Framework security upgrade — Bumped the Next.js dependency to a patched release addressing a published vulnerability in the prior version.
  • Author metadata column — Added an author email column to the task list and removed a redundant versions count that cluttered the view without helping operators.
  • UI cleanup — Disabled a vestigial "New Task" button that routed nowhere, and updated end-to-end tests to match the current navigation model.

Scale and test coverage

The taxonomy migration touched 61 files across 45 commits — roughly 7,000 lines added. That sounds large, but the bulk is the admin editor, cascading form rewrites, and test fixtures rather than exotic algorithms. The core primitives — adjacency list, tree builder, Zod validation, CAS writes — are a few hundred lines each.

Test coverage reflects where the risk actually lives. Forty unit tests exercise tree construction, orphan detection, cycle detection, compat mapping in both directions, and edge cases like single-node trees and deeply nested branches. Ten YAML validation tests load fixture files — valid seeds, seeds with orphan references, seeds with slug collisions — and assert parse success or failure. The backward compatibility layer has dedicated round-trip tests proving that a legacy API payload and a new-format payload produce identical normalized documents.

Layer Tests What they guard
Tree builder 18 Reconstruction, descendants, ancestors, level filtering
Schema validation 14 Orphans, cycles, level consistency, slug format
Compat mapping 8 Bidirectional legacy ↔ taxonomy map conversion
YAML fixtures 10 Seed parse success/failure on representative files

What I would do differently

I would have introduced the TaxonomyStorageProvider interface on day one — even backed by a hardcoded JSON file — and shipped a read-only tree view before enabling writes. Both would have made the migration incremental rather than a week-long extraction sprint.

The through-line is the same principle I keep returning to in platform work: make the contract explicit and the depth extensible at every boundary. A flat adjacency list does not care whether you have two levels or six. The L(n) taxonomy system turned a deploy-gated configuration problem into a self-service domain model — and every existing pipeline consumer kept working while we got there.