Skip to content

IDs & identity

An id is the one value that crosses every layer: database, wire, logs and URLs. This page answers what format an id has and who mints it, how an id is typed, and what an id may and may not be used for.

Mint every id as a UUIDv7 through one helper

Section titled “Mint every id as a UUIDv7 through one helper”

Impact: HIGH time-ordered, coordination-free, and testable

  • newId in packages/domain/src/ids.ts is the only code that calls Crypto.randomUUIDv7. Crypto is marked unstable and changed shape in a patch release once, so one call site keeps the next change to a one-file edit.
  • Crypto reads Clock, so TestCrypto.layer(seed) plus TestClock makes ids reproducible in tests.
  • The text form is the lowercase, hyphenated 36-character string, the same in the database, on the wire and in logs. The column is uuid on Postgres and TEXT on SQLite and D1, PRIMARY KEY, with no DEFAULT.
  • Decoding accepts any UUID version. We only generate v7.

❌ Incorrect — an unstable API called at every site, or a generator TestClock cannot reach:

const id = RuleId.make(yield* crypto.randomUUIDv7()) // one of N call sites
const eventId = `evt_${Date.now()}_${counter++}` // per-process counter

✅ Correct — one helper, branded by the id schema:

packages/domain/src/ids.ts
export const newId = <A>(id: { readonly make: (uuid: string) => A }): Effect.Effect<A, never, Crypto.Crypto> =>
Effect.gen(function* () {
const crypto = yield* Crypto.Crypto
return id.make(yield* crypto.randomUUIDv7())
}).pipe(
// oxlint-disable-next-line app/no-effect-die -- Crypto.make returns randomUUIDv7 as Effect.succeed
Effect.orDie,
)
// labeling/create-rule.ts
const id = yield* newId(RuleId)

Source: notes/09-production-concerns/ids-and-identity.md · Decision 1

Let the side that creates the entity mint its id

Section titled “Let the side that creates the entity mint its id”

Impact: HIGH the id must exist before the insert

  • Our web client mints a UUIDv7 for a create it may retry. The server decodes it like any input and inserts with ON CONFLICT (id) DO NOTHING (see idempotency and outbox).
  • The server mints with newId(…) for handlers, jobs, subscribers, outbox events (newId(OutboxEventId)), and rows whose sealed column needs the id for its AAD.
  • No DEFAULT gen_random_uuid(), SERIAL, identity column or AUTOINCREMENT on any entity table. Integer ids remain only where the subject is a sequence, like the migration ledger.
  • A seeded or system row gets a fixed literal UUID committed with the seed.

❌ Incorrect — the database mints, so the id does not exist until the insert:

CREATE TABLE labeling_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL
);

✅ Correct — the id comes from the application:

CREATE TABLE labeling_rules (
id uuid PRIMARY KEY,
org_id uuid NOT NULL
);

Source: notes/09-production-concerns/ids-and-identity.md · Decision 2

Impact: MEDIUM ids are only roughly chronological

  • There is no counter within a millisecond, and replicas’ clocks differ. A client could also mint a v7 with a false timestamp.
  • The created_at column is the timestamp. No business logic, and no sort that must be exact, decodes the time from an id.
  • id is still a valid keyset on its own, or as the tiebreaker after created_at.

❌ Incorrect — business time decoded from the id:

const createdAt = timestampFromUuidV7(rule.id) // a hand-rolled decoder

✅ Correct — time has its own column:

const createdAt = rule.createdAt

Source: notes/09-production-concerns/ids-and-identity.md · Decision 1

Brand every id through one of two factories

Section titled “Brand every id through one of two factories”

Impact: HIGH a brand is only as strong as the path to it

  • Entity ids use makeEntityId; ids someone else mints (an IdP subject, a GitHub or Stripe id) use makeExternalId. No other Schema.brand call creates an id. One brand per value.
  • An external id is never our primary key. It gets its own type named for its source, its own column, and is stored as a string even when the upstream sends a number.
  • A branded value comes from exactly three places: decoding at a boundary, newId, or the schema’s make. Never as; the lint rule app/no-id-assertion flags it, in tests too.
  • Other primitives get a brand only for confusion (two same-typed values cross one signature, like (orgId, ruleId)) or proof (downstream relies on an unchecked invariant). Every brand carries at least one runtime check.

❌ Incorrect — a brand with no check, a cast, and a sentinel id:

export const RuleId = Schema.String.pipe(Schema.brand("RuleId"))
const id = "pull-requests-panel" as RuleId
const fixture = RuleId.make("rule")

✅ Correct — checked brands from the two factories:

export const makeEntityId = <const B extends string>(brand: BrandKey<B>) =>
Schema.String.check(Schema.isUUID()).pipe(Schema.brand<B>(brand))
export const makeExternalId = <const B extends string>(brand: BrandKey<B>) =>
Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed()).pipe(Schema.brand<B>(brand))
export const RuleId = makeEntityId("RuleId")
export const ExternalUserId = makeExternalId("ExternalUserId")

Source: notes/09-production-concerns/ids-and-identity.md · Decisions 3–4

Carry the stored id on the wire, and decode it at the edge

Section titled “Carry the stored id on the wire, and decode it at the edge”

Impact: MEDIUM one predictable 400, no database round trip

  • The wire carries the same UUID string the row stores. There is no public/internal split and no stored prefix.
  • Path parameters are branded ids in params. A malformed id fails the decode and is a bad request. A well-formed id that is unknown or in another org is the domain’s RuleNotFound.
  • API paths address entities by id only. A slug is a mutable display attribute.

❌ Incorrect — a raw string param, checked (or not) inside the handler:

HttpApiEndpoint.get("getRule", "/orgs/:orgId/labeling/rules/:ruleId", {
params: { orgId: Schema.String, ruleId: Schema.String },
})

✅ Correct — branded ids decoded at the boundary:

HttpApiEndpoint.get("getRule", "/orgs/:orgId/labeling/rules/:ruleId", {
params: { ...OrgParams, ruleId: RuleId },
})

Source: notes/09-production-concerns/ids-and-identity.md · Decisions 3, 5

Log ids freely, but never treat one as a secret

Section titled “Log ids freely, but never treat one as a secret”

Impact: HIGH a v7 id is half timestamp and ends up everywhere

  • An id is not an access token. Access goes through authorization and the org_id predicate. A shareable unauthenticated link uses a token from secrets, encryption and tokens, not a row id.
  • Our own ids go on logs and spans as app.<feature>.<entity>_id, because they join a log line to its row. An external id may be personal data (some IdPs use the email as sub), so it goes on a span only where that external call is traced.
  • A v7 id reveals its creation time to the millisecond. That is accepted because the id is never a secret.

❌ Incorrect — an “unlisted” resource protected only by its id:

// GET /shared/:ruleId, no auth: "nobody can guess a UUID"
const rule = yield* rules.findByIdUnscoped(ruleId)

✅ Correct — the id is logged; access is authorization plus a token:

yield* Effect.annotateCurrentSpan("app.labeling.rule_id", ruleId)
const rule = yield* rules.findById(orgId, ruleId) // org-scoped, authorized

Source: notes/09-production-concerns/ids-and-identity.md · Decision 5

  • Prefixed public ids (a boundary codec over the same UUIDs, with a prefix registry in ids.ts) — trigger: the first API consumer that is not our web client; the codec lands in the PR that admits it, before it sees an id.
  • Slug addressing (an endpoint that resolves a human-readable name to an id) — trigger: the first entity users address by name in a URL they share.