Identifiers that resist hallucination, survive repeated LLM copying, and repair themselves when damaged — or fail honestly when they can't.
The JavaScript implementation of LLMUID, an identifier scheme for systems where identifiers must pass through large language models — read, copied and re-emitted across many prompt hops.
K7-M3-XR-9D-Q2
Ten symbols over a 29-symbol alphabet of digits and consonants, eight of them a random payload and two of them check symbols, written as five groups of two. No vowels, so an identifier can never spell a word. No lookalikes, so it can never be misread across ambiguous glyphs.
npm install llmuidRequires Node 22.12 or later. No dependencies.
The module imports nothing from any host — no node:crypto, no node:fs, no
TextEncoder — so the same file runs unchanged on Node, Deno, Bun, in a
browser and on an edge runtime. mint() is the one method that needs anything
of its host, and what it needs is crypto.getRandomValues.
import { LLMUID } from "llmuid";
const r = new LLMUID();
const identifier = r.mint(); // K7-M3-XR-9D-Q2Reading is liberal. Case, delimiters and wrapping carry no information, and any single damage event is repaired silently:
r.resolve("K7-M3-XR-9D-Q2"); // K7-M3-XR-9D-Q2, pristine
r.resolve("`k7 m3 xr 9d q2`"); // K7-M3-XR-9D-Q2, delimiters and case
r.resolve("K7-M3-XB-9D-Q2"); // K7-M3-XR-9D-Q2, one substitution
r.resolve("K7-M3-RX-9D-Q2"); // K7-M3-XR-9D-Q2, one transpositionAnything further away is a hard failure, never a guess:
r.resolve("K7-M3-ZZ-ZZ-Q2"); // null
r.last_error(); // 'Checksum failed and no issued
// identifier is within 2 edits'Check symbols can be bound to the slot, role or parent an identifier belongs
to, so a genuine identifier pasted into the wrong place fails to resolve. The
same context string must be given to mint() and to resolve().
const identifier = r.mint("invoice");
r.resolve(identifier, "invoice"); // the identifier
r.resolve(identifier, "receipt"); // null
r.last_error(); // 'Wrong context: ... was not issued
// under this context'This is the defence against the most dangerous failure of all — a well-formed identifier in the wrong role, which nothing about the string itself can catch.
The registry is in-memory and append-only: it lives in the object and dies with
it. registry() hands the issued set back so a caller can persist it, and the
constructor takes that same array back.
const issued = r.registry(); // array of canonical renderings
r = new LLMUID(issued); // same registry, new processmint(context?: string): string | null
resolve(llmuid: string, context?: string): string | null
registry(): string[]
last_error(): string | null
self_test(): booleanTypes ship with the package, written by hand rather than generated, so there is no build step and no development dependency.
Nothing raises. Failure returns null and explains itself through
last_error(), which returns null when there is nothing to explain. The one
call that can throw — the host random source — is caught and converted into a
failed mint like any other.
last_error() is a method rather than a getter, so that it reads the same here
as in every other implementation of the scheme.
The wording of last_error() separates the two things worth watching: a repair
means the channel is degrading, while a failure means the pipeline is faulty,
since honest noise almost never produces multi-event damage.
That is a design constraint, not an oversight. crypto.subtle.digest is
asynchronous, and a promise cannot be waited on from a synchronous method: its
resolution is a microtask, so it cannot run until the stack that would be
waiting has already unwound. The SHA-256 the context digest needs is therefore
written out inside the module.
Making it async instead would carry through mint(), resolve() and
self_test(), and it would break last_error(): two concurrent resolve()
calls would race on one error slot, and the caller that succeeded would read
the error belonging to the caller that failed. A synchronous digest is what
keeps failure a return value you can trust.
src/vectors/ is a copy of the conformance vectors from the specification
repository: 134 cases pinning the context digest, liberal reading, the
bounded distance and the damage contract end to end. self_test() grades this
class against every one of them, and then against the invariants minting is
answerable for — which are random by design, so no fixed case can pin them.
r.self_test(); // true
r.last_error(); // the first failing case, if notIt reads the vectors from the installed package, mints only into throwaway objects, and leaves the registry of the object it is called on untouched.
The vectors are frozen and they are the answer key. A failure means this implementation has drifted from the specification — never that a vector needs updating.
Normalization is deliberately liberal, which means it will happily eat the prose around an identifier as well:
r.resolve("see invoice K7-M3-XR-9D-Q2 today"); // null, too longExtract identifiers from surrounding text yourself — they match
/\b[0-9BCDFGHJKMNPQRSTVWXZ]{2}(?:-[0-9BCDFGHJKMNPQRSTVWXZ]{2}){4}\b/ in
canonical rendering — and hand resolve() one candidate at a time.
The random payload makes identifiers statistically unguessable, but not cryptographically so, and the check symbols are public arithmetic anyone can compute. Identifiers must never be used as secrets, capabilities or bearer tokens, and possession of a valid identifier must never grant authority. The adversary in this design is a hallucinating model, not an attacker.
The SHA-256 in this module exists to derive a context digest deterministically across languages. It is not there to protect anything, and nothing here should be read as a general-purpose cryptographic library.
There is nothing to install — no dependencies, dev or otherwise, and one self-contained class. There are two checks, and the class carries the second one itself rather than the tree carrying a test framework to run it:
node --check src/llmuid.js
node -e 'import("./src/llmuid.js").then(({ LLMUID }) => {
const r = new LLMUID();
console.log(r.self_test() ? "pass" : r.last_error());
});'llmuid.md is the design document and is authoritative; this code implements it without variation. If the two disagree, this code is wrong.
It was written from the specification and graded against the vectors rather
than translated from the PHP implementation, which is one rendering of
the same document and not a second source of truth. Three places are where a
port lands somewhere plausible and wrong, and the vectors are aimed at each: the
context digest is read big-endian, an adjacent transposition costs one operation
and not the two a stock Levenshtein routine charges, and case folding is
restricted to the spellings the alphabet lists — toUpperCase() would turn
U+017F into an S that is in the alphabet and fail a pristine identifier on
length.
Three more are specific to this language, and no vector reaches them:
- The registry is a
Map, never an object. An object stores an integer-like key as an array index and enumerates it ahead of every string key, so one all-digit identifier would silently reorder whatregistry()promises to hand back in mint order. - The context is canonicalized over its UTF-8 bytes.
toLowerCase(),trim()and\sare each wider than the byte operation they resemble, and every one of them would produce check symbols that are entirely believable and match nothing another implementation minted. - A lone surrogate is encoded rather than replaced.
TextEncoderwould substitute U+FFFD where the other implementations keep the bytes.
MIT — see LICENSE.