Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 19 additions & 4 deletions docs/architecture/04-error-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ Over HTTP the same object is the problem+json body, with `httpStatus` as the sta
| 2 | Add the code + `title` + `httpStatus` + `retryable` to your package registry | `packages/<pkg>/src/errors.ts` |
| 3 | Write the `fix` as a command you have actually run | same |
| 4 | Add a test that asserts the code **and** that `fix` is non-empty | `packages/<pkg>/src/errors.test.ts` |
| 5 | If cross-cutting, add a row to the table below | `docs/architecture/04-error-contract.md` |
| 6 | Verify uniqueness + docs coverage | `x verify` |
| 5 | Add the code's row to the error reference — the `errors` step requires it | `wiki/Error-Codes.md` |
| 6 | If cross-cutting, add a row to the table below | `docs/architecture/04-error-contract.md` |
| 7 | Verify: uniqueness at registration, `fix` shape and docs coverage in the `errors` step | `x verify` |

```ts
// packages/entity/src/errors.ts — one file per package, no codes declared inline
Expand All @@ -123,7 +124,19 @@ export const ENTITY_ERRORS = {
| `this is not supported` | `set jobs.driver = 'pg' in app.config.ts` |
| `retry later` | `x jobs retry 8f2a1c --from nudge` |

Enforced: `x verify` fails on an error declaration whose `fix` is empty, or whose `fix` matches the banned-phrase list (`check`, `make sure`, `try`, `see the docs`) without a command token. A code with no docs page also fails.
Enforced by the **`errors` step** of `x verify`. It reads every `fix:` string literal in shipped source — test files and `.d.ts` excluded — and treats a `${…}` interpolation as unknown, so nothing inside one counts as a command.

| Fails on | Code |
|---|---|
| an empty `fix` | `X_ERROR_FIX_INVALID` |
| a `fix` carrying `check`, `make sure`, `try` or `see the docs` with no command token | `X_ERROR_FIX_INVALID` |
| a declared `X_*` code with no row in `wiki/Error-Codes.md` | `X_ERROR_CODE_UNDOCUMENTED` |

A **command token** is the `x` CLI, a known tool (`bun`, `bunx`, `git`, `docker`, `psql`, `curl`, …), a literal call expression (`name(`), or a file path (`app.config.ts`, `apps/web/api/index.ts`). "check `x doctor --json`" passes; "check your database connection" does not.

The docs half is a **host check** the framework repo contributes to the `errors` step — the same seam the tier table uses on `boundaries`.

Out of reach: a `fix` computed at runtime. `fix: input.fix` has no literal to read, so the step cannot judge it — the value's own author does.

For deliberately unimplemented paths, the throw is still typed and still actionable:

Expand All @@ -137,7 +150,7 @@ throw new UltimateError({

## Cross-cutting codes

Thrown by more than one package; every package may reference them.
Thrown by more than one package, or by the gate about any of them; every package may reference them.

| Code | Owner | Status | Meaning | Typical `fix` |
|---|---|---|---|---|
Expand All @@ -151,6 +164,8 @@ Thrown by more than one package; every package may reference them.
| `X_TENANT_MISMATCH` | `entity` | 403 | a row's tenant differs from the request tenant | scope the query to `ctx.tenantId` |
| `X_DB_DRIFT` | `entity` | 500 | schema differs from migrations | `x db gen "<name>"` |
| `X_BOUNDARY_VIOLATION` | `cli` | build | an import rule broke; `data.chain` is the path | `x fix boundary <file>` |
| `X_ERROR_FIX_INVALID` | `cli` | build | a `fix:` literal is empty, or advice with no command token | `rewrite the fix at <file>:<line> as a runnable command` |
| `X_ERROR_CODE_UNDOCUMENTED` | `cli` | build | a declared `X_*` code has no row in the error reference | `add a row for <CODE> to wiki/Error-Codes.md` |
| `X_MANIFEST_STALE` | `manifest` | build | `x.manifest.json` / `openapi.json` differ from the code | `x manifest write` |
| `X_BUDGET_EXCEEDED` | `render` | build | route bytes / LCP over budget; `data.cause` names the import | `x fix boundary <file>` |
| `X_BUILD_SKEW` | `http` | 409 | client build ID incompatible with the server contract | `reload` (client-side signal) |
Expand Down
6 changes: 4 additions & 2 deletions examples/dummy/apps/web/app/posts/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ export class PostNotFound extends UltimateError {
constructor(reference: string) {
super({
code: 'X_POST_NOT_FOUND',
cause: `no post ${JSON.stringify(reference)} in the actor's organisation`,
fix: 'check the id and that the actor belongs to the post’s org — cross-org reads are denials, not 404s by accident',
cause:
`no post ${JSON.stringify(reference)} in the actor's organisation — a reference owned ` +
'by another org is a denial, never a 404 by accident',
fix: 'psql "$DATABASE_URL" -c "SELECT id, slug FROM posts WHERE org_id = \'<ctx.actor.orgId>\'"',
docs: 'https://ultimate.dev/errors/X_POST_NOT_FOUND',
});
}
Expand Down
19 changes: 13 additions & 6 deletions packages/action/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
* Every failure @ultimat3/action can produce, one subclass per stable code so
* callers `instanceof` a specific failure instead of string-matching a message.
*/
import { assertNever, hasErrorCode, registerErrorCodes, UltimateError } from '@ultimat3/core';
import { assertNever, registerErrorCodes, UltimateError } from '@ultimat3/core';
import type { SurfaceDenial } from '@ultimat3/policy';

