The branded ID Zod schemas in packages/shared/src/ids.ts have never been used. Server functions validate IDs as bare strings and then cast the brand back on.
Current state
ids.ts exports each ID twice on purpose -- as a Zod schema and as a type -- and carries an eslint-disable at the top of the file specifically to permit that idiom. Its docblock (lines 16-22) instructs:
From an external boundary (route param, request body, search param) -- run the value through the matching Zod schema (UserId.parse(raw)) or use the schema in a validateSearch / validateParams config.
Inside trusted code, prefer assignment-position type assertions only when crossing a boundary the type system can't see -- never to silence a type error in the middle of a function.
Measured across packages/web/src and packages/workers/src:
Value imports of '@corates/shared/ids': 0
Calls to UserId.parse / OrgId.parse / ProjectId.parse: 0
Type-only imports of '@corates/shared/ids': 40
What the code does instead, packages/web/src/server/functions/org-projects.functions.ts:22-29:
.validator(z.object({ orgId: z.string(), name: z.string().trim().min(1).max(255) }))
.handler(async ({ data, context: { session, db } }) => {
const { orgId, ...projectData } = data;
return createOrgProject(session, db, orgId as OrgId, projectData);
});
Roughly 31 sites: org-projects.functions.ts (15), admin-orgs.functions.ts (11), dev-tools.functions.ts (5).
Why it matters
No live bug. Authorization is enforced by requireOrgMembership / requireProjectAccess, not by the brand, and z.string().brand() erases at runtime.
The cost is that the brand is switched off at the one place it would pay for itself. Every field arrives as string and is cast positionally into multi-argument calls:
deleteProjectById(session, db, data.orgId as OrgId, data.projectId as ProjectId)
Transpose those two arguments and the compiler is silent, because both sides are casts from string. That is verbatim the bug class ids.ts:8 says brands exist to prevent ("passed projectId where userId was expected"). The codebase pays the full ceremony cost of branded IDs and gets compile-time protection only on Drizzle-to-Drizzle paths.
Done when
*.functions.ts validators use the branded schemas: z.object({ orgId: OrgId, projectId: ProjectId })
data.orgId is already OrgId in the handler, so the ~31 casts delete themselves
ids.ts documents something the codebase actually does
Effort: 1-2 hours. Zero runtime change.
Also resolves a Source of Truth conflict: ids.ts currently documents a workflow that has never been used.
Amendment: the brand carries no runtime validation
z.string().brand<'UserId'>() is type-level only. .brand() adds nothing at runtime, so:
UserId.parse('') // succeeds
UserId.parse('not-an-id') // succeeds
UserId.parse(someOrgId) // succeeds
Adopting the schemas in the validators still closes the real hole -- a transposed orgId/projectId stops compiling -- and that is worth doing on its own. But the .parse() prescribed by the ids.ts docblock is a no-op guard. Do not finish this issue believing the boundary is now validated.
Second decision to make while in here: do these IDs get actual format validation? It has to be per-ID, because the formats differ:
crypto.randomUUID() -- ProjectId, ProjectMemberId, FeedbackId, NotificationId, ContactSubmissionId, OrgAccessGrantId. A .uuid() refinement is correct for these.
- Better Auth's own id format --
UserId, OrgId, MemberId. A .uuid() here would be wrong; a length/charset bound is the most that applies.
For comparison, cf-sync-engine (same author) validates its ids for real: MAX_ID_LENGTH, rejection on empty and NUL, and TABLE_NAME_RE on table names, each throwing with a useful message. That is the standard to match if the parse is going to exist at all.
Decision (2026-09-12): adopt the schemas and stop there. Per-ID format refinements are speculative until some code path reads an ID from a boundary that requireOrgMembership / requireProjectAccess does not already check. Reopen that half if such a path appears.
Part of #778 (TypeScript correctness target state and tracker).
The branded ID Zod schemas in
packages/shared/src/ids.tshave never been used. Server functions validate IDs as bare strings and then cast the brand back on.Current state
ids.tsexports each ID twice on purpose -- as a Zod schema and as a type -- and carries an eslint-disable at the top of the file specifically to permit that idiom. Its docblock (lines 16-22) instructs:Measured across
packages/web/srcandpackages/workers/src:What the code does instead,
packages/web/src/server/functions/org-projects.functions.ts:22-29:Roughly 31 sites:
org-projects.functions.ts(15),admin-orgs.functions.ts(11),dev-tools.functions.ts(5).Why it matters
No live bug. Authorization is enforced by
requireOrgMembership/requireProjectAccess, not by the brand, andz.string().brand()erases at runtime.The cost is that the brand is switched off at the one place it would pay for itself. Every field arrives as
stringand is cast positionally into multi-argument calls:Transpose those two arguments and the compiler is silent, because both sides are casts from
string. That is verbatim the bug classids.ts:8says brands exist to prevent ("passed projectId where userId was expected"). The codebase pays the full ceremony cost of branded IDs and gets compile-time protection only on Drizzle-to-Drizzle paths.Done when
*.functions.tsvalidators use the branded schemas:z.object({ orgId: OrgId, projectId: ProjectId })data.orgIdis alreadyOrgIdin the handler, so the ~31 casts delete themselvesids.tsdocuments something the codebase actually doesEffort: 1-2 hours. Zero runtime change.
Also resolves a Source of Truth conflict:
ids.tscurrently documents a workflow that has never been used.Amendment: the brand carries no runtime validation
z.string().brand<'UserId'>()is type-level only..brand()adds nothing at runtime, so:Adopting the schemas in the validators still closes the real hole -- a transposed
orgId/projectIdstops compiling -- and that is worth doing on its own. But the.parse()prescribed by theids.tsdocblock is a no-op guard. Do not finish this issue believing the boundary is now validated.Second decision to make while in here: do these IDs get actual format validation? It has to be per-ID, because the formats differ:
crypto.randomUUID()--ProjectId,ProjectMemberId,FeedbackId,NotificationId,ContactSubmissionId,OrgAccessGrantId. A.uuid()refinement is correct for these.UserId,OrgId,MemberId. A.uuid()here would be wrong; a length/charset bound is the most that applies.For comparison,
cf-sync-engine(same author) validates its ids for real:MAX_ID_LENGTH, rejection on empty and NUL, andTABLE_NAME_REon table names, each throwing with a useful message. That is the standard to match if the parse is going to exist at all.Decision (2026-09-12): adopt the schemas and stop there. Per-ID format refinements are speculative until some code path reads an ID from a boundary that
requireOrgMembership/requireProjectAccessdoes not already check. Reopen that half if such a path appears.Part of #778 (TypeScript correctness target state and tracker).