# Prefix Registry

A prefix only pays off - "the ID names its resource on sight" - when it is
globally unique and well-formed across your whole app. `UXID.Registry` is an
opt-in, compile-time DSL that makes those guarantees the compiler's job instead
of a hand-rolled CI test, and turns the same declarations into a runtime routing
table (prefix → schema) for the ID-driven patterns Adam Kirk describes in his
ElixirConf US 2025 talk, [_UXIDs in Elixir/Ecto_][uxid_talk_url]
(authorization/IDOR checks, admin auto-linking, Relay global IDs).

## Declaring a registry

Declare one registry module as your single source of truth:

```elixir
defmodule MyApp.IDs do
  use UXID.Registry,
    default_size: :medium,
    default_validate: true

  defid :org,     prefix: "org",     schema: MyApp.Org,             category: :account
  defid :contact, prefix: "contact", size: :large, schema: MyApp.CRM.Contact
  defid :lead,    prefix: "lead"
  defid :event,   prefix: "evt",     size: :small, monotonic: true
  retired "usr" # reserve a prefix so it stays unique-checked, never reused
end
```

**Compile-time guarantees.** Every prefix is checked against `:prefix_format`
(overridable; the default permits an internal underscore for compound prefixes
like `in_ref`), and all prefixes - active *and* `retired` - are checked for
uniqueness. A malformed or duplicate prefix is a compile error, so the governance
every prefixed-ID scheme needs ships in the library.

Keep to **one registry module per app**: compile-time uniqueness only holds
within a single module, since the library never sees two registries together.

## By key - minting and schema configuration

```elixir
MyApp.IDs.generate!(:org)   # => "org_01h..."
MyApp.IDs.prefix(:org)      # => "org"
MyApp.IDs.size(:org)        # => :medium
MyApp.IDs.schema(:org)      # => MyApp.Org
MyApp.IDs.all()             # => [%{key: :org, prefix: "org", schema: MyApp.Org, ...}, ...]
```

`field_opts/1` is the single-source-of-truth hook - a schema spreads it instead
of restating prefix/size/validate anywhere:

```elixir
@primary_key {:id, UXID, [autogenerate: true] ++ MyApp.IDs.field_opts(:org)}
```

`generate!/2` merges caller options over the registry's, so a call site can pass
anything the key does not own:

```elixir
MyApp.IDs.generate!(:share, monotonic: false)   # one-off override
MyApp.IDs.generate!(:export, from: natural_key) # deterministic - see below
```

`:prefix` and `:size` belong to the key and raise if passed - the registry's whole
contract is that a key determines its shape. Drop to `UXID.generate!/1` if you
genuinely need a one-off shape.

## Body-shape options

`:size` is not the only thing that decides what a body looks like. Three more
options can be declared on the key, for the same reason: they change the ID's
shape, so every call site and every schema field has to agree on them.

```elixir
defid :event,   prefix: "evt", size: :small, monotonic: true
defid :session, prefix: "ses", compact_time: true
defid :ticket,  prefix: "tkt", rand_size: 4
```

| Option | Values | Effect |
|---|---|---|
| `:monotonic` | `true`, `false`, a list of sizes | Opts the key into (or out of) [monotonic generation](monotonic.md) without consulting the global policy |
| `:compact_time` | `true`, `false` | Spends 40 rather than 48 bits on the timestamp, moving the freed byte into the random field |
| `:rand_size` | a non-negative integer | An explicit random-byte count, overriding the width implied by `:size` |

Leave one unset and the key defers to the global application configuration
exactly as `UXID.generate!/1` does, so declaring nothing changes nothing. Set it
and it flows into **both** `generate!/2` and `field_opts/1` - so an Ecto
`autogenerate: true` field mints the same shape as an explicit call, with the
declaration living in one place:

```elixir
@primary_key {:id, UXID, [autogenerate: true] ++ MyApp.IDs.field_opts(:event)}
```

A call site can still override any of the three for a one-off
(`generate!(:event, monotonic: false)`); unlike `:prefix` and `:size` they are
defaults, not pins.

Registry-wide defaults are available for the two policy-shaped ones, alongside
`:default_size` and `:default_validate`:

```elixir
use UXID.Registry,
  default_size: :medium,
  default_monotonic: [:small, :medium],
  default_compact_time: false
```

Malformed values are compile errors, like everything else the registry checks: an
unknown size (in `:size` or in a `:monotonic` list) would otherwise fall through
to `:xlarge` and silently mint the wrong shape. Declaring both
`deterministic: true` and `monotonic: true` is rejected too - the pair can never
mint, since one asks for a stable hash and the other for burst-random bits.