const docs = (code: string): string => `https://ultimate.dev/errors/${code}`;

/** Titles for the framework-wide code table. Guarded: `X_INPUT_INVALID` is shared. */
const TITLES: Readonly<Record<string, string>> = {
/**
* Titles for the framework-wide code table — every one of them owned by this package.
* `X_INPUT_INVALID` and `X_RPC_FAILED` are action's: an action is where an input schema is
* enforced and where the typed client speaks, and `@ultimat3/query` only throws them.
* Authz codes are absent on purpose — `ActionDeniedError` re-uses the policy decision's code.
*/
const OWNED_TITLES: Readonly<Record<string, string>> = {
X_ACTION_DUPLICATE: 'two actions are registered under one name',
X_ACTION_FOREIGN: 'a value that is not an action was projected as one',
X_ACTION_POLICY_MISSING: 'an action was registered without a policy',
Expand All @@ -20,9 +25,11 @@ const TITLES: Readonly<Record<string, string>> = {
X_RPC_FAILED: 'an RPC call failed without a problem+json body',
};

for (const [code, title] of Object.entries(TITLES)) {
if (!hasErrorCode(code)) registerErrorCodes({ [code]: { title } });
}
// One unconditional call: a presence guard would turn "another package claims one of these codes"
// from an X_ERROR_CODE_DUPLICATE at import into whichever module loaded first deciding the title.
registerErrorCodes(
Object.fromEntries(Object.entries(OWNED_TITLES).map(([code, title]) => [code, { title }])),
);

/** Thrown when a projection needs a name the action does not have yet. */
export class ActionUnregisteredError extends UltimateError {
Expand Down
55 changes: 55 additions & 0 deletions packages/admin/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// A registered error code is a contract: hasErrorCode() must see it, describeErrorCode()
// must render the title this package declared, and every code must resolve to its docs
// page. These tests are what keeps that contract from rotting silently.

import { describe, expect, test } from 'bun:test';
import { describeErrorCode, hasErrorCode } from '@ultimat3/core';
import {
ADMIN_BORROWED_ERROR_CODES,
ADMIN_ERROR_CODES,
ADMIN_ERROR_TITLES,
ADMIN_OWNED_ERROR_CODES,
} from './errors';

describe('ADMIN_ERROR_TITLES', () => {
test('titles exactly the codes admin owns — a borrowed code carries no title here', () => {
expect(Object.keys(ADMIN_ERROR_TITLES).sort()).toEqual([...ADMIN_OWNED_ERROR_CODES].sort());
});

test('owned and borrowed are disjoint and together are every code admin throws', () => {
const owned = new Set<string>(ADMIN_OWNED_ERROR_CODES);
for (const code of ADMIN_BORROWED_ERROR_CODES) expect(owned.has(code)).toBe(false);
expect([...ADMIN_ERROR_CODES].sort()).toEqual(
[...ADMIN_OWNED_ERROR_CODES, ...ADMIN_BORROWED_ERROR_CODES].sort(),
);
});
});

describe('registration', () => {
test('every declared code is registered in the framework-wide registry', () => {
for (const code of ADMIN_ERROR_CODES) {
expect(hasErrorCode(code)).toBe(true);
}
});

test('describeErrorCode renders the title this package declared', () => {
for (const code of ADMIN_OWNED_ERROR_CODES) {
expect(describeErrorCode(code).title).toBe(ADMIN_ERROR_TITLES[code]);
}
});

test('X_NOT_IMPLEMENTED is borrowed from core, not re-registered by admin', () => {
// admin declares no title for it, so the string below can only have come from core.
expect(describeErrorCode('X_NOT_IMPLEMENTED').title).toBe(
'this driver does not implement the requested feature',
);
});
});

describe('docs', () => {
test('every code resolves to its canonical docs page', () => {
for (const code of ADMIN_ERROR_CODES) {
expect(describeErrorCode(code).docs).toBe(`https://ultimate.dev/errors/${code}`);
}
});
});
32 changes: 29 additions & 3 deletions packages/admin/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
// The X_* codes owned by @ultimat3/admin. Every one names the exact edit that fixes it,
// because the two dashboards fail at boot (bad registry, bad mount) where an agent has no
// stack trace to reason from — only the message.
import { UltimateError } from '@ultimat3/core';
import { registerErrorCodes, UltimateError } from '@ultimat3/core';

export const ADMIN_ERROR_CODES = [
/** Codes this package declares and owns. */
export const ADMIN_OWNED_ERROR_CODES = [
'X_ADMIN_ENTITY_UNKNOWN',
'X_ADMIN_FIELD_UNSUPPORTED',
'X_ADMIN_POLICY_MISSING',
'X_DEV_DASHBOARD_IN_PROD',
'X_NOT_IMPLEMENTED',
] as const;

/**
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `DevSourceUnavailableError` throws it; this package
* neither titles nor registers it, because the owner's title is the only one that may exist.
*/
export const ADMIN_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;

/** Every code admin can throw: the ones it owns plus the one it borrows. */
export const ADMIN_ERROR_CODES = [
...ADMIN_OWNED_ERROR_CODES,
...ADMIN_BORROWED_ERROR_CODES,
] as const;

export type AdminOwnedErrorCode = (typeof ADMIN_OWNED_ERROR_CODES)[number];
export type AdminErrorCode = (typeof ADMIN_ERROR_CODES)[number];

export const ADMIN_ERROR_TITLES: Readonly<Record<AdminOwnedErrorCode, string>> = {
X_ADMIN_ENTITY_UNKNOWN: 'the admin references an entity that does not exist',
X_ADMIN_FIELD_UNSUPPORTED: 'a column type the admin cannot render',
X_ADMIN_POLICY_MISSING: 'an admin-exposed subject has no policy',
X_DEV_DASHBOARD_IN_PROD: '/_x was mounted outside dev',
};

// One unconditional call, so a second package claiming one of admin's codes throws
// X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
registerErrorCodes(
Object.fromEntries(Object.entries(ADMIN_ERROR_TITLES).map(([code, title]) => [code, { title }])),
);

const docsFor = (code: AdminErrorCode): string => `https://ultimate.dev/errors/${code}`;

/** A resource, nav item, or MCP tool named an entity the registry does not have. */
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export { AdminDetail, type AdminDetailProps } from './detail';
// host that only mounts the dev panels never loads a production admin component.
export {
ADMIN_ERROR_CODES,
ADMIN_ERROR_TITLES,
AdminEntityUnknownError,
type AdminErrorCode,
type AdminErrorParts,
Expand Down
14 changes: 8 additions & 6 deletions packages/ai/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// The X_* codes owned by @ultimat3/ai. Budget and threshold failures name the exact knob to
// change, because the caller is often a CI job with no human attached.

import { hasErrorCode, registerErrorCodes, UltimateError } from '@ultimat3/core';
import { registerErrorCodes, UltimateError } from '@ultimat3/core';

export const AI_ERROR_CODES = [
'X_AI_PROVIDER_UNAVAILABLE',
Expand Down Expand Up @@ -37,11 +37,13 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
};

// Titles must be registered for `format()` to render the contract's first line. Guarded
// because registering a code twice throws X_ERROR_CODE_DUPLICATE at import time.
for (const [code, title] of Object.entries(AI_ERROR_TITLES)) {
if (!hasErrorCode(code)) registerErrorCodes({ [code]: { title } });
}
// Titles must be registered for `format()` to render the contract's first line. Unconditional and
// in one call: every code above is owned here, so a second package claiming one is a real conflict
// that has to surface as X_ERROR_CODE_DUPLICATE rather than resolve to whoever imported first.
// `X_FORBIDDEN` (policy's) and `X_CURRENCY_MISMATCH` (money's) are thrown here but never titled.
registerErrorCodes(
Object.fromEntries(Object.entries(AI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
);

const docsFor = (code: AiErrorCode): string => `https://ultimate.dev/errors/${code}`;

Expand Down
6 changes: 4 additions & 2 deletions packages/auth/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ bun run --filter @ultimat3/auth typecheck
Gotchas:
- `exactOptionalPropertyTypes` — declare optional fields as `x?: T | undefined`.
- `noUncheckedIndexedAccess` — index a `Record` into a local before narrowing it.
- `X_NOT_IMPLEMENTED` is core's, `X_FORBIDDEN` is policy's: `errors.ts` guards registration
with `hasErrorCode()` or import order throws `X_ERROR_CODE_DUPLICATE`.
- `X_NOT_IMPLEMENTED` is core's, `X_FORBIDDEN` is policy's. `errors.ts` registers only the codes
this package **owns**, unconditionally, and lists the borrowed two in `AUTH_BORROWED_ERROR_CODES`
without a title. A `hasErrorCode()` guard would suppress the `X_ERROR_CODE_DUPLICATE` that is
supposed to fire when two packages claim one code.
- Tests run against `MemoryAdapter`; nothing in this package needs a database.
43 changes: 23 additions & 20 deletions packages/auth/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
// factories here are deliberately coarse — `rate-limit.ts` owns the single login failure
// every credential path must throw, and nothing else describes *why* a credential failed.

import { hasErrorCode, registerErrorCodes, UltimateError } from '@ultimat3/core';
import { registerErrorCodes, UltimateError } from '@ultimat3/core';

export const AUTH_ERROR_CODES = [
/** Codes this package declares and owns. `X_UNAUTHENTICATED` is auth's; http only borrows it. */
export const AUTH_OWNED_ERROR_CODES = [
'X_UNAUTHENTICATED',
'X_SESSION_EXPIRED',
'X_MFA_REQUIRED',
Expand All @@ -16,12 +17,22 @@ export const AUTH_ERROR_CODES = [
'X_ACCOUNT_LOCKED',
'X_API_KEY_INVALID',
'X_AUTH_WRITE_FAILED',
'X_NOT_IMPLEMENTED',
] as const;

/**
* Codes another package owns that auth only throws: `X_FORBIDDEN` is `@ultimat3/policy`'s and
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. No titles here on purpose — a second copy of a title
* is a title that drifts, and registering one of these would be `X_ERROR_CODE_DUPLICATE`.
*/
export const AUTH_BORROWED_ERROR_CODES = ['X_FORBIDDEN', 'X_NOT_IMPLEMENTED'] as const;

/** Every code auth can throw: the ones it owns plus the ones it borrows. */
export const AUTH_ERROR_CODES = [...AUTH_OWNED_ERROR_CODES, ...AUTH_BORROWED_ERROR_CODES] as const;

export type AuthOwnedErrorCode = (typeof AUTH_OWNED_ERROR_CODES)[number];
export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[number];

export const AUTH_ERROR_TITLES: Readonly<Record<AuthErrorCode, string>> = {
export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
X_UNAUTHENTICATED: 'no authenticated actor for this request',
X_SESSION_EXPIRED: 'session passed its idle or absolute expiry',
X_MFA_REQUIRED: 'a second factor is required before this session is usable',
Expand All @@ -32,25 +43,17 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthErrorCode, string>> = {
X_ACCOUNT_LOCKED: 'too many failed attempts; this key is locked out',
X_API_KEY_INVALID: 'api key is unknown, revoked, expired or wrong',
X_AUTH_WRITE_FAILED: 'an adapter write returned no row, so it cannot be confirmed',
X_NOT_IMPLEMENTED: 'this driver does not implement the requested feature',
};

/** `X_FORBIDDEN` is owned by `@ultimat3/policy`. Guards reuse the code, never redefine it. */
export const AUTH_BORROWED_ERROR_TITLES: Readonly<Record<'X_FORBIDDEN', string>> = {
X_FORBIDDEN: 'policy denied this actor',
};

// Titles must be registered for `format()` to render the contract's first line, but
// `X_NOT_IMPLEMENTED` belongs to core and `X_UNAUTHENTICATED`/`X_FORBIDDEN` may already be
// registered by http or policy — registering a code twice throws X_ERROR_CODE_DUPLICATE.
for (const [code, title] of Object.entries({
...AUTH_ERROR_TITLES,
...AUTH_BORROWED_ERROR_TITLES,
})) {
if (!hasErrorCode(code)) registerErrorCodes({ [code]: { title } });
}
// Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
// loudly as X_ERROR_CODE_DUPLICATE at import. A presence guard would turn that collision into
// whichever package loaded first deciding what the code means.
registerErrorCodes(
Object.fromEntries(Object.entries(AUTH_ERROR_TITLES).map(([code, title]) => [code, { title }])),
);

export type AuthThrowCode = AuthErrorCode | keyof typeof AUTH_BORROWED_ERROR_TITLES;
/** Every code an `AuthError` may carry — the owned ones and the two borrowed ones alike. */
export type AuthThrowCode = AuthErrorCode;

export class AuthError extends UltimateError {
override readonly name = 'AuthError';
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export {
export { BuiltinAdapter } from './builtin-adapter';
export type { AuthErrorCode, AuthThrowCode, OAuthExchangeFailure } from './errors';
export {
AUTH_BORROWED_ERROR_TITLES,
AUTH_BORROWED_ERROR_CODES,
AUTH_ERROR_CODES,
AUTH_ERROR_TITLES,
AuthError,
Expand Down
7 changes: 5 additions & 2 deletions packages/auth/src/oauth-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,14 @@ async function postForm(
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
} catch (error) {
const reason = error instanceof Error ? error.message : 'the request failed before a response';
throw oauthExchangeFailed({
provider,
stage: 'token',
detail: error instanceof Error ? error.message : 'the request failed before a response',
fix: `check egress to ${new URL(url).host} from this host, then restart the flow`,
detail:
`${reason} — nothing left this host for ${url} (egress, DNS or TLS); restart the ` +
'flow once it does, since the code is already spent',
fix: `curl -sS -m 5 -o /dev/null ${url}`,
});
}

Expand Down
7 changes: 5 additions & 2 deletions packages/auth/src/oauth-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,14 @@ async function getJson(
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
} catch (error) {
const reason = error instanceof Error ? error.message : 'the request failed before a response';
throw oauthExchangeFailed({
provider,
stage: 'userinfo',
detail: error instanceof Error ? error.message : 'the request failed before a response',
fix: `check egress to ${new URL(url).host} from this host, then restart the flow`,
detail:
`${reason} — nothing left this host for ${url} (egress, DNS or TLS); restart the ` +
'flow once it does',
fix: `curl -sS -m 5 -o /dev/null ${url}`,
});
}
if (!response.ok) {
Expand Down
6 changes: 4 additions & 2 deletions packages/auth/src/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ export function createAuthLimiter(
export function loginFailed(): AuthError {
return new AuthError({
code: 'X_UNAUTHENTICATED',
cause: 'the email and password combination did not match an account',
fix: 'check the address and password, or reset it at POST /auth/password/reset',
cause:
'the email and password combination did not match an account — re-enter them before ' +
'issuing the reset below, which mails a single-use token',
fix: "issueVerification(runtime, { purpose: 'password-reset', identifier: email, locale })",
});
}
Loading
Loading