Skip to main content

UUIDs & IDs Strategy

This document defines how we generate, format, and use UUIDs and IDs across the platform. It covers resource IDs, request IDs, organization slugs, and API keys. Follow these conventions when adding new entities, endpoints, or services.

Resource IDs (TypeID format)

Every persisted entity gets a TypeID — a type-prefixed, UUIDv7-based identifier encoded in Crockford base32.

Format

{prefix}_{26-char-base32-encoded-uuidv7}
Examples:
usr_01h2xcejqtf2nbrexx3vqjhp41    (user)
org_01h455d8sc2shh7cysq5bn60y0    (organization)
proj_01h455d8sc2shh7cysq5bn60y1   (project)
mem_01h455d8sc2shh7cysq5bn60y2    (organization member)
inv_01h455d8sc2shh7cysq5bn60y3    (invitation)

Prefix Registry

Every entity type has a unique, short prefix. When adding a new entity, register its prefix here to avoid collisions.
PrefixEntitySchemaNotes
usrUserauthUser identifier
orgOrganizationauthOrganization identifier
memOrganization memberauthOrganization member identifier
invInvitationauthInvitation identifier
projProjectatlasProject identifier
pmemProject memberatlasProject member identifier
clsClusteratlasFuture
srvBare metal serveratlasFuture
evtAudit eventatlasFuture
tmplCluster templateatlasFuture
credCloud credentialatlasFuture
Rules for new prefixes:
  • 2-5 lowercase ASCII letters (no digits, no underscores)
  • Must be an obvious abbreviation of the entity name
  • Must not collide with any existing prefix
  • Add to this table before writing the migration

Generating Resource IDs

In Golang (application layer): TypeID Golang package: jetify-com/typeid-go
import (
	"github.com/k0rdent/shared/utils/typeid"
)

userId := typeid.GenerateResourceId("usr")
projectId := typeid.GenerateResourceId("proj")
In TypeScript (application layer): TypeID TypeScript package: jetify-com/typeid-js
import { typeid } from '@k0rdent/shared/utils/typeid';

const userId = typeid('usr');
// → "usr_01h2xcejqtf2nbrexx3vqjhp41"

const projectId = typeid('proj');
// → "proj_01h455d8sc2shh7cysq5bn60y1"
In PostgreSQL (migration/default): The database has a generate_prefixed_id(prefix) function. Use it as the column default:
CREATE TABLE atlas.clusters (
  id TEXT PRIMARY KEY DEFAULT generate_prefixed_id('cls'),
  -- ...
);
Note: The current PostgreSQL function uses UUIDv4 with stripped dashes (not TypeID encoding). We plan to migrate this to match the TypeID format. Until then, IDs generated in Postgres vs. the application layer will look slightly different but both are valid. The prefix is the important part for debugging.

Decoding a Resource ID

If you need to extract the underlying UUID (for logging, debugging, or cross-referencing):
import { decode } from '@k0rdent/shared/utils/typeid';

const { prefix, uuid } = decode('usr_01h2xcejqtf2nbrexx3vqjhp41');
// prefix → "usr"
// uuid   → "0192a3b4-c5d6-7e8f-9a0b-1c2d3e4f5a6b"  (UUIDv7)

Why TypeID over alternatives?

We evaluated nanoid, cuid2, ULID, KSUID, and bare UUIDs. TypeID won because:
  • It’s a spec, not a library. The format is defined at github.com/jetify-com/typeid. If the reference library is abandoned, we can replace it with ~60 lines of code wrapping the uuid package (which has 385M+ weekly downloads).
  • Type prefix is baked into the ID. A grep for org_ in logs instantly filters to organizations. With bare UUIDs you’d need a join or additional context.
  • UUIDv7 under the hood. The underlying ID is an IETF standard (RFC 9562). We’re not locked into a niche format.
  • Crockford base32 encoding. Shorter than hex UUIDs (26 chars vs 32), no ambiguous characters, lowercase by default.

Request IDs

Every API request gets a request ID for end-to-end tracing. These are not persisted as database entities — they live in logs, headers, and error responses.

Format

req_{region}-{timestamp_hex}-{entropy_hex}
Examples:
req_us1-018f4a2b3c4d-a1b2c3d4e5f6g7h8
req_eu1-018f4a2b3c4d-9f8e7d6c5b4a3928
req_ap1-018f4a2b3c4d-1234567890abcdef
SegmentDescriptionLength
req_Fixed prefix (identifies this as a request)4 chars
{region}Deployment region code2-3 chars
{timestamp_hex}Date.now() as hex12 hex chars
{entropy_hex}Random bytes via Web Crypto16 hex chars

