Identifiers
Entity IDs, type discriminators, tenant context, and versioning — the meta-fields that power the graph.
Entity ID Format
Every entity receives an auto-generated ID in the format {type}_{sqid}:
import { Contact } from '@headlessly/crm'
const contact = await Contact.create({ name: 'Alice', stage: 'Lead' })
console.log(contact.$id) // 'contact_fX9bL5nRd'The type prefix is always lowercase. The suffix is generated by sqids -- a library that produces short, unique, URL-safe identifiers.
Examples Across Entities
| Entity | Example ID |
|---|---|
| Contact | contact_fX9bL5nRd |
| Deal | deal_k7TmPvQx |
| Organization | organization_pQ8xNfKm |
| Subscription | subscription_nT5xKpRm |
| Issue | issue_e5JhLzXc |
| Event | evt_tR8kJmNxP |
| ApiKey | apikey_nT5xKpRmVw |
Why sqids
| Requirement | How sqids Solves It |
|---|---|
| Short | 8-12 characters vs. 36 for UUIDs |
| URL-safe | Alphanumeric only, no special characters |
| Non-offensive | Built-in blocklist prevents generating IDs that contain profanity |
| Non-sequential | IDs do not reveal creation order or total count |
| Unique | Collision-free within a tenant namespace |
Why Not UUIDs
UUIDs (550e8400-e29b-41d4-a716-446655440000) are 36 characters, contain hyphens, and are impossible to read, type, or remember. They work for machine-to-machine communication but are hostile to developers and agents who interact with IDs directly.
Why Not Sequential IDs
Sequential integers (contact_1, contact_2) leak information: total entity count, creation order, and growth rate. They are also trivially enumerable, which creates security concerns in multi-tenant systems.
The Seven Meta-Fields
Every entity carries seven system-managed fields prefixed with $:
$type
The PascalCase entity type discriminator. Used for routing, serialization, and type-safe parsing:
const contact = await Contact.get('contact_fX9bL5nRd')
console.log(contact.$type) // 'Contact'
const deal = await Deal.get('deal_k7TmPvQx')
console.log(deal.$type) // 'Deal'The $type field enables polymorphic queries across the graph. When events reference a target, the ID prefix and $type together identify both the entity type and specific instance.
$id
The unique identifier in {type}_{sqid} format. Globally unique within a tenant, parseable to extract the entity type:
// Parse the type from an ID
const id = 'contact_fX9bL5nRd'
const type = id.split('_')[0] // 'contact'IDs are assigned at creation time and never change. They are the stable reference for an entity across its entire lifecycle.
$context
The tenant namespace URL. Every entity is scoped to exactly one tenant:
const contact = await Contact.get('contact_fX9bL5nRd')
console.log(contact.$context) // 'https://headless.ly/~acme'The $context ensures complete tenant isolation. Queries, events, and relationships are always scoped to the tenant. See Multi-Tenancy for details.
$version
A monotonically increasing integer that increments on every mutation:
const contact = await Contact.create({ name: 'Alice', stage: 'Lead' })
console.log(contact.$version) // 1
await Contact.update(contact.$id, { stage: 'Qualified' })
const updated = await Contact.get(contact.$id)
console.log(updated.$version) // 2Versions provide a total ordering of mutations per entity. They are used for optimistic concurrency control, event ordering, and time travel. The version never decreases, even after a rollback (the rollback itself increments the version).
$createdAt
ISO 8601 timestamp of when the entity was created:
console.log(contact.$createdAt) // '2026-01-15T12:00:00Z'Set once at creation time. Never changes.
$updatedAt
ISO 8601 timestamp of the most recent mutation:
console.log(contact.$updatedAt) // '2026-01-16T09:30:00Z'Updated on every write operation, including custom verbs.
$createdBy
The ID of the user or agent that created the entity:
console.log(contact.$createdBy) // 'agent_mR4nVkTw'This field enables audit trails. Combined with the event log, you can trace every entity back to its creator and every subsequent mutation to its actor.
Meta-Fields Are Read-Only
You cannot set meta-fields directly. They are managed by the system:
// This will NOT set the ID -- the system generates it
await Contact.create({
$id: 'contact_myCustomId', // Ignored
name: 'Alice',
stage: 'Lead',
})
// This will NOT change the version -- mutations increment it automatically
await Contact.update('contact_fX9bL5nRd', {
$version: 99, // Ignored
stage: 'Qualified',
})Using IDs in Relationships
Entity IDs are the glue that connects the 35-entity graph. Pass an ID string to set a relationship:
import { Deal } from '@headlessly/crm'
await Deal.create({
name: 'Enterprise License',
value: 50000,
contact: 'contact_fX9bL5nRd',
organization: 'organization_k7TmPvQx',
})The system validates that the referenced entity exists and belongs to the same tenant. Cross-tenant references are not possible.
ID in URLs
Entity IDs appear in REST URLs and MCP tool calls:
# REST
GET https://crm.headless.ly/~acme/Contact/contact_fX9bL5nRd
# Verb execution
POST https://crm.headless.ly/~acme/Contact/contact_fX9bL5nRd/qualify{ "type": "Contact", "id": "contact_fX9bL5nRd" }Because IDs are short and URL-safe, they work cleanly in paths, query parameters, and log output without encoding.