ID format
One format for every primary ID in Agentfy, Bonsite, Securify, Shiffty and Store Deputy (owner decision 2026-10-15). This page is the reference: the other four products copy the module, the test vector and the table below from here.
<prefix>-<adjective>-<noun>-<suffix>
user-calm-rivers-k8m4p2d7n9x3New IDs are minted only in this canonical form. The earlier format of each product is still accepted for IDs already issued (LEGACY_ID_FORMATS), never minted. The AGNT2-588 prefix-only migration is the exception: organization-* becomes org-*, preserving the entire tail. It does not change the word/suffix format.
The format
| Part | Rule |
|---|---|
prefix | The whole model name in kebab-case, with the word Organization shortened to org (OrganizationMember → org-member, AgentMcpServer → agent-mcp-server), lower case, at most 32 characters, may itself contain -. Registry: ID_ENTITIES in api/src/slices/admin/idMigration/data/idInventory.ts. |
adjective | One word from adjectives of human-id 4.1.3 — 200 words. |
noun | One word from nouns of human-id 4.1.3 — 300 words. No verb, no adverb, no dictionary of our own. |
suffix | Exactly 12 characters of lower-case Crockford base32: 0123456789abcdefghjkmnpqrstvwxyz (no i, l, o, u). 60 bits. |
| randomness | Cryptographic: Node crypto.randomBytes by default. Words by masked rejection; the suffix by nanoid 3.3.11 customRandom. |
| uniqueness | The primary key. On a collision the whole write is repeated, at most 5 times (withIdRetry). Words never count as uniqueness. |
| length | At most 32 + 1 + 7 + 1 + 8 + 1 + 12 = 62 characters. Any accepted value, legacy included, is at most 80 (ID_MAX_LENGTH). |
Dictionaries are the arrays the package exports, in the package's own order, lower-cased; a word with a character outside [a-z] would be dropped at load (4.1.3 has none). Dependencies are pinned exactly, without ^: "human-id": "4.1.3", "nanoid": "3.3.11" (the CommonJS line).
Note: the shared task text uses user-calm-river-… as its example; river is not in human-id (rivers is), so that literal string is not a valid ID.
Generation — exact algorithm
The reference, byte for byte, in every product (fixed by the coordinator from SHIF-28, Agentfy/shiffty#37). formatId(prefix, random = crypto.randomBytes) reads from a source random(n) that returns n bytes, in this order:
- adjective —
index(200) - noun —
index(300) - suffix — nanoid 3.3.11
customRandom(ID_SUFFIX_ALPHABET, 12, random)()with the same source
index(n) takes the fewest whole bytes that hold n − 1, reads them big-endian, masks the value to the bit length of n − 1, and draws again while the value is not below n:
| n | bits | bytes per try | mask | accepted when |
|---|---|---|---|---|
| 200 (adjectives) | 8 | 1 | 255 | value < 200 |
| 300 (nouns) | 9 | 2 | 511 | (b0·256 + b1) & 511 < 300 |
nanoid's customRandom for a 32-letter alphabet and size 12 asks for ceil(1.6 × 31 × 12 / 32) = 19 bytes in one request, reads them from the last one back, keeps byte & 31 of each (every value is in range), and stops after 12 letters; the first 7 bytes of the request are unused.
Control test vector
A source that hands out bytes 0, 1, 2, 3, … in order (each call continues the count) gives:
formatId('user', counter) === 'user-afraid-swans-nmkjhgfedcba'Byte 0 → adjectives[0] = afraid; bytes 1, 2 → 258 & 511 = 258 → nouns[258] = swans; one request of bytes 3…21, read from 21 down to 10 → letters 21…10 of the alphabet = nmkjhgfedcba. Exactly 22 bytes are read. Every product's suite asserts this string; a different result means a different dictionary, order, alphabet or draw — not a valid copy.
Validation
isCanonicalId(value, prefix?) accepts exactly
^(<registry prefixes, longest first, escaped>)-<adjective>-<noun>-[0-9abcdefghjkmnpqrstvwxyz]{12}$with both words checked against the dictionaries (not just [a-z]+). prefix may be one prefix, a list, or omitted (any well-formed prefix). There is no normalisation: upper case, Unicode look-alikes, percent encoding and whitespace are refused. Because the number of trailing segments is fixed, a hyphenated prefix is never ambiguous: team-api-key-… is never a team ID.
isLegacyId(value, prefix?) accepts the product's LEGACY_ID_FORMATS. isId(value, prefix) — what DTO validators (@IsEntityId), pipes and gateways use — is isCanonicalId || isLegacyId. Validation never looks anything up and never grants access.
Refused as canonical (tested in every product): a third word (verb), a 6- or 20-character suffix, a suffix of another length of hex, i/l/o/u in the suffix, a word outside the dictionaries — unless the value matches that product's legacy format. Twelve hex digits are a subset of the Crockford alphabet, so …-a1b2c3d4e5f6 is canonical; that cannot and need not be told apart.
The module
api/src/slices/setup/id/domain/idFormat.ts — the same content in all five repositories, except LEGACY_ID_FORMATS. Exports:
| Export | |
|---|---|
ID_SUFFIX_ALPHABET, ID_SUFFIX_LENGTH = 12 | the suffix |
ID_WORDS { adjectives, nouns } | the dictionaries, as loaded |
formatId(prefix, random?) | the only way a new ID is made |
isCanonicalId(value, prefix?), isLegacyId(value, prefix?), isId(value, prefix) | validation |
LEGACY_ID_FORMATS, legacyIdFormat(value, prefix?) | the product's earlier formats |
assertIdPrefix, idPrefixOf, idPattern, ID_MAX_LENGTH | prefix checks, OpenAPI pattern |
The prefix registry stays where each product keeps it. Pure TypeScript (no enums or parameter properties), so Node can load it natively.
Legacy formats by product
| Product | LEGACY_ID_FORMATS (accepted, never minted) |
|---|---|
| Agentfy | <prefix>-<adjective>-<noun>-<verb>-<20 Crockford> — human-id 4.1.3 adjective/noun/verb, rows made on dev after AGNT2-535/537; a bare lower-case UUID — historical rows the AGNT2-535 cutover still has to translate |
| Bonsite | 3 words + 20 Crockford |
| Securify | 2 words + 12 hex (indistinguishable from canonical — see above) |
| Shiffty | 2 words from its own dictionary + 6 Crockford |
| Store Deputy | 2 words + 12 [a-z0-9] |
Agentfy: the cutover
Dev completed the original cutover in AGNT2-580. AGNT2-588 (owner decision 2026-10-24) supersedes the Organization part of AGNT2-537: organization, organization-member, organization-credential and organization-policy become org, org-member, org-credential and org-policy. OrganizationAudit uses a technical UUID and is not minted by the prefix registry.
The maintenance command handles both a fresh legacy database (directly to the current prefixes) and an already migrated database (prefix-only hops preserving the tail). The platform ID is org-silver-days-zxy8s9bpp6hp. No HTTP aliases are created; old organization addresses are not found. Existing provider references resolve through the recorded migration chain. Historical audit data, mapping hops and opaque user/partner content remain historical; they are not live IDs to mint.
Stop writers, back up and restore-check before using api/scripts/id-migrate.ts. The same transaction updates references, encryption ownership and defaults and revokes sessions/signed contexts. Repeat runs change zero identities. Run the cutover before seeding an existing local database; seed refuses old IDs instead of duplicating organizations. See specs/AGNT2-535-id/transition.md and the AGNT2-588 report for environment-specific execution evidence.