Why not TypeID for requests?

Request IDs embed the region for grep-based debugging. When an incident hits, grep req_us1 access.log instantly narrows to the US-1 region. TypeID’s base32 encoding would obscure this.

Generating Request IDs

const REGION = process.env.K0RDENT_REGION ?? 'local';

function generateRequestId(): string {
  const ts = Date.now().toString(16).padStart(12, '0');
  const bytes = new Uint8Array(8);
  crypto.getRandomValues(bytes);
  const entropy = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
  return `req_${REGION}-${ts}-${entropy}`;
}

Using Request IDs

Middleware (API layer):
app.use((req, res, next) => {
  req.requestId = req.headers['x-request-id'] ?? generateRequestId();
  res.setHeader('x-request-id', req.requestId);
  next();
});
Error responses: Always include the request ID so users can reference it in support tickets:
{
  "error": {
    "code": "FORBIDDEN",
    "message": "You do not have access to this project.",
    "requestId": "req_us1-018f4a2b3c4d-a1b2c3d4e5f6g7h8"
  }
}
Structured logging:
logger.info('Project created', {
  requestId: req.requestId,
  projectId: project.id,
  organizationId: org.id,
});

Organization Slugs

Organization slugs appear in URLs (/orgs/{slug}/...) and optionally as subdomains. They are user-chosen, derived from the organization name, and must be globally unique.

Format

3-39 characters, lowercase letters and digits, hyphens between words
Must start with a letter, cannot end with a hyphen
Examples:
acme-corp        (from "Acme Corp")
my-startup       (from "My Startup")
cloudnative-io   (from "CloudNative.io")

Reserved Slugs

Certain slugs are blocked to prevent route collisions and subdomain hijacking:
  • Route collisions: new, create, edit, delete, settings, members, billing, invitations
  • Subdomain collisions: api, admin, www, app, cdn, auth, console, docs, status
  • Platform identity: k0rdent, atlas, arc, mirantis, platform, system
See the full list in packages/shared/src/utils/org-slug.ts.

Utilities

import {
  suggestOrgSlug,
  validateOrgSlug,
  findAvailableSlug,
  isReservedSlug,
} from '@k0rdent/shared/utils/org-slug';

// Generate a slug suggestion from an org name
suggestOrgSlug('Acme Corp');          // → "acme-corp"
suggestOrgSlug('123 Industries');     // → "x123-industries" (prefixed — must start with letter)
suggestOrgSlug('New Corp');           // → "new-corp-a7f3" (reserved word gets random suffix)

// Validate a user-entered slug
validateOrgSlug('acme-corp');         // → { valid: true }
validateOrgSlug('ab');                // → { valid: false, error: 'TOO_SHORT' }
validateOrgSlug('admin');             // → { valid: false, error: 'RESERVED' }

// Find an available slug (checks DB)
const slug = await findAvailableSlug('acme-corp', async (s) => {
  const exists = await db.query('SELECT 1 FROM auth.organizations WHERE slug = $1', [s]);
  return exists.rowCount > 0;
});
// → "acme-corp" if available, otherwise "acme-corp-1", "acme-corp-2", etc.

API Keys

API keys use the existing format and do not follow the TypeID convention. Keys are secrets — they’re not user-facing identifiers that need to be human-readable.

Format

k0r_{type}_{random_base62}
SegmentDescription
k0r_Platform prefix
{type}Key type: live, test, ci
{random}Base62-encoded random bytes
Example: k0r_live_7Hs9kQ2mNpR4xYzA... See docs/api-key-system-design.md for the full API key design.

Common Patterns & Recipes

Creating a new entity with a TypeID

// In a service/handler
import { typeid } from '@k0rdent/shared/utils/typeid';

async function createCluster(input: CreateClusterInput) {
  const id = typeid('cls');

  await db.query(
    `INSERT INTO atlas.clusters (id, project_id, name, ...) VALUES ($1, $2, $3, ...)`,
    [id, input.projectId, input.name],
  );

  return { id, ...input };
}

Validating an ID format in an API handler

function isValidTypedId(id: string, expectedPrefix: string): boolean {
  const parts = id.split('_');
  if (parts.length !== 2) return false;
  if (parts[0] !== expectedPrefix) return false;
  if (parts[1].length !== 26) return false;
  return /^[0-7][0-9a-hjkmnp-tv-z]{25}$/.test(parts[1]);
}

// Usage in route handler
app.get('/projects/:id', (req, res) => {
  if (!isValidTypedId(req.params.id, 'proj')) {
    return res.status(400).json({ error: { code: 'INVALID_ID', message: 'Invalid project ID format' } });
  }
  // ...
});

