From 7f615c70457f5ec27b3e66f7225f7ee2277676fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:07:04 +0000 Subject: [PATCH 1/3] feat(spec)!: retire CONCURRENT_LIMIT_EXCEEDED from StandardErrorCode Removes the producerless 429 member from the closed catalogue (ADR-0049 enforce-or-remove, ADR-0112), on ruling A narrowed to this code alone. QUOTA_EXCEEDED stays unchanged. - The retired spelling answers with its prescription at every catalogue door: StandardErrorCode, ErrorCode (ApiErrorSchema.code) and makeApiErrorSchema, through a package-internal error map (api/retired-error-codes.ts). - The hand-written error catalogue loses its entry and its wire count moves 52 -> 51; the unpinned-status baseline is regenerated by its gate. - check-error-status-conformance's vocabulary parse now stops at the enum array's own bracket (an options object no longer lets it read the next declarations), with a self-test battery for it. - ADR-0087 D3 semantic entry, registry regenerated. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- ...17707-concurrent-limit-exceeded-retired.md | 27 ++++++ content/docs/api/error-catalog.mdx | 7 +- packages/spec/src/api/contract.zod.ts | 5 +- .../spec/src/api/error-code-ledger.zod.ts | 7 +- packages/spec/src/api/errors.zod.ts | 15 ++- .../spec/src/api/retired-error-codes.test.ts | 91 +++++++++++++++++++ packages/spec/src/api/retired-error-codes.ts | 61 +++++++++++++ ...-code-concurrent-limit-exceeded-retired.ts | 39 ++++++++ packages/spec/src/migrations/registry.ts | 35 +++++++ scripts/check-error-status-conformance.mjs | 38 +++++++- scripts/error-status-unpinned-baseline.json | 1 - 11 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 .changeset/17707-concurrent-limit-exceeded-retired.md create mode 100644 packages/spec/src/api/retired-error-codes.test.ts create mode 100644 packages/spec/src/api/retired-error-codes.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.standard-error-code-concurrent-limit-exceeded-retired.ts diff --git a/.changeset/17707-concurrent-limit-exceeded-retired.md b/.changeset/17707-concurrent-limit-exceeded-retired.md new file mode 100644 index 00000000000..b3b1579a6cf --- /dev/null +++ b/.changeset/17707-concurrent-limit-exceeded-retired.md @@ -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 + + diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index e7fdf496d6a..4e427e22e21 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -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 @@ -470,11 +470,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) diff --git a/packages/spec/src/api/contract.zod.ts b/packages/spec/src/api/contract.zod.ts index 5f94f903943..16f785fdf5c 100644 --- a/packages/spec/src/api/contract.zod.ts +++ b/packages/spec/src/api/contract.zod.ts @@ -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 @@ -281,7 +282,9 @@ export const ApiErrorSchema = lazySchema(() => z.object({ export function makeApiErrorSchema(extraCodes: TExtra) { const vocabulary: string[] = [...StandardErrorCode.options, ...extraCodes]; return ApiErrorSchema.extend({ - code: (z.enum(vocabulary as [string, ...string[]]) as z.ZodType) + // 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) .describe('Error code (StandardErrorCode ∪ the ledger this consumer registered)'), }); } diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index d8ad728505e..63acea2e0f1 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -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': [ @@ -1379,7 +1380,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; export type ErrorCode = StandardErrorCode | RegisteredErrorCode; diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index 3c0dcdec28e..cb2f18b70b8 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -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) @@ -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 @@ -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 diff --git a/packages/spec/src/api/retired-error-codes.test.ts b/packages/spec/src/api/retired-error-codes.test.ts new file mode 100644 index 00000000000..7980dba0d27 --- /dev/null +++ b/packages/spec/src/api/retired-error-codes.test.ts @@ -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, 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'); + }); +}); diff --git a/packages/spec/src/api/retired-error-codes.ts b/packages/spec/src/api/retired-error-codes.ts new file mode 100644 index 00000000000..370906ff5a6 --- /dev/null +++ b/packages/spec/src/api/retired-error-codes.ts @@ -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 = 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; +} diff --git a/packages/spec/src/migrations/entries/semantic/18.standard-error-code-concurrent-limit-exceeded-retired.ts b/packages/spec/src/migrations/entries/semantic/18.standard-error-code-concurrent-limit-exceeded-retired.ts new file mode 100644 index 00000000000..ad0056372cc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.standard-error-code-concurrent-limit-exceeded-retired.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'standard-error-code-concurrent-limit-exceeded-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'error.code value CONCURRENT_LIMIT_EXCEEDED — a StandardErrorCode member retired from the ' + + 'closed catalogue, so constructing or parsing an error with it now refuses at every catalogue ' + + 'door (StandardErrorCode, ErrorCode / ApiErrorSchema.code, makeApiErrorSchema) with the ' + + 'removal prescription', + replacement: + 'delete any branch on `CONCURRENT_LIMIT_EXCEEDED` — a branch on a catalogue code no producer ' + + 'emits has nothing to match. 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 rather than reusing ' + + 'the retired spelling. `QUOTA_EXCEEDED`, its catalogue neighbour, is unchanged.', + reason: + 'ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A on #17707 ' + + '(maintainer 「同意」, decision batch #126 item 2) retired both producerless 429 members; the ' + + 'closure-review ruling of 2026-09-24 (letter 留·收窄, maintainer 「其他同意」) narrowed it to ' + + 'this code alone after `QUOTA_EXCEEDED` was found emitted by a hosted AI agent route and read ' + + 'by the console chatbot plugin. The ledger doctrine in error-code-ledger.zod.ts names a ' + + 'producerless row with no card behind it as the registered-but-unemittable retirement class, ' + + 'and a catalogue member no producer speaks teaches an author a branch that cannot fire; after ' + + 'removal the stale spelling fails parse with its prescription instead. An error code is WIRE ' + + 'vocabulary, not a metadata key, so there is no authored source for a D2 conversion to ' + + 'rewrite and this entry is the notification channel, as it was for ' + + '`standard-error-code-batch-members-retired`. No mechanical rewrite exists: a dead branch has ' + + 'no correct mechanical target.', + acceptanceCriteria: + 'No consumer branches on `CONCURRENT_LIMIT_EXCEEDED`; request pacing is handled on ' + + '`RATE_LIMIT_EXCEEDED`. Constructing or parsing an error with the retired spelling fails ' + + '`StandardErrorCode`, `ErrorCode` / `ApiErrorSchema` and the `makeApiErrorSchema` envelope ' + + 'parse, and the failure message is the removal prescription rather than the bare enum listing. ' + + '`QUOTA_EXCEEDED` still parses at every door.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 153240fa1ab..b317834908b 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -11978,6 +11978,41 @@ const step18: MigrationStep = { + 'of an envelope-level code; constructing an ApiError with a retired spelling ' + 'fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently.', }, + { + id: 'standard-error-code-concurrent-limit-exceeded-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'error.code value CONCURRENT_LIMIT_EXCEEDED — a StandardErrorCode member retired from the ' + + 'closed catalogue, so constructing or parsing an error with it now refuses at every catalogue ' + + 'door (StandardErrorCode, ErrorCode / ApiErrorSchema.code, makeApiErrorSchema) with the ' + + 'removal prescription', + replacement: + 'delete any branch on `CONCURRENT_LIMIT_EXCEEDED` — a branch on a catalogue code no producer ' + + 'emits has nothing to match. 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 rather than reusing ' + + 'the retired spelling. `QUOTA_EXCEEDED`, its catalogue neighbour, is unchanged.', + reason: + 'ADR-0049 enforce-or-remove applied to the ADR-0112 error catalogue. Ruling A on #17707 ' + + '(maintainer 「同意」, decision batch #126 item 2) retired both producerless 429 members; the ' + + 'closure-review ruling of 2026-09-24 (letter 留·收窄, maintainer 「其他同意」) narrowed it to ' + + 'this code alone after `QUOTA_EXCEEDED` was found emitted by a hosted AI agent route and read ' + + 'by the console chatbot plugin. The ledger doctrine in error-code-ledger.zod.ts names a ' + + 'producerless row with no card behind it as the registered-but-unemittable retirement class, ' + + 'and a catalogue member no producer speaks teaches an author a branch that cannot fire; after ' + + 'removal the stale spelling fails parse with its prescription instead. An error code is WIRE ' + + 'vocabulary, not a metadata key, so there is no authored source for a D2 conversion to ' + + 'rewrite and this entry is the notification channel, as it was for ' + + '`standard-error-code-batch-members-retired`. No mechanical rewrite exists: a dead branch has ' + + 'no correct mechanical target.', + acceptanceCriteria: + 'No consumer branches on `CONCURRENT_LIMIT_EXCEEDED`; request pacing is handled on ' + + '`RATE_LIMIT_EXCEEDED`. Constructing or parsing an error with the retired spelling fails ' + + '`StandardErrorCode`, `ErrorCode` / `ApiErrorSchema` and the `makeApiErrorSchema` envelope ' + + 'parse, and the failure message is the removal prescription rather than the bare enum listing. ' + + '`QUOTA_EXCEEDED` still parses at every door.', + }, { id: 'startup-orchestrator-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a diff --git a/scripts/check-error-status-conformance.mjs b/scripts/check-error-status-conformance.mjs index 41eeb5ad761..32e64ab95ae 100644 --- a/scripts/check-error-status-conformance.mjs +++ b/scripts/check-error-status-conformance.mjs @@ -202,11 +202,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ '24 — R6, the ASSIGNMENT form, and the two bounds that keep it honest.': 4, '25 — `z.enum([...])` members: the ONE extra segment `lookup` walks, and the': 3, '26 — a code the DOOR TRANSLATES away is not a wire producer: derived from': 8, + '27 — the vocabulary parse reads the enum ARRAY and stops at its bracket: an': 2, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 27; +const SELF_TEST_BATTERY_FLOOR = 28; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -696,9 +697,17 @@ export function deriveDoorMap(errorsZodSource) { return out; } -/** The reconciled vocabulary: `StandardErrorCode`'s members. */ +/** + * The reconciled vocabulary: `StandardErrorCode`'s members. + * + * The member block ends at the array's own closing bracket — the first line that + * BEGINS with `]` — and never at a later `]);`. The enum carries an `error` map + * (`z.enum([...], { error })`, the retired-spelling prescription), so its call no + * longer closes on `]);`; anchored there, the lazy match ran on through the next + * declarations and read their quoted codes as members too (the 27th battery). + */ export function parseStandardErrorCodes(errorsZodSource) { - const block = /export const StandardErrorCode = z\.enum\(\[([\s\S]*?)\]\);/.exec(errorsZodSource); + const block = /export const StandardErrorCode = z\.enum\(\[([\s\S]*?)\n\]/.exec(errorsZodSource); if (!block) throw new Error(`${ERRORS_ZOD}: StandardErrorCode enum not found — the deriver's anchor moved.`); return [...block[1].matchAll(/'([A-Z][A-Z0-9_]*)'/g)].map((m) => m[1]); } @@ -1721,7 +1730,28 @@ function selfTest() { && passthroughDoor.emitted.get('FEEDS_DISABLED')?.has(403) === true, JSON.stringify([passthroughDoor.translations, [...passthroughDoor.emitted.keys()]])); - const CASES = 58; + // 27 — the vocabulary parse reads the enum ARRAY and stops at its bracket: an + // options object after it (`z.enum([...], { error })`) and the quoted codes + // of the declarations below it are NOT members. Anchored on a trailing + // `]);`, the lazy match ran past an options-carrying enum to the next + // `]);` and counted a later map's values — duplicates of real members, and + // a code the options map merely names, as members of the catalogue. + battery('27 — the vocabulary parse reads the enum ARRAY and stops at its bracket: an'); + const ENUM_WITH_OPTIONS = + "export const StandardErrorCode = z.enum([\n 'TIMEOUT',\n 'VALIDATION_ERROR',\n], {\n" + + " error: (issue) => (issue.input === 'RETIRED_SPELLING' ? 'removed' : undefined),\n});\n" + + "export const HttpStatusErrorCodeMap = {\n 400: 'VALIDATION_ERROR',\n 504: 'TIMEOUT',\n 599: 'NOT_A_MEMBER',\n};\n" + + "export const RetryStrategy = z.enum([\n 'no_retry',\n]);\n"; + const withOptions = parseStandardErrorCodes(ENUM_WITH_OPTIONS); + check('27 an options object after the array adds no member, and the next declaration is not read', + JSON.stringify(withOptions) === JSON.stringify(['TIMEOUT', 'VALIDATION_ERROR']), + JSON.stringify(withOptions)); + const plainClose = parseStandardErrorCodes(`${ZOD_ENUM_DECL}\nexport const M = {\n 599: 'NOT_A_MEMBER',\n};\n`); + check('27b the plain `]);` close reads the same members (control)', + JSON.stringify(plainClose) === JSON.stringify(['TIMEOUT', 'VALIDATION_ERROR']), + JSON.stringify(plainClose)); + + const CASES = 60; // ── The floor: every declared battery RAN, and ran its cases (#13489) ─── // // Evaluated after every battery has had its chance and BEFORE the verdict, so diff --git a/scripts/error-status-unpinned-baseline.json b/scripts/error-status-unpinned-baseline.json index 0596eb33688..c48471ec847 100644 --- a/scripts/error-status-unpinned-baseline.json +++ b/scripts/error-status-unpinned-baseline.json @@ -1,7 +1,6 @@ { "note": "StandardErrorCode members documented with an HTTP status that NO producer this gate can read declares a status for — nothing pins the doc claim on either side. Shrink-only: a new entry is a gate failure, and a row that becomes pinned must be removed. Regenerate with `node scripts/check-error-status-conformance.mjs --update`.", "unpinned": [ - "CONCURRENT_LIMIT_EXCEEDED", "CONCURRENT_MODIFICATION", "DUPLICATE_VALUE", "EMAIL_NOT_VERIFIED", From e9f9b060f3a3b0f5f34892eaa4e45c0527df5abf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:18:31 +0000 Subject: [PATCH 2/3] docs(spec): regenerate reference pages after the CONCURRENT_LIMIT_EXCEEDED retirement Produced by `pnpm --filter @objectstack/spec check:generated --fix` (only check:docs was proven stale): contract.mdx, error-code-ledger.mdx and errors.mdx drop the retired member and their enum counts move by one. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 3 +-- content/docs/references/api/error-code-ledger.mdx | 4 +--- content/docs/references/api/errors.mdx | 4 +--- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 27848a09d9a..7fe9be3e5ff 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -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' \| … +331 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' \| … +330 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`. | @@ -80,7 +80,6 @@ const result = ApiErrorSchema.parse(data); * `PRECONDITION_REQUIRED` * `RATE_LIMIT_EXCEEDED` * `QUOTA_EXCEEDED` -* `CONCURRENT_LIMIT_EXCEEDED` * `INTERNAL_ERROR` * `DATABASE_ERROR` * `TIMEOUT` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 9cafe7575d8..515917c93e8 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -247,7 +247,6 @@ const result = ErrorCode.parse(data); * `PRECONDITION_REQUIRED` * `RATE_LIMIT_EXCEEDED` * `QUOTA_EXCEEDED` -* `CONCURRENT_LIMIT_EXCEEDED` * `INTERNAL_ERROR` * `DATABASE_ERROR` * `TIMEOUT` @@ -569,7 +568,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` @@ -615,7 +614,6 @@ const result = ErrorCode.parse(data); * `PRECONDITION_REQUIRED` * `RATE_LIMIT_EXCEEDED` * `QUOTA_EXCEEDED` -* `CONCURRENT_LIMIT_EXCEEDED` * `INTERNAL_ERROR` * `DATABASE_ERROR` * `TIMEOUT` diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index 94ee9cbd38e..ec80b510326 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -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. | @@ -102,7 +102,6 @@ const result = EnhancedApiErrorSchema.parse(data); * `PRECONDITION_REQUIRED` * `RATE_LIMIT_EXCEEDED` * `QUOTA_EXCEEDED` -* `CONCURRENT_LIMIT_EXCEEDED` * `INTERNAL_ERROR` * `DATABASE_ERROR` * `TIMEOUT` @@ -321,7 +320,6 @@ const result = EnhancedApiErrorSchema.parse(data); * `PRECONDITION_REQUIRED` * `RATE_LIMIT_EXCEEDED` * `QUOTA_EXCEEDED` -* `CONCURRENT_LIMIT_EXCEEDED` * `INTERNAL_ERROR` * `DATABASE_ERROR` * `TIMEOUT` From 628738ddf436e3a4eda155861318821e14de2ded Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:28:27 +0000 Subject: [PATCH 3/3] docs(spec): regenerate contract.mdx and error-code-ledger.mdx on the merged tree Discharges the os-regen deferral the merge commit recorded. Generated by `pnpm --filter @objectstack/spec build` then `pnpm --filter @objectstack/spec gen:docs` (packages/spec/scripts/build-docs.ts); a second gen:docs run is a no-op and check:docs reports 226 files in sync. The regenerated hunks are this branch's own on origin/main's side: the CONCURRENT_LIMIT_EXCEEDED rows leave the StandardErrorCode lists and the two enum summaries count one member fewer. Claude-Session: https://claude.ai/code/session_01AsCNgFBs8HCjwhyHQsFbx3 Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 3 +-- content/docs/references/api/error-code-ledger.mdx | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index ffadb96627e..298d3535d36 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -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`. | @@ -80,7 +80,6 @@ const result = ApiErrorSchema.parse(data); * `PRECONDITION_REQUIRED` * `RATE_LIMIT_EXCEEDED` * `QUOTA_EXCEEDED` -* `CONCURRENT_LIMIT_EXCEEDED` * `INTERNAL_ERROR` * `DATABASE_ERROR` * `TIMEOUT` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 8d1eb46d304..1c348888e5c 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -247,7 +247,6 @@ const result = ErrorCode.parse(data); * `PRECONDITION_REQUIRED` * `RATE_LIMIT_EXCEEDED` * `QUOTA_EXCEEDED` -* `CONCURRENT_LIMIT_EXCEEDED` * `INTERNAL_ERROR` * `DATABASE_ERROR` * `TIMEOUT` @@ -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` @@ -606,7 +605,6 @@ const result = ErrorCode.parse(data); * `PRECONDITION_REQUIRED` * `RATE_LIMIT_EXCEEDED` * `QUOTA_EXCEEDED` -* `CONCURRENT_LIMIT_EXCEEDED` * `INTERNAL_ERROR` * `DATABASE_ERROR` * `TIMEOUT`