Identifiers

Last updated 2 days ago

j17 has a single rule for what counts as an identifier — whether it's an aggregate key, an actor id, or a target id. This page is the canonical reference.

The four identifier kinds

Kind Example When to use
UUID (v4 or v5) a0000000-0000-4000-a000-000000000001 The default. Use for entities you mint (users, orders, sessions) or derive deterministically (UUIDv5 from a stable namespace+name).
Humane code ABC123XYZ Short, human-typable ids (referral codes, invite tokens, ticket numbers). 9 characters, Crockford base32.
Singleton global, twilio_webhook Named, fixed instances. The implicit global works for any aggregate type; custom names must be declared in the spec.
Tagged UUID a0000000-0000-4000-a000-000000000001:2026 A UUID with a short suffix tag, for partitioning by period, version, or shard.

Where each kind is valid

All four kinds are accepted as aggregate keys, metadata.actor.id, and metadata.target.id. The same isValidKeyId check runs in all three contexts, so anything you can use as an aggregate key you can also use as an actor or target.

Field UUID v4/v5 Humane code Singleton Tagged UUID Notes
Aggregate key ({id} in URL)
metadata.actor.id system_* actor types use a separate hardcoded id allowlist (see Reserved tokens)
metadata.target.id
Projection primary keys Same rule when keys come from $.key or $.id template paths

If you have a use case where one kind should be allowed in one context but not another, that's a design conversation — but as of today, the rule is uniform.

UUIDs

j17 accepts v4 and v5 UUIDs. Other versions (v1 time-based, v3 MD5-based, v7 time-ordered) are rejected.

xxxxxxxx-xxxx-{4|5}xxx-[89ab]xxx-xxxxxxxxxxxx
  • The version nibble (15th hex digit) must be 4 or 5.
  • The variant nibble (20th hex digit) must be 8, 9, a, or b (RFC 4122 variant).
  • 36 characters including hyphens.

Case handling: UUIDs are accepted in any case (lower, upper, or mixed). j17 canonicalizes them to lowercase at the HTTP edge before any routing or storage, so A0000000-…, a0000000-…, and A0000000-… all key the same aggregate stream. Subsequent reads return the lowercase canonical form. Tagged UUIDs follow the same rule for their UUID half; the tag half stays as-supplied (and must be lowercase to begin with — see Tagged UUIDs below).

Use v4 when the id is randomly generated at write time.

Use v5 when the id should be deterministic from a stable name. v5 is SHA-1(namespace_uuid || name) and gives you the same UUID every time for the same input. This is the right tool for "I want a stable id for an external entity I don't control" — a third-party webhook source, an external account id, a known integration. Pick a stable namespace UUID once and reuse it.

Humane codes

A 9-character identifier using the Crockford base32 subset, designed to be unambiguous when read aloud or typed.

Canonical character set: 0-9, A-Z excluding I, L, O, U (32 characters total — the standard Crockford base32 alphabet). These exclusions avoid confusion with 1, 1, 0, and V respectively.

Normalization at the edge: when humane codes arrive in URLs or request bodies, j17 normalizes them before validation:

Input Normalized to
I 1
L 1
O 0
U V
lowercase uppercase

After normalization, the code must consist only of canonical characters. Generated codes never contain ambiguous characters.

Use humane codes when the identifier needs to be entered by a human (typed from a card, dictated over the phone, scanned imperfectly) and you can tolerate a smaller id space than UUIDs (32^9 ≈ 3.5 × 10^13 distinct codes vs. UUIDv4's 2^122).

Singletons

A singleton is a fixed, well-known identifier for an aggregate that exists in exactly one instance per aggregate type.

global (implicit)

Every aggregate type automatically supports the keyword global as an id — no spec changes required.

curl https://myapp.j17.dev/config/global \
  -H "Authorization: Bearer $API_KEY"

Use global for app-wide config, feature flags, a shared counter, or any "one per type" aggregate.

Custom singletons (declared)

If you need more than one named singleton, or want a more descriptive name than global, declare them at the top level of the spec:

{
  "singletons": ["all", "twilio_webhook", "stripe_webhook"]
}

Custom singletons are instance-wide, not per-type. Once declared, the name is accepted as an identifier for any aggregate type in that instance — and also as an actor id or target id.

# As an aggregate key
curl -X POST https://myapp.j17.dev/webhook_log/twilio_webhook/was_received \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"data": {...}, "metadata": {"actor": {"type": "webhook", "id": "twilio_webhook"}}}'

# As an actor (in the same call above)
# As a target
curl -X POST https://myapp.j17.dev/audit/<uuid>/was_recorded \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"data": {...}, "metadata": {
    "actor": {"type": "user", "id": "<uuid>"},
    "target": {"type": "integration", "id": "twilio_webhook"}
  }}'

