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
9 changes: 9 additions & 0 deletions .changeset/retire-batch-error-codes.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/spec': minor
---

Retire `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and `TRANSACTION_FAILED` from `StandardErrorCode` (ADR-0112 amendment 2026-08-18, ADR-0049 enforce-or-remove, #9266). Breaking for the error vocabulary: the three spellings now fail `StandardErrorCode` / `ApiErrorSchema` parse. No producer has ever emitted any of them — the batch surface reports these conditions per row instead, with strictly more information.

FROM → TO: `error.code === 'BATCH_PARTIAL_FAILURE' | 'BATCH_COMPLETE_FAILURE' | 'TRANSACTION_FAILED'` (envelope-level, never emitted) → read the per-row `results[].errors[].code` — a rolled-back atomic batch marks each row `ROLLED_BACK`, rows the abort never reached `NOT_ATTEMPTED`, and the causal row keeps its own error (HTTP 200, both codes ledger-registered). One-line fix: delete any branch on the three retired spellings (it never fired) and branch on the per-row codes instead.

<!-- adr-0087: registered standard-error-code-batch-members-retired -->
21 changes: 1 addition & 20 deletions content/docs/api/error-catalog.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ description: Complete reference for all ObjectStack error codes with causes, fix

# Error Code Catalog

ObjectStack uses a structured error system with **9 error categories** and **53 standardized error codes**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
ObjectStack uses a structured error system with **9 error categories** and **50 standardized error codes**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.

<Callout type="info">
**Source:** `packages/spec/src/api/errors.zod.ts`
Expand DownExpand Up@@ -460,25 +460,6 @@ runtime can actually emit.

---

## Batch Operation Errors

### `BATCH_PARTIAL_FAILURE`
**Cause:** Some operations in a batch request succeeded while others failed.
**Fix:** Check the `details` for individual operation results. Retry only the failed operations.
**Retry:** `retry_immediate` (failed operations only)

### `BATCH_COMPLETE_FAILURE`
**Cause:** All operations in the batch request failed.
**Fix:** Check the `details` for root cause. Fix and retry the entire batch.
**Retry:** `retry_backoff`

### `TRANSACTION_FAILED`
**Cause:** A database transaction failed and was rolled back.
**Fix:** Check the `details` for the specific failure. Retry the entire transaction.
**Retry:** `retry_backoff`

---

## Action Errors (`/api/v1/actions`)

Since #3962 `/actions` failures speak HTTP like every other route — the status
Expand Down
5 changes: 1 addition & 4 deletions content/docs/references/api/contract.mdx
Original file line numberDiff line numberDiff 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' \| … +286 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' \| … +283 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, #9106) |
| **message** | `string` | ✅ | Readable error message |
| **category** | `string` | optional | Error category (e.g. validation, authorization) |
Expand DownExpand Up@@ -87,9 +87,6 @@ const result = ApiErrorSchema.parse(data);
* `EXTERNAL_SERVICE_ERROR`
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `BATCH_PARTIAL_FAILURE`
* `BATCH_COMPLETE_FAILURE`
* `TRANSACTION_FAILED`
* `ACCOUNT_LOCKED`
* `ALREADY_REVERTED`
* `AMBIGUOUS_MATCH`
Expand Down
8 changes: 1 addition & 7 deletions content/docs/references/api/error-code-ledger.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -192,9 +192,6 @@ const result = ErrorCode.parse(data);
* `EXTERNAL_SERVICE_ERROR`
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `BATCH_PARTIAL_FAILURE`
* `BATCH_COMPLETE_FAILURE`
* `TRANSACTION_FAILED`
* `ACCOUNT_LOCKED`
* `ALREADY_REVERTED`
* `AMBIGUOUS_MATCH`
Expand DownExpand Up@@ -446,7 +443,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' \| … +46 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' \| … +43 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 DownExpand Up@@ -501,9 +498,6 @@ const result = ErrorCode.parse(data);
* `EXTERNAL_SERVICE_ERROR`
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `BATCH_PARTIAL_FAILURE`
* `BATCH_COMPLETE_FAILURE`
* `TRANSACTION_FAILED`


---
Expand Down
8 changes: 1 addition & 7 deletions content/docs/references/api/errors.mdx
Original file line numberDiff line numberDiff 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' \| … +46 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' \| … +43 more>` | ✅ | Machine-readable error code |
| **message** | `string` | ✅ | Human-readable error message |
| **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>` | optional | Error category |
| **httpStatus** | `number` | optional | HTTP status code |
Expand DownExpand Up@@ -108,9 +108,6 @@ const result = EnhancedApiErrorSchema.parse(data);
* `EXTERNAL_SERVICE_ERROR`
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `BATCH_PARTIAL_FAILURE`
* `BATCH_COMPLETE_FAILURE`
* `TRANSACTION_FAILED`


---
Expand DownExpand Up@@ -294,9 +291,6 @@ const result = EnhancedApiErrorSchema.parse(data);
* `EXTERNAL_SERVICE_ERROR`
* `INTEGRATION_ERROR`
* `WEBHOOK_DELIVERY_FAILED`
* `BATCH_PARTIAL_FAILURE`
* `BATCH_COMPLETE_FAILURE`
* `TRANSACTION_FAILED`


---
Expand Down
4 changes: 4 additions & 0 deletions docs/adr/0112-error-code-vocabulary-and-ledger.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -103,6 +103,10 @@ Nine rulings, D1–D9.

**D2 — `StandardErrorCode` members are renamed in place (breaking).** Same 51 concepts, SCREAMING spelling (`permission_denied` → `PERMISSION_DENIED`). The enum remains the small, closed **standard catalog**: codes with platform-wide HTTP semantics (the `ErrorCategory`/status mapping). It does not try to swallow service-specific codes. `@objectstack/client` re-exports the type (`client/src/index.ts:12`) and uses it in `StandardError` (`:243`) — the client updates in the same PR.

> **Amendment (2026-08-18, [#9266](https://github.com/objectstack-ai/objectstack/issues/9266)) — the three batch-operation members are retired: `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE`, `TRANSACTION_FAILED` leave the standard catalog.** The catalog's warrant (D2) is "codes with platform-wide HTTP semantics"; these three never earned it. Measured on 2026-08-18 (#9266's dev report is the evidence of record): no producer has ever emitted any of them — outside the enum declaration the only occurrences in the whole repo were two spec tests using them as arbitrary fixture strings, and `git log -S` over `packages/` shows they have never had a producer since this ADR introduced the vocabulary. The batch write path reports exactly these conditions with **more** information than an envelope code could carry: a rolled-back atomic batch marks each row `errors[0].code = ROLLED_BACK`, rows the abort never reached `NOT_ATTEMPTED`, and the causal row keeps its own error — both codes ledger-registered (D3), both answered at HTTP 200, which also means no doc-publishable HTTP status ever existed for the three (the `check:error-status-conformance` census that found them ungraded is what filed the card).
>
> **Ruled (maintainer, 2026-08-18): 「9266 同意 A」** — ADR-0049 enforce-or-remove applied to the error vocabulary itself. A catalog member no producer can speak is a declared-but-unenforced surface in the one contract every consumer branches on: it teaches an AI author to write `if (error.code === 'BATCH_PARTIAL_FAILURE')`, a branch that can never fire. After removal that spelling fails `ApiErrorSchema` parse at the vocabulary boundary — loud at authoring time, the posture this ADR exists to defend. Accept-set narrowing is acknowledged: a previously-parseable code becomes a parse refusal; the ADR-0087 notification channel is the `standard-error-code-batch-members-retired` semantic entry (no stored-metadata rewrite exists — error codes live on the wire, not in `sys_metadata`). The count claims elsewhere in this ADR ("51 concepts", "53") are historical readings at their own dates and are deliberately not rewritten.

**D3 — Service extension codes are registered, not free-typed.** Codes like `ATTACHMENT_DOWNLOAD_DENIED` or `UPLOAD_SESSION_NOT_FOUND` do not enter the standard catalog. Each service registers its codes in an **error-code ledger** (ADR-0060 pattern; same generation machinery as `api-surface.json` / `json-schema.manifest.json`). Registration carries the code, the owning service, and a one-line meaning. A recommended (not required) convention is a domain prefix (`ATTACHMENT_*`, `UPLOAD_*`). The ledger is the anti-bottleneck: adding a service code touches the service's own ledger entry, not the spec enum — but it is still a *deliberate, reviewable* act, which is the entropy gate this whole problem lacked.

**D4 — `ApiErrorSchema.code` stops being `z.string()`.** It becomes the generated union of the standard catalog and the registered ledger (`ErrorCode`, generated at build time — Zod-first per PD#1, enum generated from ledger + `StandardErrorCode`). Conformance suites thereby assert values for free. There is **no regex escape hatch** (see Alternatives — a casing regex passes hallucinated codes, which is precisely the AI failure mode this ADR exists to prevent).
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/src/api/batch.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -323,13 +323,13 @@ describe('BatchUpdateResponseSchema', () => {
},
],
error: {
code: 'BATCH_PARTIAL_FAILURE',
code: 'INTERNAL_ERROR',
message: 'Batch operation failed',
},
});

expect(response.failed).toBe(1);
expect(response.error?.code).toBe('BATCH_PARTIAL_FAILURE');
expect(response.error?.code).toBe('INTERNAL_ERROR');
});
});

Expand Down
11 changes: 8 additions & 3 deletions packages/spec/src/api/errors.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,9 +42,14 @@ describe('StandardErrorCode', () => {
expect(StandardErrorCode.parse('INSUFFICIENT_PRIVILEGES')).toBe('INSUFFICIENT_PRIVILEGES');
});

it('should accept batch operation error codes', () => {
expect(StandardErrorCode.parse('BATCH_PARTIAL_FAILURE')).toBe('BATCH_PARTIAL_FAILURE');
expect(StandardErrorCode.parse('TRANSACTION_FAILED')).toBe('TRANSACTION_FAILED');
it('refuses the retired batch-operation codes (ADR-0112 amendment 2026-08-18, #9266)', () => {
// Retired under ADR-0049 enforce-or-remove: no producer ever emitted them;
// the batch surface reports these conditions per row via the ledger-registered
// ROLLED_BACK / NOT_ATTEMPTED codes instead. The wrong spelling must fail at
// the vocabulary boundary rather than compile into a branch that never fires.
expect(StandardErrorCode.safeParse('BATCH_PARTIAL_FAILURE').success).toBe(false);
expect(StandardErrorCode.safeParse('BATCH_COMPLETE_FAILURE').success).toBe(false);
expect(StandardErrorCode.safeParse('TRANSACTION_FAILED').success).toBe(false);
});
});

Expand Down
10 changes: 5 additions & 5 deletions packages/spec/src/api/errors.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -117,12 +117,12 @@ export const StandardErrorCode = z.enum([
'EXTERNAL_SERVICE_ERROR', // External API call failed
'INTEGRATION_ERROR', // Integration service error
'WEBHOOK_DELIVERY_FAILED', // Webhook delivery failed

// Batch Operation Errors
'BATCH_PARTIAL_FAILURE', // Batch operation partially succeeded
'BATCH_COMPLETE_FAILURE', // Batch operation completely failed
'TRANSACTION_FAILED', // Transaction rolled back
]);
// 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
// conditions per row via the ledger-registered ROLLED_BACK / NOT_ATTEMPTED at
// HTTP 200 instead of an envelope-level code.

export type StandardErrorCode = z.input<typeof StandardErrorCode>;

Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
// 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-batch-members-retired',
surface:
'`error.code` values `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and '
+ '`TRANSACTION_FAILED` — three `StandardErrorCode` members retired from the closed '
+ 'catalog (ADR-0112 amendment 2026-08-18), so constructing or parsing an ApiError '
+ 'with any of them now refuses at the vocabulary boundary',
replacement:
'branch on the codes the batch surface actually speaks: a rolled-back atomic batch '
+ 'marks each row `errors[0].code = ROLLED_BACK`, rows the abort never reached '
+ '`NOT_ATTEMPTED`, and the causal row keeps its own error — all per row, at HTTP '
+ '200, both codes ledger-registered. Delete any branch on the three retired '
+ 'spellings outright: it never fired, because nothing ever emitted them',
reason:
'ADR-0049 enforce-or-remove applied to the error vocabulary. No producer has ever '
+ 'emitted any of the three — measured on #9266: outside the enum declaration the '
+ 'only occurrences in the whole repo were two spec tests using them as arbitrary '
+ 'fixture strings, and `git log -S` shows they never had a producer since ADR-0112 '
+ 'introduced the vocabulary. A catalog member no producer can speak teaches an AI '
+ 'author a branch that can never fire; after removal the wrong spelling fails '
+ 'parse at authoring time instead. This is a WIRE vocabulary, not stored metadata '
+ '— no `sys_metadata` row exists for the D2 chain to rewrite, so (like '
+ '`driver-sql-upsert-cross-row-identity-merge-refused`) this entry is the '
+ 'notification channel. No mechanical rewrite exists: a dead branch has no '
+ 'correct mechanical target — the per-row codes carry strictly more information '
+ 'than the envelope code the branch expected. Maintainer ruling 2026-08-18: '
+ '「9266 同意 A」. #9266, ADR-0112, ADR-0049.',
acceptanceCriteria:
'No consumer branches on the three retired spellings; batch failure handling reads '
+ 'the per-row `results[].errors[].code` (`ROLLED_BACK` / `NOT_ATTEMPTED`) instead '
+ 'of an envelope-level code; constructing an ApiError with a retired spelling '
+ 'fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently.',
};
33 changes: 33 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5799,6 +5799,39 @@ const step18: MigrationStep = {
+ 'the curated retirements answer with their prescriptions. `os validate` exits non-zero '
+ 'on a stack carrying any undeclared top-level key, with or without `--strict`.',
},
{
id: 'standard-error-code-batch-members-retired',
surface:
'`error.code` values `BATCH_PARTIAL_FAILURE`, `BATCH_COMPLETE_FAILURE` and '
+ '`TRANSACTION_FAILED` — three `StandardErrorCode` members retired from the closed '
+ 'catalog (ADR-0112 amendment 2026-08-18), so constructing or parsing an ApiError '
+ 'with any of them now refuses at the vocabulary boundary',
replacement:
'branch on the codes the batch surface actually speaks: a rolled-back atomic batch '
+ 'marks each row `errors[0].code = ROLLED_BACK`, rows the abort never reached '
+ '`NOT_ATTEMPTED`, and the causal row keeps its own error — all per row, at HTTP '
+ '200, both codes ledger-registered. Delete any branch on the three retired '
+ 'spellings outright: it never fired, because nothing ever emitted them',
reason:
'ADR-0049 enforce-or-remove applied to the error vocabulary. No producer has ever '
+ 'emitted any of the three — measured on #9266: outside the enum declaration the '
+ 'only occurrences in the whole repo were two spec tests using them as arbitrary '
+ 'fixture strings, and `git log -S` shows they never had a producer since ADR-0112 '
+ 'introduced the vocabulary. A catalog member no producer can speak teaches an AI '
+ 'author a branch that can never fire; after removal the wrong spelling fails '
+ 'parse at authoring time instead. This is a WIRE vocabulary, not stored metadata '
+ '— no `sys_metadata` row exists for the D2 chain to rewrite, so (like '
+ '`driver-sql-upsert-cross-row-identity-merge-refused`) this entry is the '
+ 'notification channel. No mechanical rewrite exists: a dead branch has no '
+ 'correct mechanical target — the per-row codes carry strictly more information '
+ 'than the envelope code the branch expected. Maintainer ruling 2026-08-18: '
+ '「9266 同意 A」. #9266, ADR-0112, ADR-0049.',
acceptanceCriteria:
'No consumer branches on the three retired spellings; batch failure handling reads '
+ 'the per-row `results[].errors[].code` (`ROLLED_BACK` / `NOT_ATTEMPTED`) instead '
+ 'of an envelope-level code; constructing an ApiError with a retired spelling '
+ 'fails `StandardErrorCode`/`ApiErrorSchema` parse rather than passing silently.',
},
{
id: 'ui-record-blocks-unknown-keys-refused',
surface: 'page `record:alert` / `record:quick_actions` / `record:history` / '
Expand Down
5 changes: 1 addition & 4 deletions scripts/error-status-unpinned-baseline.json
Original file line numberDiff line numberDiff line change
@@ -1,8 +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`.",
"note": "StandardErrorCode members documented with an HTTP status that NO producer this gate can read declares a status for \u2014 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": [
"BATCH_COMPLETE_FAILURE",
"BATCH_PARTIAL_FAILURE",
"CONCURRENT_LIMIT_EXCEEDED",
"CONCURRENT_MODIFICATION",
"DELETE_RESTRICTED",
Expand DownExpand Up@@ -33,7 +31,6 @@
"RECORD_NOT_ACCESSIBLE",
"SESSION_EXPIRED",
"TIME_RESTRICTED",
"TRANSACTION_FAILED",
"VALUE_OUT_OF_RANGE",
"VALUE_TOO_LONG",
"VALUE_TOO_SHORT",
Expand Down
Loading