## Deterministic keys

Some entities are derived rather than created: their identity is a function of a
natural key, so the same input must always produce the same ID (see the
[Deterministic IDs guide](deterministic.md)). Mint those by key with `from:`:

```elixir
MyApp.IDs.generate!(:export, from: phone)
# => "exp_z9r3k..."   (stable for this input, forever)
```

Passthrough alone still permits the failure mode where one call site derives and
another mints randomly, silently producing two ID shapes for one entity. Declare
the key so that becomes impossible:

```elixir
defid :export, prefix: "exp", deterministic: true, route: true
```

```elixir
MyApp.IDs.generate!(:export)
# ** (ArgumentError) key :export is declared deterministic: true and must be
#    minted with from: - e.g. generate!(:export, from: natural_key)
```

The flag is a *requirement*, not a permission: an undeclared key can still be
minted with `from:`, so an incidental deterministic ID does not force a registry
change.

**Do not wire a deterministic key with `autogenerate: true`.** Ecto has no
per-row input at autogenerate time, so `UXID` mints a random ID there and the
declaration cannot stop it. Mint in a changeset instead:

```elixir
# NOT this, for a deterministic key:
@primary_key {:id, UXID, [autogenerate: true] ++ MyApp.IDs.field_opts(:export)}

# but this:
@primary_key {:id, UXID, MyApp.IDs.field_opts(:export)}

def changeset(export, attrs) do
  export
  |> cast(attrs, [:phone])
  |> put_change(:id, MyApp.IDs.generate!(:export, from: attrs.phone))
end
```

The flag is surfaced on `all/0`, so an app can enforce that rule over its own
registry in a conformance test.

One sizing note: a deterministic body spends its whole width on hash bits, and a
key with no `:size` (and no registry `:default_size`) falls through to the
**`:xlarge`** width - set `:size` explicitly if you want narrower derived IDs.

## By ID string - the runtime routing table

This is the "which resource is this?" map that powers authorization scans, admin
tooling, and global-ID resolution:

```elixir
MyApp.IDs.known?("org_01h...")      # => true   (cheap prefix-only membership check)
MyApp.IDs.key_for("org_01h...")     # => :org
MyApp.IDs.schema_for("org_01h...")  # => MyApp.Org
MyApp.IDs.resolve("org_01h...")     # => %{key: :org, schema: MyApp.Org, category: :account, ...}
```

Lookups split an ID on the **last** delimiter, which is unambiguous without any
registry lookup because a UXID body is Crockford Base32 and never contains the
delimiter - so `in_ref_01h...` recovers the `in_ref` prefix cleanly. For that
reason the `:delimiter` must be a character that cannot appear in a Base32 body
(`"_"` - the default - or `"-"`); an underscore is preferred for compound
prefixes since it does not break double-click-to-select-the-whole-id.

## Routing in a layered or umbrella app

The `schema:` literal above points the registry **up** at a schema module. In a
flat app that is fine. But in a layered app the registry usually wants to live at
the *base* layer - so every layer can depend down on it to mint IDs and read
`field_opts/1` - while the schemas it routes to live *above* it. Naming those
schemas from the base layer inverts the dependency direction (and trips tools
like `Boundary`).

To keep the direction correct, **omit `schema:`** and let each schema register
itself under its key with `UXID.Registered`. The reference then points *down*
(schema names a registry key), never up:

```elixir
# base layer - governance only, no schema: literal
defmodule MyApp.IDs do
  use UXID.Registry
  defid :contact, prefix: "contact", route: true   # filled at boot by self-registration
end

# upper layer - the schema marks itself
defmodule MyApp.CRM.Contact do
  use Ecto.Schema
  use UXID.Registered, key: :contact
  @primary_key {:id, UXID, [autogenerate: true] ++ MyApp.IDs.field_opts(:contact)}
end
```

`route: true` marks a key that *must* resolve to a schema (a `schema:` literal
sets this automatically; a mid-migration entry with neither stays unrouted and is
not required).

### Building and verifying the table at boot

At boot, `verify!/1` scans the given OTP apps for the marker (by reflection - no
base-layer reference to an upper-layer module), assembles the prefix → schema
table into `:persistent_term`, and validates it. Wire it into your top app's
`start/2` so **every** boot - prod, dev, and CI's `mix test` - re-verifies:

```elixir
def start(_type, _args) do
  MyApp.IDs.verify!(otp_apps: [:my_app])   # or all umbrella apps: [:core, :crm, :web]
  # ... start your supervision tree
end
```

`verify!/1` raises `ArgumentError`, listing every problem, when:

