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
27 changes: 27 additions & 0 deletions .changeset/17707-concurrent-limit-exceeded-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/spec': minor
---

**BREAKING** — `CONCURRENT_LIMIT_EXCEEDED` is removed from the closed `StandardErrorCode` catalogue (#17707).

A `major`-class change, recorded as `minor` under the launch-window convention. ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue: ruling A on #17707, narrowed on 2026-09-24 to this code alone.

**Why.** A catalogue member is the list callers branch on exhaustively, and a member with no producer teaches a branch that cannot fire. `CONCURRENT_LIMIT_EXCEEDED` had no producer behind it when the ruling was recorded, so it leaves the catalogue. Its neighbour `QUOTA_EXCEEDED` stays, unchanged, as the narrowing ruled.

### FROM → TO

| removed | what to write instead |
| --- | --- |
| `CONCURRENT_LIMIT_EXCEEDED` (`StandardErrorCode`, 429) | nothing — delete the branch. For request pacing branch on `RATE_LIMIT_EXCEEDED` (HTTP 429; wait `retryAfterSeconds` before retrying). A service that enforces its own concurrency limit registers a code for it in its own error-code ledger. |

**The one-line fix: delete every branch on `CONCURRENT_LIMIT_EXCEEDED`.** A comparison against a value typed `StandardErrorCode` or `ErrorCode` no longer compiles (`TS2367`). At runtime the spelling now fails `StandardErrorCode`, `ErrorCode` / `ApiErrorSchema.code` and `makeApiErrorSchema(...)` parse, and the failure message is the removal prescription itself.

**What stays.** The other `StandardErrorCode` members, `QUOTA_EXCEEDED` included, are unchanged.

⚠️ **The out-of-repo consumer population is NOT MEASURED.** Inside this repository the code occurred only in the enum declaration, the hand-written error catalogue page, the generated reference pages and the unpinned-status baseline, and the pinned objectui checkout does not name it; `@objectstack/spec` is published, so readers elsewhere were not measured.

The ADR-0087 D3 semantic entry `standard-error-code-concurrent-limit-exceeded-retired` carries the judgement: an error code is wire vocabulary, not a metadata key, so there is no authored source for a D2 conversion to rewrite.

Clause-②: no

<!-- adr-0087: registered standard-error-code-concurrent-limit-exceeded-retired -->
7 changes: 1 addition & 6 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Error Code Catalog
description: Complete reference for all ObjectStack error codes with causes, fixes, and retry strategies
---

ObjectStack uses a structured error system with **9 error categories** and **52 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
ObjectStack uses a structured error system with **9 error categories** and **51 error codes reachable on the wire**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.

This catalog documents the **wire face** — the codes a client can actually receive. That is not quite the
`StandardErrorCode` enum: the enum also carries in-process spellings the REST door translates at the
Expand Down Expand Up @@ -509,11 +509,6 @@ an environment scope (no `X-Environment-Id` header and no hostname mapping).
**Fix:** Wait for the quota to reset (check `retryAfterSeconds`), or upgrade the plan.
**Retry:** `retry_after`

### `CONCURRENT_LIMIT_EXCEEDED`
**Cause:** Too many concurrent requests from the same client.
**Fix:** Reduce parallel request count. Implement request queuing.
**Retry:** `retry_backoff`

---

## Server Errors (5xx)
Expand Down
3 changes: 1 addition & 2 deletions content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +321 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
| **message** | `string` | ✅ | Readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
Expand Down Expand Up @@ -80,7 +80,6 @@ const result = ApiErrorSchema.parse(data);
* `PRECONDITION_REQUIRED`
* `RATE_LIMIT_EXCEEDED`
* `QUOTA_EXCEEDED`
* `CONCURRENT_LIMIT_EXCEEDED`
* `INTERNAL_ERROR`
* `DATABASE_ERROR`
* `TIMEOUT`
Expand Down
4 changes: 1 addition & 3 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,6 @@ const result = ErrorCode.parse(data);
* `PRECONDITION_REQUIRED`
* `RATE_LIMIT_EXCEEDED`
* `QUOTA_EXCEEDED`
* `CONCURRENT_LIMIT_EXCEEDED`
* `INTERNAL_ERROR`
* `DATABASE_ERROR`
* `TIMEOUT`
Expand Down Expand Up @@ -560,7 +559,7 @@ const result = ErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `string` | ✅ | The registered extension code the waiver keeps admissible |
| **shadows** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +43 more>` | ✅ | The standard-catalog member whose condition the code re-spells |
| **shadows** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +42 more>` | ✅ | The standard-catalog member whose condition the code re-spells |
| **reason** | `string` | ✅ | Why the synonym stays registered — recorded so admission is a decision, not drift |

### Allowed Values: `StandardSynonymWaiver.shadows`
Expand Down Expand Up @@ -606,7 +605,6 @@ const result = ErrorCode.parse(data);
* `PRECONDITION_REQUIRED`
* `RATE_LIMIT_EXCEEDED`
* `QUOTA_EXCEEDED`
* `CONCURRENT_LIMIT_EXCEEDED`
* `INTERNAL_ERROR`
* `DATABASE_ERROR`
* `TIMEOUT`
Expand Down
4 changes: 1 addition & 3 deletions content/docs/references/api/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const result = EnhancedApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +43 more>` | ✅ | Machine-readable error code |
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +42 more>` | ✅ | Machine-readable error code |
| **message** | `string` | ✅ | Human-readable error message |
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution. |
| **refusal** | `true` | optional | Producer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose `message` is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld. |
Expand Down Expand Up @@ -102,7 +102,6 @@ const result = EnhancedApiErrorSchema.parse(data);
* `PRECONDITION_REQUIRED`
* `RATE_LIMIT_EXCEEDED`
* `QUOTA_EXCEEDED`
* `CONCURRENT_LIMIT_EXCEEDED`
* `INTERNAL_ERROR`
* `DATABASE_ERROR`
* `TIMEOUT`
Expand Down Expand Up @@ -321,7 +320,6 @@ const result = EnhancedApiErrorSchema.parse(data);
* `PRECONDITION_REQUIRED`
* `RATE_LIMIT_EXCEEDED`
* `QUOTA_EXCEEDED`
* `CONCURRENT_LIMIT_EXCEEDED`
* `INTERNAL_ERROR`
* `DATABASE_ERROR`
* `TIMEOUT`
Expand Down
5 changes: 4 additions & 1 deletion packages/spec/src/api/contract.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { QuerySchema } from '../data/query.zod';
import { DurationMs } from '../shared/duration.zod';
import { ErrorCode } from './error-code-ledger.zod';
import { StandardErrorCode } from './errors.zod';
import { retiredStandardErrorCodeMessage } from './retired-error-codes';

// ==========================================
// 1. Base Envelopes
Expand Down Expand Up @@ -281,7 +282,9 @@ export const ApiErrorSchema = lazySchema(() => z.object({
export function makeApiErrorSchema<const TExtra extends readonly string[]>(extraCodes: TExtra) {
const vocabulary: string[] = [...StandardErrorCode.options, ...extraCodes];
return ApiErrorSchema.extend({
code: (z.enum(vocabulary as [string, ...string[]]) as z.ZodType<StandardErrorCode | TExtra[number]>)
// The retired-spelling prescription rides this door too: `.options` above
// carries the catalogue's members, not its error map (`retired-error-codes.ts`).
code: (z.enum(vocabulary as [string, ...string[]], { error: retiredStandardErrorCodeMessage }) as z.ZodType<StandardErrorCode | TExtra[number]>)
.describe('Error code (StandardErrorCode ∪ the ledger this consumer registered)'),
});
}
Expand Down
7 changes: 6 additions & 1 deletion packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@

import { z } from 'zod';
import { StandardErrorCode, HttpStatusErrorCodeMap } from './errors.zod';
import { retiredStandardErrorCodeMessage } from './retired-error-codes';

export const ERROR_CODE_LEDGER = {
'@objectstack/rest': [
Expand Down Expand Up @@ -1373,7 +1374,11 @@ export const REGISTERED_ERROR_CODES: readonly RegisteredErrorCode[] = Object.fre
* failure, not a new dialect.
*/
export const ErrorCode = z.enum(
[...StandardErrorCode.options, ...REGISTERED_ERROR_CODES] as [string, ...string[]]
[...StandardErrorCode.options, ...REGISTERED_ERROR_CODES] as [string, ...string[]],
// `.options` carries the catalogue's members, not its error map — so a retired
// standard spelling would answer here with zod's bare enum message unless this
// door passes the same prescription (`retired-error-codes.ts`).
{ error: retiredStandardErrorCodeMessage },
) as z.ZodType<StandardErrorCode | RegisteredErrorCode>;

export type ErrorCode = StandardErrorCode | RegisteredErrorCode;
Expand Down
15 changes: 13 additions & 2 deletions packages/spec/src/api/errors.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { z } from 'zod';
*/
import { lazySchema } from '../shared/lazy-schema';
import { retiredKey } from '../shared/retired-key';
import { retiredStandardErrorCodeMessage } from './retired-error-codes';
export const ErrorCategory = z.enum([
'validation', // Input validation errors (400)
'authentication', // Authentication failures (401)
Expand Down Expand Up @@ -104,7 +105,6 @@ export const StandardErrorCode = z.enum([
// Rate Limiting (429)
'RATE_LIMIT_EXCEEDED', // Too many requests
'QUOTA_EXCEEDED', // API quota exceeded
'CONCURRENT_LIMIT_EXCEEDED', // Too many concurrent requests

// Server Errors (500)
'INTERNAL_ERROR', // Generic internal server error
Expand All @@ -117,7 +117,18 @@ export const StandardErrorCode = z.enum([
'EXTERNAL_SERVICE_ERROR', // External API call failed
'INTEGRATION_ERROR', // Integration service error
'WEBHOOK_DELIVERY_FAILED', // Webhook delivery failed
]);
], {
// A retired spelling answers with its prescription; every other invalid code
// keeps zod's own enum message (see `retired-error-codes.ts` for the three
// catalogue doors that share this map).
error: retiredStandardErrorCodeMessage,
});
// Retired (ADR-0049 enforce-or-remove, #17707, ruling A narrowed to this code):
// CONCURRENT_LIMIT_EXCEEDED — a 429 for "too many concurrent requests" that no
// producer in this repository emitted. Parsing it now answers with the
// prescription in `retired-error-codes.ts`; request pacing is
// RATE_LIMIT_EXCEEDED. Its neighbour QUOTA_EXCEEDED stays, unchanged: a hosted
// AI agent route emits it and the console's chatbot plugin reads it.
// Retired (ADR-0112 amendment 2026-08-18, ADR-0049 enforce-or-remove, #9266):
// BATCH_PARTIAL_FAILURE / BATCH_COMPLETE_FAILURE / TRANSACTION_FAILED — never
// emitted by any producer in the repo's history; the batch surface reports these
Expand Down
91 changes: 91 additions & 0 deletions packages/spec/src/api/retired-error-codes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import type { z } from 'zod';
import { ApiErrorSchema, makeApiErrorSchema } from './contract.zod';
import { ErrorCode, REGISTERED_ERROR_CODES } from './error-code-ledger.zod';
import { EnhancedApiErrorSchema, StandardErrorCode } from './errors.zod';

/**
* `CONCURRENT_LIMIT_EXCEEDED` left the closed `StandardErrorCode` catalogue
* (ADR-0049 enforce-or-remove; #17707, ruling A narrowed to this code alone).
*
* Each catalogue door is pinned on its own, because each builds its own `z.enum`
* and the last two build theirs from `StandardErrorCode.options` — members
* without the error map — so the prescription can go missing at one door while
* the others stay green (`retired-error-codes.ts`).
*
* The pins assert the refusal AND its prescription: a bare `success: false`
* would pass just as well with zod's generic enum message, which names the legal
* codes and nothing about the branch the caller has to delete.
*/

const RETIRED = 'CONCURRENT_LIMIT_EXCEEDED';
const PRESCRIPTION =
/`CONCURRENT_LIMIT_EXCEEDED` was removed from `StandardErrorCode`.*Delete any branch on it\..*`RATE_LIMIT_EXCEEDED`/s;

/** The message of the one issue a parse raised at `path`. */
function messageAt(result: z.ZodSafeParseResult<unknown>, path: readonly PropertyKey[]): string {
expect(result.success).toBe(false);
const issues = result.error!.issues.filter(
(issue) => JSON.stringify(issue.path) === JSON.stringify(path),
);
expect(issues).toHaveLength(1);
return issues[0].message;
}

const envelope = (code: string) => ({ code, message: 'm' });

describe('retired StandardErrorCode member CONCURRENT_LIMIT_EXCEEDED', () => {
it('is no longer a catalogue member', () => {
expect(StandardErrorCode.options).not.toContain(RETIRED);
});

it('door 1 — StandardErrorCode refuses it with the prescription', () => {
expect(messageAt(StandardErrorCode.safeParse(RETIRED), [])).toMatch(PRESCRIPTION);
});

it('door 1 — EnhancedApiErrorSchema.code refuses it with the prescription', () => {
expect(messageAt(EnhancedApiErrorSchema.safeParse(envelope(RETIRED)), ['code'])).toMatch(PRESCRIPTION);
});

it('door 2 — ErrorCode (catalogue ∪ framework ledger) refuses it with the prescription', () => {
expect(REGISTERED_ERROR_CODES).not.toContain(RETIRED);
expect(messageAt(ErrorCode.safeParse(RETIRED), [])).toMatch(PRESCRIPTION);
});

it('door 2 — ApiErrorSchema.code refuses it with the prescription', () => {
expect(messageAt(ApiErrorSchema.safeParse(envelope(RETIRED)), ['code'])).toMatch(PRESCRIPTION);
});

it('door 3 — makeApiErrorSchema (catalogue ∪ a downstream ledger) refuses it with the prescription', () => {
const DownstreamApiError = makeApiErrorSchema(['DOWNSTREAM_ONLY_CODE'] as const);
expect(messageAt(DownstreamApiError.safeParse(envelope(RETIRED)), ['code'])).toMatch(PRESCRIPTION);
});

// The prescription is keyed on the exact spelling that used to be legal. A
// typo was never a code, so telling its author it "was removed" would
// misinform — it keeps zod's own enum message at every door.
it('an unrelated invalid code keeps zod\'s own enum message at every door', () => {
const typo = 'CONCURRENT_LIMIT_EXCEEDEDD';
const messages = [
messageAt(StandardErrorCode.safeParse(typo), []),
messageAt(ErrorCode.safeParse(typo), []),
messageAt(ApiErrorSchema.safeParse(envelope(typo)), ['code']),
messageAt(makeApiErrorSchema(['DOWNSTREAM_ONLY_CODE'] as const).safeParse(envelope(typo)), ['code']),
];
for (const message of messages) expect(message).not.toMatch(/was removed/);
});

// The ruling narrowed the retirement to this one code: its catalogue sibling
// stays, because a hosted route emits it and a console plugin reads it.
it('QUOTA_EXCEEDED, the sibling that stays, still parses at every door', () => {
expect(StandardErrorCode.parse('QUOTA_EXCEEDED')).toBe('QUOTA_EXCEEDED');
expect(ErrorCode.parse('QUOTA_EXCEEDED')).toBe('QUOTA_EXCEEDED');
expect(EnhancedApiErrorSchema.parse(envelope('QUOTA_EXCEEDED')).code).toBe('QUOTA_EXCEEDED');
expect(ApiErrorSchema.parse(envelope('QUOTA_EXCEEDED')).code).toBe('QUOTA_EXCEEDED');
expect(
makeApiErrorSchema(['DOWNSTREAM_ONLY_CODE'] as const).parse(envelope('QUOTA_EXCEEDED')).code,
).toBe('QUOTA_EXCEEDED');
});
});
61 changes: 61 additions & 0 deletions packages/spec/src/api/retired-error-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* # Retired `StandardErrorCode` members that still answer with a prescription
*
* A member removed from the closed `StandardErrorCode` catalogue (`errors.zod.ts`)
* stops parsing at once — but zod's own enum message only lists the legal codes,
* which tells a caller still branching on the old spelling THAT it failed and
* nothing about what to do instead. This table is the "what to do instead" half,
* keyed by the exact spelling that used to be legal.
*
* ## Three doors, one table
*
* The catalogue is parsed at three places, and each builds its own `z.enum` — the
* last two from `StandardErrorCode.options`, which carries the MEMBERS but not the
* error map, so a prescription declared on the first alone would be silent at the
* other two:
*
* 1. `StandardErrorCode` itself (`errors.zod.ts`) — and through it
* `EnhancedApiErrorSchema.code` and the ledger waiver's `shadows`;
* 2. `ErrorCode` (`error-code-ledger.zod.ts`) — the catalogue ∪ the framework
* ledger, which is what `ApiErrorSchema.code` parses against;
* 3. `makeApiErrorSchema(extraCodes)` (`contract.zod.ts`) — the catalogue ∪ a
* downstream product's own ledger.
*
* Each passes {@link retiredStandardErrorCodeMessage} as its `error` map. Only a
* spelling in this table gets the prescription: telling the author of a typo that
* their code "was removed" would misinform, so everything else keeps zod's own
* enum message (the `HookBodyCapability` / `object.managedBy: 'system'` shape).
*
* ## Package-internal on purpose — NOT re-exported by `api/index.ts`
*
* The retirement narrows the accepted set and moves nothing else observable: the
* package's public API surface (`check:api-surface`) does not gain a name for the
* machinery that words the refusal.
*/

/**
* `CONCURRENT_LIMIT_EXCEEDED` — retired under ADR-0049 enforce-or-remove on the
* ruling recorded on #17707 (A, narrowed to this code alone). Its catalogue
* sibling `QUOTA_EXCEEDED` stays: a hosted AI agent route emits it and the
* console's chatbot plugin reads it.
*/
const CONCURRENT_LIMIT_EXCEEDED_RETIRED =
'`CONCURRENT_LIMIT_EXCEEDED` was removed from `StandardErrorCode` in @objectstack/spec 17.5.0 '
+ '(ADR-0049 enforce-or-remove, ADR-0112 catalogue) — a catalogue code with no producer teaches a '
+ 'branch that cannot fire. Delete any branch on it. For request pacing, branch on '
+ '`RATE_LIMIT_EXCEEDED` (HTTP 429; wait `retryAfterSeconds` before retrying); a service that '
+ 'enforces its own concurrency limit registers a code for it in its own error-code ledger.';

const RETIRED_STANDARD_ERROR_CODES: ReadonlyMap<string, string> = new Map([
['CONCURRENT_LIMIT_EXCEEDED', CONCURRENT_LIMIT_EXCEEDED_RETIRED],
]);

/**
* The `error` map every catalogue door passes to its `z.enum`: the prescription
* for a retired spelling, `undefined` (zod's own enum message) for anything else.
*/
export function retiredStandardErrorCodeMessage(issue: { input?: unknown }): string | undefined {
return typeof issue.input === 'string' ? RETIRED_STANDARD_ERROR_CODES.get(issue.input) : undefined;
}
Loading
Loading