Passing IDs between services

Always pass TypeIDs as-is (with prefix). Never strip the prefix for storage or transport — the whole point is that the prefix travels with the ID.
// Good — prefix stays
await queue.publish('cluster.created', { clusterId: 'cls_01h2xce...' });

// Bad — stripping the prefix defeats the purpose
await queue.publish('cluster.created', { clusterId: '01h2xce...' });

Logging with request context

function createLogger(requestId: string) {
  return {
    info: (msg: string, meta?: Record<string, unknown>) =>
      console.log(JSON.stringify({ level: 'info', requestId, msg, ...meta, ts: new Date().toISOString() })),
    error: (msg: string, meta?: Record<string, unknown>) =>
      console.error(JSON.stringify({ level: 'error', requestId, msg, ...meta, ts: new Date().toISOString() })),
  };
}

// In a handler
const log = createLogger(req.requestId);
log.info('Deleting cluster', { clusterId: cluster.id, orgId: org.id });

Edge Cases & Guidance

”I need a new entity type. What prefix do I use?”

  1. Check the Prefix Registry table above. Make sure your prefix doesn’t collide.
  2. Pick a 2-5 letter abbreviation that’s obvious. cls for cluster, tmpl for template, cred for credential.
  3. Add it to the registry table in this document as part of your PR. The migration and application code should match.
  4. If you’re unsure, open a thread in #platform-eng — prefix collisions are painful to fix after the fact.

”My entity doesn’t need a prefix (it’s internal-only).”

It still gets one. Internal entities end up in logs, error messages, and debugging sessions. The prefix costs nothing and saves time when someone is grepping through logs at 2am.

”Can I use a different ID format for my table?”

Discuss with the team first. The whole point of a unified strategy is consistency. If you have a legitimate reason (e.g., you’re integrating with an external system that mandates UUIDs), document the exception in your PR and get a review from the platform team.

”The PostgreSQL generate_prefixed_id function doesn’t match TypeID encoding.”

Known issue. The Postgres function currently uses prefix_ + hex-encoded UUIDv4 (dashes stripped). The application layer uses TypeID (prefix + Crockford base32-encoded UUIDv7). Both are valid and the prefix is the important part. We plan to align these in a future migration. In the meantime, don’t write code that assumes a specific encoding for the suffix — always treat the suffix as opaque.

”Should request IDs be stored in the database?”

No. Request IDs live in logs, response headers, and error payloads. If you need to correlate a request to a database row, log both the request ID and the resource ID together. Storing request IDs in tables adds write overhead for no benefit.

”A user is passing a request ID from the client — should I trust it?”

Accept it via the x-request-id header for tracing continuity, but validate the format. If it doesn’t match req_{region}-{hex}-{hex}, generate a new one. Never let a malformed request ID propagate into logs.

”I’m adding a reserved slug — where do I update?”

The RESERVED_SLUGS set lives in packages/shared/src/utils/org-slug.ts. Add your reserved word there and add a test case. Common reasons to reserve a slug: new top-level route, new subdomain, new platform concept.

”When in doubt…”

Open a discussion in #platform-eng. ID format decisions are easy to make and hard to undo. A 5-minute conversation prevents months of migration pain.

Migration Notes

Current state (as of migration 0003)

  • Database IDs use generate_prefixed_id(prefix)prefix_ + hex UUIDv4 (no dashes)
  • Org slugs derived from email username + random hex suffix
  • Request IDs not yet standardized across all services

Target state

  • Database defaults aligned with TypeID format (prefix + Crockford base32 UUIDv7)
  • Org slugs user-chosen from org name
  • Request IDs standardized with region embedding
  • generate_prefixed_id() Postgres function updated to produce TypeID-compatible output

Migration path

  1. Application layer first. New IDs generated in TypeScript use the TypeID format immediately.
  2. Database defaults later. Update generate_prefixed_id() to use UUIDv7 + Crockford base32 in a future migration.
  3. No backfill needed. Existing IDs remain valid — they have the right prefix, just a different suffix encoding. All code should treat the suffix as opaque.

Quick Reference

WhatFormatExample
Resource ID{prefix}_{base32_uuidv7}proj_01h455d8sc2shh7cysq5bn60y1
Request IDreq_{region}-{timestamp_hex}-{entropy_hex}req_us1-018f4a2b3c4d-a1b2c3d4e5f6g7h8
Organization slug{lowercase-alphanumeric-hyphens}acme-corp
API keyk0r_{type}_{base62}k0r_live_7Hs9kQ2mNpR4xYzA...
Randomness sourcecrypto.getRandomValues()Universal across all runtimes