docs: add searchable API error reference (#12502)

## Summary
- Add a searchable catalog of every error ID ZITADEL's gRPC/Connect APIs
can return (`content/apis/errors.mdx`), wired into the sidebar next to
"gRPC Status Codes" (with a cross-link added there too). There was
previously no such reference — error IDs are assigned ad hoc at ~3,800
call sites across the backend.
- `scripts/generate-error-reference.ts` scans `internal/**/*.go` and
`backend/**/*.go` for `zerrors.Throw*()` call sites, resolves i18n
messages, and clusters ~3,200 distinct IDs into ~1,160 distinct causes
by (subsystem, gRPC code, message) — so identical causes are merged into
one entry instead of repeated ~3,200 times.
- Detects real ID collisions (same ID thrown for genuinely different
reasons at different call sites) and surfaces them as warnings, rather
than silently merging them.
- Generates a worked example API response for every cause.
- 116 of the highest-impact causes (covering the majority of call sites)
carry an explanation written against the actual source rather than the
raw message text; the rest carry a mechanically derived explanation —
both are visibly labeled by source so the depth of research is
transparent.
- New `ErrorReference` component renders the catalog as a searchable,
collapsible subsystem → gRPC code → cause tree.

The generator is a standalone script (`pnpm generate:error-reference` in
`apps/docs`), not wired into the docs build's `generate` chain, since
scanning the whole Go backend on every docs build would be wasteful. The
generated `data.json` and the curated overrides are committed, the same
way `content/apis/benchmarks/**/output.json` is already committed
despite being externally generated.

## Test plan
- [x] `pnpm nx run @zitadel/docs:check-types` passes
- [x] `pnpm nx run @zitadel/docs:lint` passes
- [x] `pnpm nx run @zitadel/docs:check-links` passes
- [x] Verified locally in `pnpm nx run @zitadel/docs:dev`: page renders,
tree expands/collapses, search filters by ID/message, collision warnings
render correctly (e.g. `AUTHZ-Kl3p0`)

---------

Co-authored-by: Rajat Singh <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Marco A. <[email protected]>
This commit is contained in:
Rajat Singh
2026-09-08 09:47:58 +00:00
committed by GitHub
co-authored by Rajat Singh Claude Sonnet 5 Marco A.
parent 8b87ca50d4
commit e8d95f306b
14 changed files with 1327 additions and 0 deletions
@@ -0,0 +1,10 @@
import data from '@/components/ErrorReference/data.json';
// Serves the ~1MB error-reference catalog from a route handler instead of a
// static import, so it never lands in the client JS bundle for /apis/errors
// — the browser only pays for it once, on demand, and caches it after.
export function GET() {
return Response.json(data, {
headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
});
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,396 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, TriangleAlert } from 'lucide-react';
import { cn } from '@/utils/cn';
interface ErrorLocation {
file: string;
line: number;
}
interface ErrorIdEntry {
id: string;
locations: ErrorLocation[];
}
interface ExampleResponse {
httpStatus: number;
grpcCode: number;
body: { code: number; message: string; details: [{ '@type': string; id: string; message: string }] };
}
type WhySource = 'curated' | 'i18n-decomposition' | 'defensive-guard-template' | 'generic-fallback';
interface ErrorCluster {
key: string;
message: string;
messageType: 'i18n' | 'literal';
ids: ErrorIdEntry[];
why: string;
whySource: WhySource;
example: ExampleResponse;
collidesWith: { key: string; message: string; subsystem: string }[];
}
interface KindBucket {
kind: string;
clusters: ErrorCluster[];
}
interface Subsystem {
id: string;
name: string;
summary: string;
kinds: KindBucket[];
}
interface ErrorReferenceData {
meta: {
generatedAt: string;
sourceRef: string;
totalCallSites: number;
totalIds: number;
totalClusters: number;
totalCollisions: number;
};
subsystems: Subsystem[];
}
const WHY_SOURCE_LABEL: Record<WhySource, string> = {
curated: 'Researched from source',
'i18n-decomposition': 'Derived from message key',
'defensive-guard-template': 'Internal guard',
'generic-fallback': 'Auto-generated',
};
const WHY_SOURCE_STYLE: Record<WhySource, string> = {
curated: 'bg-fd-accent text-fd-accent-foreground',
'i18n-decomposition': 'border border-fd-border text-fd-muted-foreground',
'defensive-guard-template': 'border border-fd-border text-fd-muted-foreground',
'generic-fallback': 'border border-dashed border-fd-border text-fd-muted-foreground',
};
const MAX_LOCATIONS_SHOWN = 5;
const MAX_SEARCH_RESULTS = 150;
function clusterIdCount(cluster: ErrorCluster) {
return cluster.ids.length;
}
function kindIdCount(kind: KindBucket) {
return kind.clusters.reduce((n, c) => n + clusterIdCount(c), 0);
}
function subsystemIdCount(s: Subsystem) {
return s.kinds.reduce((n, k) => n + kindIdCount(k), 0);
}
function matchesQuery(cluster: ErrorCluster, q: string) {
if (cluster.message.toLowerCase().includes(q)) return true;
if (cluster.why.toLowerCase().includes(q)) return true;
return cluster.ids.some((e) => e.id.toLowerCase().includes(q));
}
export default function ErrorReference() {
const [data, setData] = useState<ErrorReferenceData | null>(null);
const [loadError, setLoadError] = useState(false);
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
fetch('/api/error-reference')
.then((res) => {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
})
.then((json) => {
if (!cancelled) setData(json as ErrorReferenceData);
})
.catch(() => {
if (!cancelled) setLoadError(true);
});
return () => {
cancelled = true;
};
}, []);
const trimmedQuery = query.trim().toLowerCase();
const searchResults = useMemo(() => {
if (!data || !trimmedQuery) return null;
const results: { subsystem: Subsystem; kind: KindBucket; cluster: ErrorCluster }[] = [];
for (const subsystem of data.subsystems) {
for (const kind of subsystem.kinds) {
for (const cluster of kind.clusters) {
if (matchesQuery(cluster, trimmedQuery)) {
results.push({ subsystem, kind, cluster });
if (results.length >= MAX_SEARCH_RESULTS) return results;
}
}
}
}
return results;
}, [data, trimmedQuery]);
function toggle(key: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
if (loadError) {
return <p className="text-sm text-fd-muted-foreground">Couldn&apos;t load the error reference. Try reloading the page.</p>;
}
if (!data) {
return <p className="text-sm text-fd-muted-foreground">Loading error reference…</p>;
}
return (
<div className="not-prose flex flex-col gap-4">
<div className="flex flex-col gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={`Search ${data.meta.totalIds.toLocaleString()} error IDs or messages, e.g. "COMMAND-2M0fs" or "not found"…`}
aria-label="Search error IDs or messages"
className="w-full px-3 py-2 text-sm bg-transparent border rounded-md border-fd-border text-fd-foreground focus:outline-none focus:ring-2 focus:ring-fd-accent"
/>
<p className="text-xs text-fd-muted-foreground">
{data.meta.totalIds.toLocaleString()} error IDs, grouped into {data.meta.totalClusters.toLocaleString()}{' '}
distinct causes across {data.subsystems.length} subsystems. {data.meta.totalCollisions} of those causes
share an ID string with at least one other cause — see the warning on those entries.
</p>
</div>
{searchResults ? (
<SearchResults results={searchResults} truncated={searchResults.length >= MAX_SEARCH_RESULTS} />
) : (
<div className="flex flex-col gap-2">
{data.subsystems.map((subsystem) => (
<SubsystemSection
key={subsystem.id}
subsystem={subsystem}
expanded={expanded}
toggle={toggle}
/>
))}
</div>
)}
</div>
);
}
function SearchResults({
results,
truncated,
}: {
results: { subsystem: Subsystem; kind: KindBucket; cluster: ErrorCluster }[];
truncated: boolean;
}) {
if (results.length === 0) {
return <p className="text-sm text-fd-muted-foreground">No matches.</p>;
}
return (
<div className="flex flex-col gap-2">
{truncated && (
<p className="text-xs text-fd-muted-foreground">
Showing the first {MAX_SEARCH_RESULTS} matches — refine your search for more specific results.
</p>
)}
{results.map(({ subsystem, kind, cluster }) => (
<div key={cluster.key} className="border rounded-md border-fd-border">
<div className="px-3 py-2 text-xs text-fd-muted-foreground border-b border-fd-border">
{subsystem.name} <ChevronRight className="inline size-3" /> {kind.kind}
</div>
<ClusterDetail cluster={cluster} />
</div>
))}
</div>
);
}
function SubsystemSection({
subsystem,
expanded,
toggle,
}: {
subsystem: Subsystem;
expanded: Set<string>;
toggle: (key: string) => void;
}) {
const open = expanded.has(subsystem.id);
return (
<div className="border rounded-md border-fd-border">
<button
type="button"
onClick={() => toggle(subsystem.id)}
aria-expanded={open}
className="flex items-start w-full gap-2 px-3 py-2 text-left"
>
<ChevronRight className={cn('size-4 shrink-0 mt-0.5 transition-transform text-fd-muted-foreground', open && 'rotate-90')} />
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-fd-foreground">
{subsystem.name} <span className="font-normal text-fd-muted-foreground">({subsystemIdCount(subsystem)} IDs)</span>
</span>
{open && <span className="text-xs text-fd-muted-foreground">{subsystem.summary}</span>}
</div>
</button>
{open && (
<div className="flex flex-col gap-2 px-3 pb-3 pl-9">
{subsystem.kinds.map((kind) => (
<KindSection
key={kind.kind}
parentKey={subsystem.id}
kind={kind}
expanded={expanded}
toggle={toggle}
/>
))}
</div>
)}
</div>
);
}
function KindSection({
parentKey,
kind,
expanded,
toggle,
}: {
parentKey: string;
kind: KindBucket;
expanded: Set<string>;
toggle: (key: string) => void;
}) {
const key = `${parentKey}::${kind.kind}`;
const open = expanded.has(key);
return (
<div className="border rounded-md border-fd-border/60">
<button type="button" onClick={() => toggle(key)} aria-expanded={open} className="flex items-center w-full gap-2 px-3 py-1.5 text-left">
<ChevronRight className={cn('size-3.5 shrink-0 transition-transform text-fd-muted-foreground', open && 'rotate-90')} />
<span className="font-mono text-xs text-fd-foreground">{kind.kind}</span>
<span className="text-xs text-fd-muted-foreground">
{kind.clusters.length} cause{kind.clusters.length === 1 ? '' : 's'}, {kindIdCount(kind)} IDs
</span>
</button>
{open && (
<div className="flex flex-col gap-1.5 px-3 pb-2 ml-5">
{kind.clusters.map((cluster) => (
<ClusterRow key={cluster.key} cluster={cluster} expanded={expanded} toggle={toggle} />
))}
</div>
)}
</div>
);
}
function ClusterRow({
cluster,
expanded,
toggle,
}: {
cluster: ErrorCluster;
expanded: Set<string>;
toggle: (key: string) => void;
}) {
const open = expanded.has(cluster.key);
return (
<div className="border rounded-md border-fd-border/60">
<button type="button" onClick={() => toggle(cluster.key)} aria-expanded={open} className="flex items-start w-full gap-2 px-2 py-1.5 text-left">
<ChevronRight className={cn('size-3.5 shrink-0 mt-0.5 transition-transform text-fd-muted-foreground', open && 'rotate-90')} />
<span className="flex-1 font-mono text-xs break-all text-fd-foreground">{cluster.message || '(empty message)'}</span>
{cluster.collidesWith.length > 0 && <TriangleAlert className="size-3.5 shrink-0 text-amber-500" />}
<span className="text-xs shrink-0 text-fd-muted-foreground">{cluster.ids.length}</span>
</button>
{open && <ClusterDetail cluster={cluster} />}
</div>
);
}
function ClusterDetail({ cluster }: { cluster: ErrorCluster }) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
function toggleLocations(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div className="flex flex-col gap-3 px-3 pb-3 text-xs">
<div className="flex items-start justify-between gap-2">
<p className="text-fd-foreground">{cluster.why}</p>
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium whitespace-nowrap', WHY_SOURCE_STYLE[cluster.whySource])}>
{WHY_SOURCE_LABEL[cluster.whySource]}
</span>
</div>
{cluster.collidesWith.length > 0 && (
<div className="flex flex-col gap-1 p-2 border rounded-md border-amber-500/40 bg-amber-500/10">
<p className="flex items-center gap-1 font-medium text-amber-600 dark:text-amber-400">
<TriangleAlert className="size-3.5" /> Same ID string used elsewhere with a different meaning
</p>
<p className="text-fd-muted-foreground">
Don&apos;t disambiguate by ID alone — a client also needs the message. Also thrown for:
</p>
<ul className="list-disc list-inside text-fd-muted-foreground">
{cluster.collidesWith.map((c) => (
<li key={c.key}>
<span className="font-mono">&ldquo;{c.message}&rdquo;</span> ({c.subsystem})
</li>
))}
</ul>
</div>
)}
<div className="flex flex-col gap-1.5">
<p className="font-medium text-fd-muted-foreground">{cluster.ids.length === 1 ? 'ID' : `IDs (${cluster.ids.length})`}</p>
<div className="flex flex-col gap-2">
{cluster.ids.map((idEntry) => {
const showAll = expandedIds.has(idEntry.id);
const shown = showAll ? idEntry.locations : idEntry.locations.slice(0, MAX_LOCATIONS_SHOWN);
const hiddenCount = idEntry.locations.length - shown.length;
return (
<div key={idEntry.id} className="flex flex-col gap-0.5">
<code className="font-mono text-fd-foreground">{idEntry.id}</code>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 pl-3 text-fd-muted-foreground">
{shown.map((loc, i) => (
<span key={i} className="font-mono">
{loc.file}:{loc.line}
</span>
))}
{hiddenCount > 0 && (
<button
type="button"
onClick={() => toggleLocations(idEntry.id)}
className="underline underline-offset-2 hover:text-fd-foreground"
>
+{hiddenCount} more
</button>
)}
{showAll && idEntry.locations.length > MAX_LOCATIONS_SHOWN && (
<button
type="button"
onClick={() => toggleLocations(idEntry.id)}
className="underline underline-offset-2 hover:text-fd-foreground"
>
show fewer
</button>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-1">
<p className="font-medium text-fd-muted-foreground">
Example response &mdash; HTTP {cluster.example.httpStatus}, gRPC {cluster.example.grpcCode}
</p>
<pre className="p-2 overflow-x-auto rounded-md bg-fd-secondary text-fd-secondary-foreground">
<code>{JSON.stringify(cluster.example.body, null, 2)}</code>
</pre>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
---
title: API error reference
description: Every error ID ZITADEL's gRPC/Connect APIs can return, grouped by subsystem and cause, with why each one happens and an example response.
sidebar_label: Error Reference
---
import ErrorReference from '@/components/ErrorReference';
An error from a ZITADEL gRPC/Connect ("resource") API looks like this:
```json
{
"code": 16,
"message": "Token is invalid (AUTH-7fs1e)",
"details": [
{
"@type": "type.googleapis.com/zitadel.v1.ErrorDetail",
"id": "AUTH-7fs1e",
"message": "Token is invalid"
}
]
}
```
`code` is the gRPC status code — see [gRPC Status Codes](/apis/statuscodes) for the full mapping to HTTP statuses. `details[].id` is a free-form identifier assigned by hand at the source call site (a package prefix plus a short random suffix) — it is **not** a stable, structured error code, and the same ID string is occasionally reused for more than one distinct condition (flagged below where it happens). `details[].message` is either translated for end users or, for internal/operator-facing failures, a literal diagnostic string that's never translated.
This `zitadel.v1.ErrorDetail`/`id` shape is what every error on this page currently uses. It isn't the only shape, though: the server picks `zitadel.error.v2.ErrorDetail`/`slug` instead whenever an error's ID is dot-separated rather than dash-separated (see [Error details and slugs](/apis/statuscodes#error-details-and-slugs)) — no call site does that yet, but new ones may. Check `details[]`'s `@type` before assuming which shape you got.
This page excludes the OAuth2/OIDC token-endpoint error format (`{"error": "invalid_request", ...}` from `/oauth/v2/token`, `/oauth/v2/introspect`, etc.), which follows [RFC 6749 §5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) and is documented at the [OIDC endpoints reference](/apis/openidoauth/endpoints).
## How to use this reference
Every error ID is listed below, organized three levels deep — outward to inward, common to distinct:
1. **Subsystem** — the part of the backend that throws it (broadest: 8 groups)
2. **gRPC code** — the kind of failure: Not Found, Invalid Argument, Permission Denied, and so on
3. **Cause** — one entry per distinct underlying message. IDs that mean exactly the same thing (a validation check reused verbatim at several call sites) are merged into a single entry instead of repeated
That shape follows how the errors actually break down: there are only 8 subsystems and about a dozen gRPC codes, but roughly 1,150 genuinely distinct causes behind the ~3,200 individual IDs. **Use the search box** below to jump straight to an ID or a phrase — it matches across IDs, messages, and explanations, so it's usually faster than browsing the tree if you already have an ID or error text in hand.
Each entry shows why the error happens, an example response, and every source location it's thrown from. When the same ID string is also thrown for a *different* reason elsewhere, a warning links the two — the ID alone isn't sufficient to tell them apart, so check the message too.
<ErrorReference />
+2
View File
@@ -29,3 +29,5 @@ Use `ErrorDetail.message` for developer diagnostics, not end-user text. Prefer m
This slug-based handling is currently relevant for backend/domain paths that run with relational-storage-backed logic.
Do not assume it for `v1`, `v2beta`, or `v3alpha` APIs or when using a v2 endpoint without having the relational storage feature enabled.
For a searchable catalog of every `details[].id` ZITADEL can return — grouped by subsystem and cause, with why each one happens and an example response — see the [Error Reference](/apis/errors).
+46
View File
@@ -0,0 +1,46 @@
// Mirrors the table in content/apis/statuscodes.mdx. Kept here so
// scripts/generate-error-reference.ts can derive HTTP statuses/example
// responses without hand-duplicating the mapping.
export interface GrpcStatusEntry {
code: number;
name: string;
httpStatus: number;
httpText: string;
}
export const GRPC_STATUS: Record<string, GrpcStatusEntry> = {
OK: { code: 0, name: 'OK', httpStatus: 200, httpText: 'OK' },
CANCELLED: { code: 1, name: 'CANCELLED', httpStatus: 499, httpText: 'Client Closed Request' },
UNKNOWN: { code: 2, name: 'UNKNOWN', httpStatus: 500, httpText: 'Internal' },
INVALID_ARGUMENT: { code: 3, name: 'INVALID_ARGUMENT', httpStatus: 400, httpText: 'Bad Request' },
DEADLINE_EXCEEDED: { code: 4, name: 'DEADLINE_EXCEEDED', httpStatus: 504, httpText: 'Gateway Timeout' },
NOT_FOUND: { code: 5, name: 'NOT_FOUND', httpStatus: 404, httpText: 'Not found' },
ALREADY_EXISTS: { code: 6, name: 'ALREADY_EXISTS', httpStatus: 409, httpText: 'Conflict' },
PERMISSION_DENIED: { code: 7, name: 'PERMISSION_DENIED', httpStatus: 403, httpText: 'Forbidden' },
RESOURCE_EXHAUSTED: { code: 8, name: 'RESOURCE_EXHAUSTED', httpStatus: 429, httpText: 'Too Many Requests' },
FAILED_PRECONDITION: { code: 9, name: 'FAILED_PRECONDITION', httpStatus: 400, httpText: 'Bad Request' },
ABORTED: { code: 10, name: 'ABORTED', httpStatus: 409, httpText: 'Conflict' },
OUT_OF_RANGE: { code: 11, name: 'OUT_OF_RANGE', httpStatus: 400, httpText: 'Bad Request' },
UNIMPLEMENTED: { code: 12, name: 'UNIMPLEMENTED', httpStatus: 501, httpText: 'Not Implemented' },
INTERNAL: { code: 13, name: 'INTERNAL', httpStatus: 500, httpText: 'Internal' },
UNAVAILABLE: { code: 14, name: 'UNAVAILABLE', httpStatus: 503, httpText: 'Service Unavailable' },
DATA_LOSS: { code: 15, name: 'DATA_LOSS', httpStatus: 500, httpText: 'Internal' },
UNAUTHENTICATED: { code: 16, name: 'UNAUTHENTICATED', httpStatus: 401, httpText: 'Unauthorized' },
};
// zerrors.Throw<Kind> function-name suffix -> gRPC status key.
export const THROW_KIND_TO_GRPC_STATUS: Record<string, keyof typeof GRPC_STATUS> = {
Internal: 'INTERNAL',
InvalidArgument: 'INVALID_ARGUMENT',
NotFound: 'NOT_FOUND',
AlreadyExists: 'ALREADY_EXISTS',
PermissionDenied: 'PERMISSION_DENIED',
PreconditionFailed: 'FAILED_PRECONDITION',
Unauthenticated: 'UNAUTHENTICATED',
Unimplemented: 'UNIMPLEMENTED',
ResourceExhausted: 'RESOURCE_EXHAUSTED',
Unavailable: 'UNAVAILABLE',
DeadlineExceeded: 'DEADLINE_EXCEEDED',
Unknown: 'UNKNOWN',
};
+5
View File
@@ -974,6 +974,11 @@ export const apisSidebar: readonly SidebarItem[] = [
label: "gRPC Status Codes",
id: "apis/statuscodes",
},
{
type: "doc",
label: "Error Reference",
id: "apis/errors",
},
{
type: "link",
label: "Rate Limits (Cloud)",
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"generate:index-pages": "tsx scripts/generate-index-pages.ts",
"generate:error-reference": "tsx scripts/generate-error-reference.ts",
"fetch:remote-content": "node scripts/fetch-remote-content.mjs",
"generate:proto-docs": "node scripts/generate-proto-docs.mjs",
"generate:api-reference": "node scripts/generate-api-reference.ts --only-generate",
+21
View File
@@ -139,6 +139,27 @@
}
]
},
"generate-error-reference": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm generate:error-reference",
"cwd": "apps/docs"
},
"outputs": [
"{projectRoot}/components/ErrorReference/data.json"
],
"cache": true,
"inputs": [
"{projectRoot}/scripts/generate-error-reference.ts",
"{projectRoot}/scripts/error-reference-overrides/**",
"{projectRoot}/lib/grpc-status.ts",
"{workspaceRoot}/internal/**/*.go",
"{workspaceRoot}/backend/**/*.go",
"{workspaceRoot}/internal/static/i18n/en.yaml",
"{workspaceRoot}/internal/api/ui/login/static/i18n/en.yaml",
"{workspaceRoot}/internal/notification/static/i18n/en.yaml"
]
},
"generate-index-pages": {
"executor": "nx:run-commands",
"options": {
@@ -0,0 +1,62 @@
{
"api|INVALID_ARGUMENT|List.Query.Invalid": {
"why": "The list/search request included a filter or sub-query variant that the endpoint's query converter has no matching case for, so it falls through to a default branch that rejects it. Common causes: an unset oneof filter field, a filter type that belongs to a different list endpoint, or a filter variant added to the proto but not yet implemented in that endpoint's converter."
},
"storage|INTERNAL|An internal error occurred": {
"why": "Generic internal-error fallback from the CockroachDB-backed static asset store (internal/static/database/crdb.go): building the SQL statement for an asset insert/select/delete failed, or the database round trip itself (query, scan, or exec) failed — for example because of a lost connection or constraint violation. The message carries no further detail beyond 'the asset store's database call failed.'"
},
"api|INTERNAL|An internal error occurred": {
"why": "Generic Errors.Internal fallback covering unrelated failures: a stored event's JSON payload fails to unmarshal while building an event/audit API response, or an outbound HTTP request or DNS lookup fails while ZITADEL checks a custom domain's ownership-verification challenge file. These call sites share only the fallback message, not a common root cause."
},
"storage|PERMISSION_DENIED|Permission denied": {
"why": "A backend/v3 domain command's permission check (opts.Permissions.Check*Permission) rejected the caller before the command ran — for example a missing session-write, instance-domain-write, or instance-read permission, depending on which command was invoked. The caller is authenticated but the acting user or service account lacks the specific role or grant that command requires."
},
"api|INVALID_ARGUMENT|Errors.ExternalIDP.IDPTypeNotImplemented": {
"why": "The external-IdP flow being invoked doesn't support the provider's type: the OAuth authorization-code callback path receives an intent backed by a JWT, LDAP, or SAML provider (none of which use code exchange), the intent-retrieval endpoint hits an IdP type it has no user-unmarshalling case for, or the LDAP-credential login path is called with an idpId that doesn't resolve to an LDAP provider. Verify idpId points to a provider whose type matches the operation being called."
},
"api|FAILED_PRECONDITION|no user agent id": {
"why": "The legacy (v1) OIDC auth-request flow resolves a 'user agent ID' from the zitadel.useragent cookie set by the login UI, and that cookie is missing from the request — typically because the auth-code or token endpoint was reached without first completing the browser-based login UI in the same browser session, or because the cookie was blocked or had expired. The newer v2 session-based auth request flow doesn't use this cookie and isn't affected."
},
"storage|FAILED_PRECONDITION|Errors.Missing.InstanceID": {
"why": "A backend/v3 session-factor-check command (password, TOTP, OTP-SMS, passkey, IdP-intent) has an empty InstanceID field at validation time. Instance ID is populated from the resolved request context rather than direct caller input, so this points to the instance context not being attached when the command was built, not a value the API caller sets."
},
"storage|FAILED_PRECONDITION|Errors.Missing.SessionID": {
"why": "Same guard as Errors.Missing.InstanceID but for SessionID: a session-factor-check command (password, TOTP, OTP-SMS, passkey, or IdP-intent check) was built without a session_id. In practice, the request submitting the factor-check result omitted the sessionId identifying which in-progress session it belongs to."
},
"storage|INVALID_ARGUMENT|Errors.Invalid.Argument": {
"why": "Generic field-validation failure in the backend/v3 instance-domain add/remove commands: domain_name is empty or longer than 253 characters after trimming, or the instance_id in the request doesn't match the instance resolved from the request context. The generic message doesn't indicate which of these checks failed."
},
"storage|INVALID_ARGUMENT|List.Query.Invalid": {
"why": "Backend/v3 counterpart to the API-layer List.Query.Invalid: a List query (instances, instance domains, sessions, …) received a filter variant with no matching case in its condition-building switch. An unrecognized, unset, or wrong-endpoint filter oneof falls through to the default branch and produces this error."
},
"api|PERMISSION_DENIED|Token is invalid": {
"why": "Returned by the OAuth token-exchange endpoint (grant_type=...token-exchange) when subject_token or actor_token fails verification for its declared token type: an access token that doesn't verify or has the wrong audience, an ID token failing signature/hint verification, a JWT-profile assertion failing signature or claim checks, or a urn:zitadel:params:oauth:token-type:user_id value that doesn't match an existing user. The token must match its declared *_token_type and still be valid/unexpired."
},
"storage|INTERNAL|unexpected number of rows updated": {
"why": "Defensive invariant check after an UPDATE scoped to a primary key in backend/v3 commands (session-factor checks, org activate/deactivate, …): the code expects exactly one row affected and throws this if more than one was. Since the WHERE clause always matches a primary key, this should be unreachable in normal operation — it guards against a data-integrity bug, not a condition an API caller can trigger."
},
"storage|NOT_FOUND|Organisation not found": {
"why": "The id supplied to an organization command (update, activate, deactivate, delete) doesn't match any organization on the current instance — the ID is wrong, the org was already deleted, or it belongs to a different instance."
},
"api|INVALID_ARGUMENT|Code is invalid": {
"why": "Login-UI handlers for setting an initial password, verifying/initializing a user, and completing an invite all collapse the underlying command's error (SetPasswordWithVerifyCode, HumanVerifyInitCode, VerifyInviteCodeSetPassword) into this single message. It surfaces whenever the code submitted through the emailed link's form doesn't match what ZITADEL generated for that user — wrong, expired, or already used — without distinguishing which."
},
"storage|INTERNAL|Object could not be read": {
"why": "Wraps read failures across the asset-storage backends: on the CockroachDB-backed store, a Scan against the assets table failed for a reason other than 'no rows' (e.g. a dropped connection); on the S3/minio-backed store, fetching an object's metadata or bytes failed at the storage API/network level. Affects reads of uploaded assets such as org logos, icons, or custom CSS."
},
"storage|INTERNAL|Object could not be removed": {
"why": "Same failure class as Object could not be read but for deletes: the SQL DELETE against the assets table, or the S3/minio RemoveObject(s) call, failed at the database or storage-API level while removing one asset or all assets of a given type/instance."
},
"api|INTERNAL|Error on validate login credentials": {
"why": "Wraps go-webauthn library failures validating a passkey/security-key assertion at login: either the browser's credential-assertion response couldn't be parsed, or ValidateLogin rejected it due to a signature/challenge mismatch, wrong authenticator, or origin/RPID mismatch. Almost always caused by a bad, stale, or replayed WebAuthn response from the client, but the underlying library error isn't surfaced beyond this generic message."
},
"api|INTERNAL|Error.Internal": {
"why": "A defensive type-assertion guard repeated across the OIDC token endpoints (device, code, refresh, client-credentials, token-exchange): r.Client is expected to always be ZITADEL's own Client implementation as handed back by the underlying OIDC (op) framework, and the failure branch should be unreachable in normal operation. It signals a framework-level bug rather than something triggerable by a malformed request."
},
"api|PERMISSION_DENIED|No matching permissions found": {
"why": "The authenticated caller has no role or membership granting the permission the called endpoint requires, either globally or — for endpoints that check a specific org/resource — scoped to the org/resource named in the request. This is the standard 'authenticated but not authorized' rejection from the role/permission-mapping layer, distinct from a token-validity error."
},
"api|UNAUTHENTICATED|invalid token": {
"why": "Bearer access-token verification failed for one of several reasons depending on the call site: the token string couldn't be parsed, its ID doesn't match any known non-revoked token, it has expired, or — for v2 access tokens — the subject claim embedded in the token doesn't match the user ID stored for it (a tamper/truncation guard; see GHSA-6mq3-xmgp-pjm5). Re-authenticate and use a fresh, valid access token."
}
}
@@ -0,0 +1,64 @@
{
"command|INVALID_ARGUMENT|Errors.Invalid.Argument": { "why": "A shared low-level validation check failed somewhere in the request — a required field was left empty, malformed, or out of range. The exact wording is reused verbatim by dozens of unrelated checks across the command layer (most often identity-provider configuration fields, domain names, and settings values), so the field at fault depends on the specific error ID's call site, not the message text." },
"command|INVALID_ARGUMENT|ID missing": { "why": "A required ID field on the request was left empty. The check is shared across dozens of unrelated resource types (schemas, SMTP/SMS configs, actions, applications, and more), so which ID is missing depends on the specific call site, not the message text." },
"command|INVALID_ARGUMENT|Resource Owner Organisation missing": { "why": "The request omitted the organization ID (resourceOwner) that the target resource belongs to — every one of these commands needs an org ID to know which event stream to operate on." },
"command|INVALID_ARGUMENT|User ID missing": { "why": "The target user's ID was left empty on a user-scoped command such as deactivate, lock, reactivate, remove, or sign-out — ZITADEL needs a concrete user ID to know which account to act on." },
"command|NOT_FOUND|Identity Provider Configuration doesn't exist": { "why": "The instance-level identity provider (LDAP, SAML, generic OIDC, or a Zitadel provider) referenced by ID for an update or certificate-regeneration request doesn't exist — it was never created, or has already been removed." },
"command|NOT_FOUND|User could not be found": { "why": "The user ID passed to the command doesn't correspond to an existing user — it was never created, or the account has already been deleted." },
"command|FAILED_PRECONDITION|User could not be found": { "why": "Phone commands (add, verify, resend a code, remove) resolve the owning user first — the user's write model shows the account doesn't exist or was already removed, leaving no user to attach the phone number to." },
"command|NOT_FOUND|Private Label Policy not found": { "why": "The organization hasn't configured a custom branding/label policy yet, so a request to change it or manage its logo/icon assets has no existing policy to act on." },
"command|INTERNAL|An internal error occurred": { "why": "Building or pushing the underlying event(s) for the request failed unexpectedly — e.g. an eventstore write failure — rather than anything being wrong with the request's contents. This points to a server-side/infra issue rather than a fixable input." },
"command|NOT_FOUND|Default Private Label Policy not found": { "why": "The instance-wide default branding/label policy hasn't been set up yet, so a request to change it or add a default logo/icon/font has nothing to operate on." },
"command|INTERNAL|Object not created": { "why": "Uploading a label-policy asset (logo or icon) to the configured object storage backend failed, so the file was never persisted." },
"command|FAILED_PRECONDITION|No changes": { "why": "The update request's values are identical to what's already stored, so there's nothing new to persist. This guard is shared by many 'change X' commands — OIDC/SAML app config, application name, OIDC settings — whenever the computed diff is empty." },
"command|NOT_FOUND|SMTP configuration not found": { "why": "The SMTP configuration referenced by its (resourceOwner, id) pair doesn't exist — wrong ID, wrong org, or it was already removed — so it can't be changed, activated, or deleted." },
"command|FAILED_PRECONDITION|Multifactor OTP (OneTimePassword) isn't ready": { "why": "An OTP check (TOTP, or OTP via SMS/email) was attempted before the user finished setting up that factor — its state isn't 'ready' yet, so there's no verified secret to check the submitted code against." },
"command|INVALID_ARGUMENT|Application invalid": { "why": "The application payload failed basic validation — a required field like the app name is empty, or a type-specific requirement for the OIDC/SAML/API configuration wasn't met." },
"command|INVALID_ARGUMENT|Code is invalid": { "why": "The submitted verification code — an init code, or a passwordless/passkey registration code — doesn't match what was generated and stored for this user, or it has expired." },
"command|INVALID_ARGUMENT|Multifactor invalid": { "why": "The second-factor or multi-factor type passed when adding or removing a factor from a login policy isn't a recognized value — e.g. it was left unspecified." },
"command|FAILED_PRECONDITION|Identity Provider Configuration doesn't exist": { "why": "The identity provider ID referenced when starting a login/linking intent, or when adding an IDP link to a user, doesn't correspond to any IDP configured on the org or instance." },
"command|FAILED_PRECONDITION|User is locked": { "why": "The user's account is currently locked — typically from too many failed verification attempts — so further recovery-code checks or removal are rejected until it's unlocked." },
"command|NOT_FOUND|Project not found": { "why": "The project ID doesn't resolve to an existing project under the given organization — never created, wrong org, or already removed." },
"command|INVALID_ARGUMENT|Metadata is invalid": { "why": "A metadata key in the request is empty or otherwise fails validation when setting or bulk-removing metadata on a user or organization." },
"command|INVALID_ARGUMENT|SAML Metadata format error": { "why": "The SAML metadata XML — supplied directly or fetched from the metadata URL — couldn't be parsed as valid SAML metadata for the identity provider." },
"command|INVALID_ARGUMENT|SAML metadata is missing": { "why": "A metadata URL was given for a SAML identity provider, but fetching the XML from that URL failed, so no metadata was available to configure the provider despite the name." },
"command|FAILED_PRECONDITION|Organisation not found": { "why": "The organization ID referenced by the command — as the target org, the granted org on a user grant, or the org a group is being created in — doesn't exist." },
"command|FAILED_PRECONDITION|User ID missing": { "why": "An internal session or MFA-setup helper needed the session's associated user ID, but it was empty — the session hasn't been linked to a user yet." },
"command|FAILED_PRECONDITION|User is not yet initialized": { "why": "The target user is still in the 'initial' state — they haven't completed their invite/first-login flow — so actions that assume an active account, like requesting a password reset or verifying an email, are rejected until then." },
"command|NOT_FOUND|Application doesn't exist": { "why": "The application referenced by project ID + app ID doesn't exist — never created or already removed — so it can't be renamed, have its secret regenerated, or be deleted." },
"command|NOT_FOUND|Errors.NotFound": { "why": "A referenced resource couldn't be located — depending on the call site, the object a permission check needed an owning org for, an organization-settings record, or an instance membership." },
"command|NOT_FOUND|SMS configuration not found": { "why": "The SMS provider configuration referenced by (resourceOwner, id) doesn't exist, or exists but isn't the provider type — Twilio vs. generic HTTP — the command expected." },
"command|INVALID_ARGUMENT|Execution is invalid": { "why": "An action-v2 execution condition must set exactly one of its mutually exclusive selectors (method/service/'all', or event/group/'all') — none, or more than one, was set." },
"command|INVALID_ARGUMENT|Project member is invalid": { "why": "The project ID, target user ID, or role list is missing, or one of the given roles isn't a valid project role, when adding, changing, or removing a project member." },
"command|FAILED_PRECONDITION|Code not found": { "why": "A code check (email, phone, or OTP) was attempted before a code was ever requested for this user — no code is on record, or the configured code provider is missing." },
"command|FAILED_PRECONDITION|Refresh Token is invalid": { "why": "During an OIDC token refresh, the presented refresh token doesn't match a currently active session, has passed its absolute or idle expiration, or doesn't parse into ZITADEL's expected <sessionID>:<tokenID> format." },
"command|NOT_FOUND|Code not found": { "why": "There's no pending verification code for this user — verifying an email code, resending an init code, or sending a passwordless-init code was requested with no outstanding code on record." },
"command|NOT_FOUND|Execution condition is invalid": { "why": "An action-v2 execution's condition references an event name, event group, or function identifier that doesn't exist in ZITADEL's registry of executable conditions." },
"command|INVALID_ARGUMENT|ClientID missing": { "why": "The Apple identity provider's Services ID (client ID) field was left empty when adding or updating an Apple IDP configuration." },
"command|INVALID_ARGUMENT|Code is empty": { "why": "A verification code (phone, email, or OTP) was submitted as an empty string when trying to verify it." },
"command|INVALID_ARGUMENT|Errors.Project.Grant.Member.Invalid": { "why": "The project ID, user ID, role list, or org/grant ID is missing when adding or changing a project grant member, or one of the given roles isn't a valid project-grant role." },
"command|INVALID_ARGUMENT|Id missing": { "why": "A user grant ID — or one of the IDs in a bulk-remove list — was left empty when deactivating or removing a grant." },
"command|INVALID_ARGUMENT|Invalid Private Key format": { "why": "The private key bytes supplied for an Apple identity provider couldn't be parsed as a valid key — the content is malformed or corrupted." },
"command|INVALID_ARGUMENT|KeyID missing": { "why": "The Apple identity provider's Key ID field, which identifies which private key signs the client secret, was left empty when adding or updating an Apple IDP configuration." },
"command|INVALID_ARGUMENT|Password is empty": { "why": "A password value was left empty when setting, changing, or checking a user's password — or, for a hash-based set, neither a plaintext password nor an encoded hash was given." },
"command|INVALID_ARGUMENT|TeamID missing": { "why": "The Apple identity provider's Developer Team ID field was left empty when adding or updating an Apple IDP configuration." },
"command|FAILED_PRECONDITION|Code is empty": { "why": "A resend-code request — phone or email, on either the schema-user or human-user model — was made, but no code has ever been generated for this user; there's nothing to resend until an initial code is created." },
"command|FAILED_PRECONDITION|Errors.Org.DomainPolicy.NotExisting": { "why": "Loading the org's domain/username policy (falling back to the instance default) failed while validating a username change or user removal — this wraps whatever underlying lookup error occurred, rather than pointing at one specific missing setting." },
"command|FAILED_PRECONDITION|Errors.User.AlreadyExisting": { "why": "The user ID supplied — or auto-generated — when creating a human or machine user already belongs to an existing user account." },
"command|FAILED_PRECONDITION|ID missing": { "why": "An internal WebAuthn/passkey helper — registering or verifying a credential, or sending a passwordless init code — needed the user ID, and sometimes a credential ID, but received an empty value." },
"command|FAILED_PRECONDITION|Project Grant is invalid": { "why": "The project grant's resource owner doesn't match the actual owning org of the project, or the grant is being created for the project's own organization — an org mismatch on the grant." },
"command|FAILED_PRECONDITION|Project not found": { "why": "The project ID doesn't resolve to an active project for the given org. This is the same underlying condition as the NOT_FOUND variant, surfacing here because the project is checked as a precondition of another command rather than as its direct target." },
"command|FAILED_PRECONDITION|Role is invalid": { "why": "A project role's resource owner doesn't match the resource owner of the project it's being added to or changed on — the role and its parent project disagree about which org owns them." },
"command|FAILED_PRECONDITION|Token is invalid": { "why": "An OIDC access token being checked against a session doesn't match the session's current token, the session isn't active, or the token has already expired." },
"command|NOT_FOUND|Action not found": { "why": "The (deprecated v1) action ID referenced doesn't exist for the given organization — never created, or already removed." },
"command|NOT_FOUND|Device Authorization Request does not exist": { "why": "The device code presented when approving or canceling an OAuth device-authorization flow doesn't match any pending request — a wrong, expired, or already-completed device code." },
"command|NOT_FOUND|Errors.Instance.DebugNotificationProvider.NotFound": { "why": "The instance's debug notification provider — a test sink that logs or writes notifications to a file instead of sending them — hasn't been configured, so there's nothing to change or remove." },
"command|NOT_FOUND|Login Policy not found": { "why": "The organization hasn't configured its own login policy, so a request to modify it — adding an IDP, removing the policy — has no existing policy to act on." },
"command|NOT_FOUND|Multifactor not existing": { "why": "The second-factor or multi-factor method being removed from a login policy (instance-wide default or org-level) was never added, or has already been removed." },
"command|NOT_FOUND|Multifactor OTP (OneTimePassword) doesn't exist": { "why": "The user never set up a TOTP or OTP-via-email second factor, or already removed it, so there's nothing to verify or delete." },
"command|NOT_FOUND|Organisation not found": { "why": "The organization ID doesn't correspond to an existing org — never created, or already removed — when trying to rename, reactivate, or delete it." },
"command|NOT_FOUND|Phone not found": { "why": "The target user has no phone number on file to verify, resend a code for, or remove. One v2 phone-event code path reuses this same message when the user account itself no longer exists, so it isn't always literally about the phone." },
"command|INTERNAL|unable to unmarshal application": { "why": "An application lifecycle event (changed or deactivated) stored in the eventstore couldn't be deserialized back into its expected Go struct while replaying event history — a sign of corrupted event data or an incompatible struct change, not a bad request." },
"command|INTERNAL|unable to unmarshal label policy": { "why": "Despite the message text, this comes from the project-grant-member event mapper (changed/removed events), not label-policy code — the stored event payload couldn't be deserialized while replaying history from the eventstore." },
"command|INTERNAL|unable to unmarshal project grant": { "why": "A project-grant lifecycle event (removed, or cascade-changed) stored in the eventstore couldn't be deserialized back into its expected Go struct while replaying event history." }
}
@@ -0,0 +1,35 @@
{
"domain|INVALID_ARGUMENT|Errors.Invalid.Event.Type": {
"why": "ZITADEL's event-sourcing layer rejects a stored event when its recorded type doesn't match the specific decoder reading it back — for example a 'removed' event handed to the 'domain policy changed' decoder. This flags a mismatch between an event's persisted type tag and the code path deserializing it, not a problem with the API call that surfaced it."
},
"domain|INTERNAL|unable to unmarshal event": {
"why": "A stored event's JSON payload fails to decode into its target Go struct while ZITADEL replays event history to rebuild an object's state or a projection. The cause is corrupted or schema-mismatched data already in the eventstore, not anything in the request that triggered the replay."
},
"domain|FAILED_PRECONDITION|No changes": {
"why": "An update command — changing an action, an identity provider configuration, a policy, or a similar resource — arrived with every field already equal to the resource's current value, leaving nothing new to persist. Change at least one property from its existing value before resubmitting."
},
"domain|INTERNAL|An internal error occurred": {
"why": "Generic fallback used by the auth request cache when a SQL query against the auth_requests table fails, or when a cached auth request can't be marshaled to or unmarshaled from its stored JSON form. The cause sits in backend storage — database connectivity, a corrupted cache row — rather than in the request payload."
},
"domain|INTERNAL|unable to unmarshal policy": {
"why": "The same class of failure as the generic event-unmarshal error, scoped to policy events such as domain policy and notification policy: the JSON payload stored for a policy-added or policy-changed event doesn't decode into the struct ZITADEL expects when replaying it. Points to corrupted or version-mismatched event data, not a malformed request."
},
"notification|INVALID_ARGUMENT|reduce.wrong.event.type %s": {
"why": "A notification projection's reduce function — for OTP/SMS codes, back-channel logout, quota notifications, and similar handlers — received an event whose concrete Go type doesn't match the single event type it's registered to process. This is an internal event-routing inconsistency inside the notification handler itself, unrelated to whatever request generated the underlying event."
},
"crypto|INTERNAL|An internal error occurred": {
"why": "Surfaces while reconstructing a stored web key (JWKS signing key) configuration: the persisted config-type tag is unspecified or isn't one of RSA, ECDSA, or ED25519, or the stored JSON for the key's parameters fails to unmarshal. Indicates corrupted or unrecognized stored key configuration, not invalid input from the caller."
},
"domain|INTERNAL|reduce.wrong.event.type %s": {
"why": "ZITADEL derives a feature flag's level and key by splitting its stored event type string (e.g. feature.instance.<key>.set) on dots and validating each segment against the known feature enums. A stored type without the expected four segments, or naming a level/key the feature registry no longer recognizes, causes this — a mismatch between stored data and current code, not a value the caller supplied."
},
"domain|INTERNAL|unable to unmarshal label policy": {
"why": "The same generic event-unmarshal failure found elsewhere in the eventstore: a persisted event's JSON payload doesn't decode into its target struct on replay. The message text is copy-pasted across unrelated event mappers — member added/removed/cascade-removed and project grant-member events use it too — so it doesn't necessarily mean a label policy event was involved."
},
"notification|FAILED_PRECONDITION|Errors.Notification.Channels.NotPresent": {
"why": "ZITADEL tried to deliver a notification (verification code, password reset link, OTP, etc.) by email or SMS but the instance has no usable provider configured for that channel — no SMTP or webhook provider for email, no Twilio or webhook provider for SMS. Configure a provider for the channel before triggering messages that depend on it."
},
"crypto|PERMISSION_DENIED|Intent Token is invalid": {
"why": "The token presented to complete an IDP intent (an external login or account-linking callback) fails validation: it's empty, not valid base64url, can't be decrypted with the server's key, or decrypts to an intent ID different from the one being completed. Supply the exact, unmodified token returned at the end of the IDP callback together with the matching intent ID."
}
}
@@ -0,0 +1,71 @@
{
"query|INVALID_ARGUMENT|reduce.wrong.event.type %s": {
"why": "Internal consistency check inside a query-projection event handler (internal/query/projection/*.go): the handler asserts that the eventstore event it received is the exact event type it was registered for, and this fires when that assertion fails. Since the projection framework only ever routes an event to reducers registered for that event's precise type, this signals a bug in the projection's event-to-reducer wiring rather than anything caused by an API request."
},
"query|INTERNAL|An internal error occurred": {
"why": "Generic fallback used across dozens of unrelated code paths — an unhandled branch in the legacy login-flow state machine, a failed JSON unmarshal of stored event data, a panic from a SQL query-builder call, and similar defensive should-not-happen checks. There is no single trigger for this cluster; each call site guards a different unexpected server-side fault rather than something the caller did wrong."
},
"query|INVALID_ARGUMENT|reduce.wrong.event.type %v": {
"why": "Same projection event-routing assertion as the %s-formatted variant of this message: a reducer's type assertion on the incoming event failed because it was invoked with an event type it isn't registered to handle. This is an internal wiring bug in the projection code, not something a client can trigger."
},
"query|INVALID_ARGUMENT|reduce.wrong.db.pool %T": {
"why": "Inside the newer relational-table projection handlers (internal/query/projection/*_relational.go), each write statement's execution closure asserts that the database executor it's given is a *sql.Tx before using it. This fires only if the projection-handler framework passes a different executor type than expected — an internal plumbing bug, not something triggered by API traffic."
},
"query|INTERNAL|SQL Statement could not be created": {
"why": "The squirrel SQL query builder's ToSql() call failed while assembling a read query, for example in the IDP-by-ID, project-by-ID, or message-text lookups. Because the query shape is fixed Go code and only the bind values come from the request, this points to a query-builder usage bug rather than bad caller input."
},
"query|INTERNAL|SQL Statement could not be finished": {
"why": "Fires when rows.Close() returns an error after a result set has already been fully scanned, for example in the user-auth-method, instance-domain, or custom-text list queries. This reflects a database connection or driver-level fault hit while finalizing the query, not a problem with the query itself or its inputs."
},
"query|INVALID_ARGUMENT|Request is invalid": {
"why": "Returned by the shared list/search query helpers (genericRowsQuery and the equivalents used by project-role and IDP-user-link search, among others) when the caller's filter criteria can't be composed into a valid SQL statement by the query builder. Points to a malformed or self-contradictory combination of search parameters in the request rather than a data problem."
},
"query|INTERNAL|could not unmarshal event": {
"why": "Deserializing a stored event's JSON payload into the corresponding legacy view-model struct failed (internal/user/repository/view and eventsourcing model code for tokens, phone numbers, external IDPs, etc). This means the event data persisted in the eventstore doesn't match what the current model code expects to decode — a data or schema mismatch rather than anything the caller controls."
},
"query|NOT_FOUND|User could not be found": {
"why": "Two legacy paths share this message. The auth-request login flow's user lookup re-applies any events newer than its cached view before answering, and only returns this when the user has no view row and no events at all (unknown ID) or the freshly-applied events show the user was deleted; GetHumanOTPSecret returns it when an OTP configuration exists but never reached the ready state, which the API then treats as if no OTP were configured."
},
"query|INTERNAL|reduce.wrong.event.type %s": {
"why": "Identical event-routing type-assertion failure as the INVALID_ARGUMENT-classified variant of this message, just thrown with ThrowInternalf instead by a handful of newer relational-table projection reducers (project_grant_relational.go, authorization_relational.go). Same should-never-happen wiring bug — the different gRPC status code reflects inconsistent error classification between call sites, not a different cause."
},
"query|FAILED_PRECONDITION|The User must be personal": {
"why": "Thrown by legacy login and auth-flow code when an operation that needs a human profile — interactive login, or reading an email, phone, or address — is attempted against a user that turns out to be a machine or service account with no human profile data. Machine users can't complete these human-only flows or expose these profile fields."
},
"query|INTERNAL|Could not be removed": {
"why": "Surfaces from internal projection-maintenance operations (RemoveFailedEvent, ClearCurrentSequence) when the DELETE, TRUNCATE, or transaction used to clear a stuck failed-event record or reset a projection's cursor fails at the database level. These are administrative reset operations, not part of normal read or write request handling."
},
"query|INTERNAL|could not unmarshal data": {
"why": "Same failure mode as could-not-unmarshal-event under a different literal string: legacy user view-model code (internal/user/repository/view/model/user.go) fails to deserialize a stored user, password, or WebAuthn-token event payload into its struct. Indicates the persisted event data doesn't match the struct the projection code currently expects."
},
"query|INTERNAL|Error in reading translation file": {
"why": "The server failed to open or read one of its bundled i18n YAML files, distinct from the separate not-found error used when the file is missing entirely. Since these files ship embedded in the binary, this points to a broken build or a misconfigured filesystem-backed i18n directory rather than anything about the request."
},
"query|NOT_FOUND|Errors.NotFound": {
"why": "While processing a UserGrant event, the projection looks up the resource-owner org of the referenced user, project, or project grant by querying the eventstore directly for that aggregate's creation event. If no such event exists for the referenced ID, the projection can't determine which org should own the row; because this checks the eventstore itself rather than a read model, it signals a genuine data or event-ordering inconsistency rather than the projection simply lagging behind a recent write."
},
"query|INVALID_ARGUMENT|Limit exceeds default limit": {
"why": "Legacy view search requests (user-session, user-membership, external-IDP list views) reject calls whose requested page-size limit exceeds the server-configured maximum. Lower the requested limit and paginate across multiple requests instead."
},
"query|INVALID_ARGUMENT|reduce.wrong.event.type, %v": {
"why": "Same projection event-routing assertion as the other reduce.wrong.event.type variants, just with a different format string, seen in the mail-template, lockout-policy, and relational settings projections. A reducer received an event type it wasn't built to handle — an internal wiring issue, not something callers trigger."
},
"query|INVALID_ARGUMENT|SQL Statement could not be created": {
"why": "Same squirrel ToSql() builder failure as the INTERNAL-classified cluster of this message, just returned as INVALID_ARGUMENT by the instance and instance-domain search queries. Effectively never happens outside a query-builder bug; the differing gRPC code reflects inconsistent classification between call sites rather than a different cause."
},
"query|NOT_FOUND|Errors.App.NotFound": {
"why": "A lookup by OIDC client ID, SAML entity ID, or app ID returned no row, seen in the client-ID permission check, the SAML service-provider lookup, and the OIDC userinfo client lookup. Means the given client or app ID doesn't exist, was removed, or the corresponding app isn't active on this instance."
},
"query|INVALID_ARGUMENT|reduce.wrong.event.type %T": {
"why": "Same projection event-routing assertion as the other reduce.wrong.event.type variants, using Go's %T type-name formatting, seen in the device-auth, instance-features, and system-features projections. The reducer was invoked with an event type outside what it's registered to handle — an internal bug, not something callers trigger."
},
"query|INTERNAL|Translation file could not be merged with custom translations": {
"why": "After loading the base i18n YAML and overlaying instance- or org-level custom-text overrides stored in the database, the JSON marshal/unmarshal round trip used to merge them fails. An internal serialization step failing, unrelated to whatever request triggered the text lookup."
},
"query|INTERNAL|unable to create sql stmt": {
"why": "Same squirrel ToSql() builder failure as the other SQL-statement-could-not-be-created clusters, just in the label-policy queries, which predate the shared SQL-statement i18n key and hardcode this message text directly instead."
},
"query|UNAUTHENTICATED|Token is invalid": {
"why": "access_token.go rejects an OIDC access token when it doesn't split into the expected session-ID/token-ID shape, when replaying that OIDC session's events yields no matching access-token ID, or when the underlying session was terminated — for example by logout — after the token was issued. All three cases mean the token is no longer usable and the caller needs to re-authenticate."
}
}
@@ -0,0 +1,570 @@
// Scans the Go backend for zerrors.Throw<Kind>[f]("ID", "message") call
// sites and emits apps/docs/components/ErrorReference/data.json: a
// 3-level tree (subsystem -> gRPC kind -> cluster) that the ErrorReference
// component renders. Regenerate after backend error call sites change:
// pnpm nx run @zitadel/docs:generate-error-reference (from repo root)
// pnpm generate:error-reference (from apps/docs)
// This file is TypeScript/ESM (run via tsx, see package.json) — plain
// `node` won't execute it without a loader.
//
// This is intentionally NOT wired into the Nx `generate` chain (which
// `dev`/`build`/`lint` all depend on) — scanning 2000+ backend Go files on
// every docs build would cache-invalidate the whole site on unrelated
// backend PRs. Output (data.json + overrides) is committed instead, the
// same way apps/docs/content/apis/benchmarks/**/output.json is committed
// despite being externally generated.
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { globSync } from 'glob';
import yaml from 'js-yaml';
import { GRPC_STATUS, THROW_KIND_TO_GRPC_STATUS } from '../lib/grpc-status';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '../../..');
const OUTPUT_PATH = join(__dirname, '../components/ErrorReference/data.json');
const OVERRIDES_DIR = join(__dirname, 'error-reference-overrides');
// ---------------------------------------------------------------------------
// Types (mirrored, informally, by apps/docs/components/ErrorReference/index.tsx)
// ---------------------------------------------------------------------------
interface RawEntry {
id: string;
kind: string; // GRPC_STATUS key
message: string;
messageType: 'i18n' | 'literal';
i18nResolved?: boolean;
file: string;
line: number;
}
interface ErrorLocation {
file: string;
line: number;
}
interface ErrorIdEntry {
id: string;
locations: ErrorLocation[];
}
interface ExampleResponse {
httpStatus: number;
grpcCode: number;
body: {
code: number;
message: string;
details: [{ '@type': string; id: string; message: string }];
};
}
interface ErrorCluster {
key: string;
message: string;
messageType: 'i18n' | 'literal';
ids: ErrorIdEntry[];
why: string;
whySource: 'curated' | 'i18n-decomposition' | 'defensive-guard-template' | 'generic-fallback';
example: ExampleResponse;
collidesWith: { key: string; message: string; subsystem: string }[];
}
interface KindBucket {
kind: string;
clusters: ErrorCluster[];
}
interface Subsystem {
id: string;
name: string;
summary: string;
kinds: KindBucket[];
}
interface ErrorReferenceData {
meta: {
generatedAt: string;
sourceRef: string;
totalCallSites: number;
totalIds: number;
totalClusters: number;
totalCollisions: number;
};
subsystems: Subsystem[];
}
// ---------------------------------------------------------------------------
// Stage: subsystem mapping (directory prefix -> subsystem id)
// ---------------------------------------------------------------------------
const SUBSYSTEMS: { id: string; name: string; summary: string }[] = [
{
id: 'command',
name: 'Command layer',
summary:
'internal/command is the write side of ZITADEL’s CQRS/event-sourcing core: nearly every state-changing gRPC/Connect call resolves to a Commands method covering users, orgs, instances, projects, IDPs, policies, providers, quotas, actions/executions, sessions, and groups. Errors cluster into three layers mapped to distinct gRPC codes: InvalidArgument for cheap syntactic validation before any eventstore read, NotFound/AlreadyExists for aggregate-existence checks against replayed write-model state, and PreconditionFailed for state-conditional rules discovered mid-command (not changed, already active, lockouts).',
},
{
id: 'query',
name: 'Query layer & legacy read models',
summary:
'internal/query is the current read side (Postgres projections), alongside the legacy internal/auth/internal/user/internal/view read-model stack it is gradually replacing. Errors here are dominated by NotFound (a projection row doesn’t exist or hasn’t caught up yet) and Internal (SQL statement construction / scan failures).',
},
{
id: 'api',
name: 'API surface, authn/authz, and identity federation',
summary:
'internal/api (gRPC/Connect handlers, REST gateway, login UI), internal/authz (token/permission checks), internal/idp (external identity provider integrations), and internal/webauthn sit at the transport/identity boundary. Errors here span request-shape validation, auth/session failures, and third-party IdP response handling.',
},
{
id: 'storage',
name: 'Storage/eventstore infrastructure & the new v3 backend',
summary:
'internal/eventstore (event append/query, Postgres-backed), internal/static (asset storage), and backend/v3 (the newer hexagonal-architecture, relational-storage-backed rewrite) — infrastructure errors: connection failures, constraint violations, unmarshal guards on stored event payloads.',
},
{
id: 'scim',
name: 'SCIM provisioning',
summary:
'internal/api/scim implements SCIM 2.0 user/group provisioning. Errors mirror the SCIM spec’s own error model (invalid filters, invalid paths, bulk-operation limits) more than ZITADEL’s general gRPC error shape.',
},
{
id: 'notification',
name: 'Notification channels',
summary:
'internal/notification renders and sends emails/SMS (templates, SMTP/Twilio/webhook providers). Errors are mostly provider-configuration and template-rendering failures.',
},
{
id: 'crypto',
name: 'Cryptography & key management',
summary:
'internal/crypto covers encryption, hashing, and key/secret generation used throughout the rest of the backend. Errors here are almost entirely configuration/algorithm mismatches, not runtime request data.',
},
{
id: 'domain',
name: 'Domain value objects, legacy event payloads, and misc infra',
summary:
'internal/domain (value-object validation), internal/repository (event definitions/unmarshalling for most aggregates), internal/execution, and other infra that doesn’t fit the groups above. A large share of this group is Internal-coded defensive guards inside event-unmarshalling code that only fire on event-schema corruption, not from ordinary API usage.',
},
];
function subsystemForPath(relPath: string): string {
if (relPath.startsWith('internal/api/scim/')) return 'scim';
if (relPath.startsWith('internal/notification/')) return 'notification';
if (relPath.startsWith('internal/crypto/')) return 'crypto';
if (
relPath.startsWith('internal/eventstore/') ||
relPath.startsWith('internal/static/') ||
relPath.startsWith('backend/v3/')
)
return 'storage';
if (
relPath.startsWith('internal/api/') ||
relPath.startsWith('internal/authz/') ||
relPath.startsWith('internal/idp/') ||
relPath.startsWith('internal/webauthn/')
)
return 'api';
if (
relPath.startsWith('internal/query/') ||
relPath.startsWith('internal/auth/') ||
relPath.startsWith('internal/user/') ||
relPath.startsWith('internal/view/') ||
relPath.includes('/repository/view/') // legacy view-model repos for org/project/iam/user aggregates
)
return 'query';
if (relPath.startsWith('internal/command/')) return 'command';
// internal/repository/{org,instance,project} hold the event definitions
// (including unmarshal-guard errors) for the aggregates internal/command
// writes to — they're the write model's event payloads, not a read model,
// so route them to 'command' too. ID prefix isn't a reliable signal for
// this: internal/repository/instance mostly throws INST-, not INSTANCE-,
// and internal/repository/project also throws APPLICATION-/API-/OIDC-/
// SAML-/USER-, not just PROJECT-.
if (
relPath.startsWith('internal/repository/org/') ||
relPath.startsWith('internal/repository/instance/') ||
relPath.startsWith('internal/repository/project/')
)
return 'command';
return 'domain';
}
// ---------------------------------------------------------------------------
// Stage: scan .go files for zerrors.Throw*() call sites
// ---------------------------------------------------------------------------
const KIND_ALIASES: Record<string, keyof typeof GRPC_STATUS> = {
...THROW_KIND_TO_GRPC_STATUS,
Error: 'UNKNOWN', // ThrowError always creates KindUnknown (see internal/zerrors/zerror.go)
};
const ID_SHAPE = /^[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9]+$/;
const CALL_RE = /zerrors\.Throw([A-Z][A-Za-z]*?)(f)?\(/g;
// Reads up to 2 top-level (paren-depth-1) double-quoted string literals
// starting right after a Throw*( call's opening paren.
function extractTopLevelStrings(content: string, startIdx: number): string[] {
const strings: string[] = [];
let depth = 1;
let i = startIdx;
const limit = Math.min(content.length, startIdx + 4000);
while (i < limit && depth > 0 && strings.length < 2) {
const ch = content[i];
if (ch === '"' && depth === 1) {
let j = i + 1;
let raw = '';
while (j < content.length && content[j] !== '"') {
if (content[j] === '\\') {
raw += content[j] + content[j + 1];
j += 2;
} else {
raw += content[j];
j++;
}
}
try {
strings.push(JSON.parse('"' + raw + '"'));
} catch {
strings.push(raw);
}
i = j + 1;
continue;
}
if (ch === '(') depth++;
else if (ch === ')') depth--;
i++;
}
return strings;
}
function lineOf(content: string, idx: number): number {
let line = 1;
for (let i = 0; i < idx; i++) if (content[i] === '\n') line++;
return line;
}
// True if `idx` is textually preceded, on its own source line, by a `//`
// comment marker — i.e. the call site is commented out. This is a line-level
// heuristic, not real Go parsing: it doesn't know whether an earlier `//` on
// the line sits inside a string literal, and it doesn't handle `/* */` block
// comments. Neither case is a realistic way to write commented-out Go code,
// so this is enough to skip the actual failure mode (a fully commented-out
// `// zerrors.ThrowX(...)` line) without the cost of a real parser.
function isCommentedOut(content: string, idx: number): boolean {
const lineStart = content.lastIndexOf('\n', idx) + 1;
return content.slice(lineStart, idx).includes('//');
}
function scanFile(absPath: string, relPath: string): RawEntry[] {
const content = readFileSync(absPath, 'utf8');
const entries: RawEntry[] = [];
let m: RegExpExecArray | null;
CALL_RE.lastIndex = 0;
while ((m = CALL_RE.exec(content))) {
if (isCommentedOut(content, m.index)) continue;
const kindRaw = m[1];
const grpcKey = KIND_ALIASES[kindRaw];
if (!grpcKey) continue; // not a real Throw<Kind> (defensive)
const strings = extractTopLevelStrings(content, CALL_RE.lastIndex);
if (strings.length < 2 || !ID_SHAPE.test(strings[0])) continue;
const [id, message] = strings;
const messageType: 'i18n' | 'literal' = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/.test(message)
? 'i18n'
: 'literal';
entries.push({
id,
kind: grpcKey,
message,
messageType,
file: relPath,
line: lineOf(content, m.index),
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Stage: i18n resolution
// ---------------------------------------------------------------------------
function loadYaml(relPath: string): any {
try {
return yaml.load(readFileSync(join(REPO_ROOT, relPath), 'utf8'));
} catch {
return {};
}
}
const I18N_BUNDLES = [
loadYaml('internal/static/i18n/en.yaml'),
loadYaml('internal/api/ui/login/static/i18n/en.yaml'),
loadYaml('internal/notification/static/i18n/en.yaml'),
];
function resolveI18n(key: string): string | undefined {
for (const bundle of I18N_BUNDLES) {
let node: any = bundle;
for (const segment of key.split('.')) {
if (node == null || typeof node !== 'object') {
node = undefined;
break;
}
node = node[segment];
}
if (typeof node === 'string') return node;
}
return undefined;
}
// ---------------------------------------------------------------------------
// Stage: "why" generation (mechanical baseline — curated overrides applied later)
// ---------------------------------------------------------------------------
const GUARD_PATTERNS = [/^unable to unmarshal /i, /^reduce\.wrong\.event\.type/i, /^cannot scan type /i, /^could not unmarshal /i];
const KIND_WHY_VERB: Record<string, (subject: string) => string> = {
NOT_FOUND: (s) => `${s} could not be found.`,
ALREADY_EXISTS: (s) => `${s} already exists.`,
INVALID_ARGUMENT: (s) => `The request was rejected because ${s.toLowerCase()} is missing or invalid.`,
FAILED_PRECONDITION: (s) => `The request was rejected because of ${s.toLowerCase()} — the current state doesn’t allow this operation.`,
PERMISSION_DENIED: (s) => `The caller is authenticated but not authorized: ${s.toLowerCase()}.`,
UNAUTHENTICATED: (s) => `The caller could not be authenticated: ${s.toLowerCase()}.`,
RESOURCE_EXHAUSTED: (s) => `A limit was exceeded: ${s.toLowerCase()}.`,
UNIMPLEMENTED: (s) => `${s} is not supported on this code path.`,
INTERNAL: (s) => `Internal failure related to ${s.toLowerCase()}.`,
};
function humanizeI18nKey(key: string): string {
const segments = key.split('.').filter((s) => s.toLowerCase() !== 'errors');
const words = segments
.map((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1 $2'))
.join(' ')
.trim();
return words.length > 0 ? `The ${words.toLowerCase()}` : 'This condition';
}
function mechanicalWhy(kind: string, message: string, messageType: 'i18n' | 'literal', i18nResolved: boolean): {
why: string;
whySource: ErrorCluster['whySource'];
} {
if (GUARD_PATTERNS.some((re) => re.test(message))) {
return {
why: 'Internal defensive guard around event/projection unmarshalling — not expected to occur from ordinary API usage. Indicates event-schema corruption or a routing bug if it does.',
whySource: 'defensive-guard-template',
};
}
// Only decompose while `message` is still the raw dotted key (unresolved).
// Once resolveI18n() has replaced it with the actual translated English
// sentence, decomposing that sentence as if it were a key produces mangled
// prose (e.g. "Errors.Internal" -> "An internal error occurred" fed back
// through humanizeI18nKey reads as "the an internal error occurred").
if (messageType === 'i18n' && !i18nResolved) {
const subject = humanizeI18nKey(message);
const verb = KIND_WHY_VERB[kind];
return {
why: verb ? verb(subject) : `${subject} (${kind}).`,
whySource: 'i18n-decomposition',
};
}
return {
why: `${kind} error: “${message}” — see the linked source location(s) for the exact trigger condition.`,
whySource: 'generic-fallback',
};
}
// ---------------------------------------------------------------------------
// Main pipeline
// ---------------------------------------------------------------------------
function main() {
const files = globSync(['internal/**/*.go', 'backend/**/*.go'], {
cwd: REPO_ROOT,
ignore: ['**/*_test.go'],
});
const rawEntries: RawEntry[] = [];
for (const relPath of files) {
const abs = join(REPO_ROOT, relPath);
rawEntries.push(...scanFile(abs, relPath));
}
// Resolve i18n text used for clustering/display, without discarding the raw key.
for (const e of rawEntries) {
if (e.messageType === 'i18n') {
const resolved = resolveI18n(e.message);
e.i18nResolved = resolved !== undefined;
if (resolved !== undefined) e.message = resolved;
}
}
// Cluster by (subsystem, kind, trimmed message).
const clusterMap = new Map<
string,
{
subsystem: string;
kind: string;
message: string;
messageType: 'i18n' | 'literal';
i18nResolved: boolean;
entries: RawEntry[];
}
>();
for (const e of rawEntries) {
const subsystem = subsystemForPath(e.file);
const message = e.message.trim();
const key = `${subsystem}|${e.kind}|${message}`;
if (!clusterMap.has(key)) {
clusterMap.set(key, {
subsystem,
kind: e.kind,
message,
messageType: e.messageType,
i18nResolved: e.i18nResolved ?? false,
entries: [],
});
}
clusterMap.get(key)!.entries.push(e);
}
// Collision detection: which cluster keys does each raw id appear in?
const idToClusterKeys = new Map<string, Set<string>>();
for (const [key, c] of clusterMap) {
for (const e of c.entries) {
if (!idToClusterKeys.has(e.id)) idToClusterKeys.set(e.id, new Set());
idToClusterKeys.get(e.id)!.add(key);
}
}
// Load curated overrides (clusterKey -> { why }).
const overrides = new Map<string, { why: string }>();
if (existsSync(OVERRIDES_DIR)) {
for (const f of readdirSync(OVERRIDES_DIR).filter((f: string) => f.endsWith('.json'))) {
const parsed = JSON.parse(readFileSync(join(OVERRIDES_DIR, f), 'utf8'));
for (const [key, val] of Object.entries<{ why: string }>(parsed)) overrides.set(key, val);
}
}
const subsystems: Subsystem[] = SUBSYSTEMS.map((s) => ({ ...s, kinds: [] }));
const subsystemIndex = new Map(subsystems.map((s) => [s.id, s]));
let totalClusters = 0;
let totalCollisions = 0;
for (const [key, c] of clusterMap) {
totalClusters++;
const idMap = new Map<string, ErrorIdEntry>();
for (const e of c.entries) {
if (!idMap.has(e.id)) idMap.set(e.id, { id: e.id, locations: [] });
idMap.get(e.id)!.locations.push({ file: e.file, line: e.line });
}
const ids = [...idMap.values()].sort((a, b) => a.id.localeCompare(b.id));
// A cluster key already bakes in (subsystem, kind, message), so the same
// id turning up under a different key is only a *real* collision — i.e.
// the id means something genuinely different depending on call site — if
// the message actually differs. Without this check, an id copy-pasted
// verbatim into files that happen to sit in two different subsystems
// would be flagged as colliding with itself.
const collidesWith: ErrorCluster['collidesWith'] = [];
const seenCollisionKeys = new Set<string>();
for (const idEntry of ids) {
for (const otherKey of idToClusterKeys.get(idEntry.id)!) {
if (otherKey === key || seenCollisionKeys.has(otherKey)) continue;
const other = clusterMap.get(otherKey)!;
if (other.message === c.message) continue;
seenCollisionKeys.add(otherKey);
collidesWith.push({ key: otherKey, message: other.message, subsystem: other.subsystem });
}
}
if (collidesWith.length > 0) totalCollisions++;
const override = overrides.get(key);
const mech = mechanicalWhy(c.kind, c.message, c.messageType, c.i18nResolved);
const why = override?.why ?? mech.why;
const whySource: ErrorCluster['whySource'] = override ? 'curated' : mech.whySource;
const status = GRPC_STATUS[c.kind];
const repId = ids[0].id;
// The server always appends " (ID)" to the top-level message (see
// internal/api/grpc/gerrors/zitadel_errors.go: msg := key; msg += " (" + id + ")").
// The nested details[].message is the plain text without that suffix.
const example: ExampleResponse = {
httpStatus: status.httpStatus,
grpcCode: status.code,
body: {
code: status.code,
message: `${c.message} (${repId})`,
details: [{ '@type': 'type.googleapis.com/zitadel.v1.ErrorDetail', id: repId, message: c.message }],
},
};
const cluster: ErrorCluster = {
key,
message: c.message,
messageType: c.messageType,
ids,
why,
whySource,
example,
collidesWith,
};
const subsystem = subsystemIndex.get(c.subsystem)!;
let bucket = subsystem.kinds.find((k) => k.kind === c.kind);
if (!bucket) {
bucket = { kind: c.kind, clusters: [] };
subsystem.kinds.push(bucket);
}
bucket.clusters.push(cluster);
}
// Sort clusters within each kind bucket by size descending (most common first),
// then sort kind buckets within each subsystem by total id count descending.
for (const s of subsystems) {
for (const bucket of s.kinds) {
bucket.clusters.sort((a, b) => b.ids.length - a.ids.length || a.message.localeCompare(b.message));
}
s.kinds.sort((a, b) => {
const aCount = a.clusters.reduce((n, c) => n + c.ids.length, 0);
const bCount = b.clusters.reduce((n, c) => n + c.ids.length, 0);
return bCount - aCount;
});
}
subsystems.sort((a, b) => {
const count = (s: Subsystem) => s.kinds.reduce((n, k) => n + k.clusters.reduce((m, c) => m + c.ids.length, 0), 0);
return count(b) - count(a);
});
let sourceRef = 'main';
try {
sourceRef = execSync('git rev-parse HEAD', { cwd: REPO_ROOT }).toString().trim();
} catch {
// best-effort only
}
const data: ErrorReferenceData = {
meta: {
generatedAt: new Date().toISOString(),
sourceRef,
totalCallSites: rawEntries.length,
totalIds: idToClusterKeys.size,
totalClusters,
totalCollisions,
},
subsystems,
};
mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
writeFileSync(OUTPUT_PATH, JSON.stringify(data));
console.log(`[error-reference] ${files.length} files scanned`);
console.log(`[error-reference] ${rawEntries.length} call sites, ${idToClusterKeys.size} distinct IDs`);
console.log(`[error-reference] ${totalClusters} clusters, ${totalCollisions} with a real collision`);
console.log(`[error-reference] ${overrides.size} curated overrides applied`);
console.log(`[error-reference] wrote ${OUTPUT_PATH}`);
}
main();