Naming guidance

The spec parser accepts any list of strings as singletons, so j17 does not enforce naming rules today — but the conventions below avoid runtime ambiguity and should be followed:

  • 1 character or longer
  • Don't redeclare global (it's already implicit)
  • Don't pick a name that's also a valid identifier of another kind — i.e. not a valid v4/v5 UUID, not a valid 9-character humane code ([0-9A-Z] minus I/L/O/U), and not a tagged-UUID form (<uuid>:<tag>). A collision works at write time but makes the id ambiguous in tooling and logs.
  • Treat the name as a URL-safe slug — letters, digits, and underscores. Avoid characters that need URL encoding.

A future spec-deploy validation pass may enforce these rules; for now they're conventions.

Concurrency caveat

Singletons name a single aggregate, which means every write to that singleton contends on the same Redis stream and the same OCC counter. Under high write concurrency this becomes a hot key — many writers will see 409 conflicts and need to retry. Singletons are great for config and low-write-rate state, less so for things that take a write per request.

If you find yourself wanting a singleton for a high-throughput counter, consider tagged UUIDs (partition by time period) or a projection over many small aggregates instead.

Reads of singletons always succeed

GET /:type/:id returns 404 Not Found when an aggregate has never had an event written. Singletons are the exception — GET /:type/global and any custom-singleton id return 200 OK with data: {} and metadata.length: 0 even before the first write. The conceptual contract is "singletons always exist, possibly empty." If you need missingness detection on a singleton, branch on metadata.length > 0 rather than status code.

Don't fake singletons with UUIDs

If you need a singleton, use global or declare a custom singleton. Don't generate a fixed UUID (like 00000000-0000-5000-8000-000000000000) and use it as a pseudo-singleton — it works, but it's fighting the system, and the same advice applies to actor ids: a webhook actor doesn't need a fake UUIDv5 derived from "twilio"; declare twilio_webhook as a singleton and use it directly.

Tagged UUIDs

A UUID followed by a short tag suffix, separated by a colon.

<v4-or-v5-uuid>:<tag>
  • The UUID portion must be a valid v4 or v5 UUID.
  • The tag is 1-10 characters, lowercase letters and digits only ([a-z0-9]+).

Common uses:

Pattern Example Purpose
Time partition uuid:2026 Roll a long-running aggregate (audit log, ledger) into per-period instances
Schema version uuid:v2 Pin an aggregate to a schema generation during migration
Shard / region uuid:us, uuid:eu Co-locate data with regional tenancy

The UUID half identifies the entity; the tag half names a variant. The same underlying UUID can have multiple tagged variants without collision (uuid:2026, uuid:2025, and the bare uuid are three different identifiers).

Tagged UUIDs vs singletons

Both name "well-known" identifiers, but they answer different questions:

  • Tagged UUIDs partition a UUID-keyed space. The full id is still mostly random; the suffix is the variant. You'd have one tagged-UUID id per (entity, period) pair.
  • Singletons name a fixed instance with no random component. The whole id is a known constant.

If you'd write a for tag in tags loop, that's a tagged UUID. If you'd write the_one_and_only, that's a singleton.

Reserved tokens

Token Meaning
global Implicit per-type singleton; works as a key, actor id, or target id without spec configuration
system_* (actor type prefix) Reserved for platform-internal actors. User specs cannot declare an agent_type starting with system_.
system_agent id allowlist When actor.type is system_*, actor.id must be one of: event_scheduler, test_data_importer, saga_coordinator, saga_compensator. The general identifier rule does not apply.
_* (event type prefix) Reserved for system-generated events (e.g., _was_tombstoned). User specs cannot declare event types starting with _.

Identifier validation summary

Two steps run on every incoming id, in order:

Step Where it runs Behavior
Normalize Worker HTTP edge (Elixir) UUIDs (and the UUID half of tagged UUIDs) downcased; mixed-case humane codes uppercased and ambiguous chars (I, L, O, U) substituted. Other ids pass through unchanged.
Validate Worker write path (Zig) Canonicalized id must match one of the four kinds.

Validation failures:

Check Returned on failure
Aggregate key format 422 with path: "key"
metadata.actor.id format 422 with path: "metadata.actor.id"
metadata.target.id format 422 with path: "metadata.target.id"
Tag format on tagged UUIDs 422 with the path of the offending field
Singleton naming conventions Not enforced today — see Naming guidance

See also

Can't find what you need? support@j17.app