- a marker names a key that isn't registered (a typo like `key: :contct`),
- two modules claim the same key, or
- a `route: true` key resolves to no schema.

After it runs, `schema_for/1` resolves layered schemas from the table (flat-app
`schema:` literals resolve with no build at all - `schema_for/1` checks the
literal first, then the table).

## Verifying uniqueness & correctness in CI

You don't need a bespoke CI job - CI already boots your app when it runs
`mix test`, and `verify!/1` in `start/2` runs on that boot. Between the compiler
and `verify!/1` you get:

| Guarantee | Where it's checked |
|---|---|
| Prefix uniqueness + format | Compile time |
| Marker typos, duplicate schema claims, routing completeness | `verify!/1` at boot (prod, dev, CI) |

The one thing the library can't know is "every schema actually draws its id from
the registry." That stays an app-side test. With `prefixes/0` and two small
reflection helpers it's a handful of lines - discover every UXID-keyed schema in
your app and assert each prefix is registered:

```elixir
defmodule MyApp.IDConformanceTest do
  use ExUnit.Case, async: true

  # Ecto stores a UXID field as a parameterized type; pull its :prefix back out.
  defp uxid_prefix(schema, field) do
    case schema.__schema__(:type, field) do
      {:parameterized, {UXID, %{prefix: prefix}}} -> prefix
      {:parameterized, UXID, %{prefix: prefix}} -> prefix
      _ -> nil
    end
  end

  # Every Ecto schema in an app whose (single) primary key is a UXID.
  defp uxid_schemas(app) do
    for mod <- Application.spec(app, :modules) || [],
        Code.ensure_loaded?(mod),
        function_exported?(mod, :__schema__, 1),
        [pk] <- [mod.__schema__(:primary_key)],
        prefix = uxid_prefix(mod, pk),
        prefix != nil,
        do: {mod, prefix}
  end

  test "every UXID-keyed schema draws its prefix from the registry" do
    for {schema, prefix} <- uxid_schemas(:my_app) do
      assert prefix in MyApp.IDs.prefixes(),
             "#{inspect(schema)} uses unregistered UXID prefix #{inspect(prefix)}"
    end
  end
end
```

## Sharing the registry across sources (JSON manifest)

UXIDs are source-agnostic - you can mint them in Postgres with `INSERT ... SELECT`
or on a mobile/JS client that generates an ID offline before upload. To keep the
Elixir registry the single source of truth in those places too, export a JSON
manifest and let the other runtime read it:

```elixir
MyApp.IDs.manifest()
# => [%{"key" => "org", "prefix" => "org", "size" => "medium",
#       "category" => "account", "deterministic" => false,
#       "monotonic" => nil, "compact_time" => nil, "rand_size" => nil}, ...]

MyApp.IDs.manifest_json()
# => ~s([{"key":"org","prefix":"org","size":"medium","category":"account","deterministic":false,"monotonic":null,"compact_time":null,"rand_size":null}, ...])
```

`manifest/0` returns plain JSON-safe data (string keys, scalar values, `nil` for
unset fields) that you can hand to any JSON library; `manifest_json/0` returns a
ready-to-write string with no extra dependency. A common pattern is a tiny Mix
task or release step that writes it to a file your database migrations or client
build consume, so every generator agrees on prefixes and sizes:

```elixir
# lib/mix/tasks/uxid.manifest.ex
defmodule Mix.Tasks.Uxid.Manifest do
  use Mix.Task
  @shortdoc "Writes the UXID prefix manifest to priv/uxid_manifest.json"
  def run(_args) do
    File.write!("priv/uxid_manifest.json", MyApp.IDs.manifest_json())
  end
end
```

The manifest carries `prefix`, `size` (which fixes the random length), `category`,
`key`, `deterministic`, and the body-shape options `monotonic`, `compact_time`,
and `rand_size` (`null` when the key defers to the app's global configuration);
combine each `prefix` with the registry's delimiter and a Base32 body to assemble
an ID anywhere. `compact_time` in particular is not optional reading for another
generator - it changes the encoded length, 8 timestamp characters rather than 10.

`deterministic` tells another generator *which scheme* a key uses, not how to
implement it - a generator that ignored the flag would mint a random ID for a
derived key, which is exactly the cross-source drift the manifest exists to
prevent. Reproducing the scheme itself (SHA-256 over prefix + input, the `z`
marker, the hash-char table) is on the implementer; see the
[Deterministic IDs guide](deterministic.md).

<!-- LINKS -->
[uxid_talk_url]: https://www.youtube.com/watch?v=YIIJClhjxOA
