diff --git a/.github/ci/error-shape-probe/ErrorShapeProbe.csproj b/.github/ci/error-shape-probe/ErrorShapeProbe.csproj
new file mode 100644
index 0000000..1ca51a9
--- /dev/null
+++ b/.github/ci/error-shape-probe/ErrorShapeProbe.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/workflows/example-regen.yml b/.github/workflows/example-regen.yml
index 5a203c6..f98e720 100644
--- a/.github/workflows/example-regen.yml
+++ b/.github/workflows/example-regen.yml
@@ -1380,6 +1380,37 @@ jobs:
[[ "$n" -gt 0 ]] || { echo "::error::generate exited 0 but wrote no files under out/"; exit 1; }
echo "==> hello-orders validates and generates on the pinned jar ($n files)."
+ - name: Set up .NET
+ if: steps.token.outputs.have == 'true'
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: Build domainExceptions against the kit's Error
+ if: steps.token.outputs.have == 'true'
+ # compatibility.md §54. The C# exceptions assign Error.Code, Id and
+ # Type; a kit Error whose field types disagree fails CS0029 here, and
+ # nowhere else: validate and generate both pass. The old string-`code`
+ # Error shipped for months that way. A copy, so the example's own
+ # Domain group (and its 14-file output) stays as it is.
+ run: |
+ set -euo pipefail
+ P="$RUNNER_TEMP/error-shape"
+ cp -R examples/hello-orders "$P"
+ python3 - "$P/hello-orders-project.json" <<'EOF'
+ import json, sys
+ p = sys.argv[1]
+ d = json.load(open(p))
+ g = d["groups"][0]
+ g["destination"] = "./out-error-shape/"
+ g["artifacts"] = ["enum", "valueObject", "domainModel", "domainExceptions"]
+ json.dump(d, open(p, "w"), indent=2)
+ EOF
+ (cd "$P" && specfuse-authoring generate generate hello-orders-project.json)
+ dotnet build .github/ci/error-shape-probe/ErrorShapeProbe.csproj \
+ -p:GenSrc="$P/out-error-shape/HelloOrders.Domain/gen-src"
+ echo "==> domainExceptions compiles against the kit's Error."
+
- name: Validate a fresh scaffold
if: steps.token.outputs.have == 'true'
run: |
diff --git a/compatibility.md b/compatibility.md
index fa43f73..9bf7ad7 100644
--- a/compatibility.md
+++ b/compatibility.md
@@ -1388,7 +1388,7 @@ The C# server prefixes every route with the path part of the server URL; the cli
- The §1.1 table gains `DELETE_SOFT_CLASSIFIED_NO_RETENTION`.
**Generator follow-up, not filed:** a structured `x-compliance.retention` (`period`, `from` as a date property, `action: delete | clear`, allowed per property) that the generator could turn into a sweep. Then `DELETE_SOFT_CLASSIFIED_NO_RETENTION` could accept either retention. The consumer has not asked for it yet. File when a second project needs it.
-2. **`Error.code` vs the HTTP status.** A consumer's `Error.code` was `type: integer` (the status), while its copy of `API_Handbook.md` §2.1 called `code` a snake_case rule name. As a result, 17 operation descriptions named codes the wire cannot carry. The consumer added a string `reason` field. The kit's own `Error` types `code` as a snake_case string and carries the status in a separate integer `status`, and §2.1 matches. **Corrected at the `0.16.0` pin bump (§52):** the conclusion drawn here, that the contradiction was the consumer's alone, is wrong. The generator's C# `domainExceptions` set `Error.Code` to an integer, so the consumer's integer `code` was the generator's shape, and the kit's `Error` does not compile with that artifact. The proposed generator rule (warn on `error.code = ` prose when `code` is an integer) is still not forwarded.
+2. **`Error.code` vs the HTTP status.** A consumer's `Error.code` was `type: integer` (the status), while its copy of `API_Handbook.md` §2.1 called `code` a snake_case rule name. As a result, 17 operation descriptions named codes the wire cannot carry. The consumer added a string `reason` field. The kit's own `Error` types `code` as a snake_case string and carries the status in a separate integer `status`, and §2.1 matches. **Corrected at the `0.16.0` pin bump (§52):** the conclusion drawn here, that the contradiction was the consumer's alone, is wrong. The generator's C# `domainExceptions` set `Error.Code` to an integer, so the consumer's integer `code` was the generator's shape, and the kit's `Error` does not compile with that artifact. The proposed generator rule (warn on `error.code = ` prose when `code` is an integer) is still not forwarded. **Resolved in §54:** the kit adopts the consumer's shape.
### 52. Generator `0.16.0` pin bump (kit `0.25.0`)
@@ -1432,7 +1432,7 @@ The C# server prefixes every route with the path part of the server URL; the cli
**Found while measuring:**
-1. **`Error.code` (corrects §51 item 2).** The C# `domainExceptions` templates set `Error.Code` to an integer, the HTTP status or an `int errorCode`, and set `Error.Id` to a string. The kit's `Error` types `code` as a snake_case string and `id` as `format: uuid`, so a project that registers `domainExceptions` fails `CS0029` on 22 assignments, measured on a `hello-orders` copy. `hello-orders` does not register the artifact. The consumer's integer `code` plus a string `reason` is the shape that compiles. `API_Handbook.md` §2.1 now says so. **Open decision:** does the kit's `Error` move to an integer `code`, a string `id` and a `reason` field, or does the generator move to a string `code`? Same shape as the `#2371` decision. Not filed: it waits on that decision.
+1. **`Error.code` (corrects §51 item 2).** The C# `domainExceptions` templates set `Error.Code` to an integer, the HTTP status or an `int errorCode`, and set `Error.Id` to a string. The kit's `Error` types `code` as a snake_case string and `id` as `format: uuid`, so a project that registers `domainExceptions` fails `CS0029` on 22 assignments, measured on a `hello-orders` copy. `hello-orders` does not register the artifact. The consumer's integer `code` plus a string `reason` is the shape that compiles. **Decided in §54:** the kit adopts the integer `code`, a `reason` and a plain-string `id` (authoring#159). The generator side is `clabonte/generator#2671`.
2. **§44 item 9.** A domain-filtered C# group (`pillar` or `domains.include`) with `event` and `service` emits no `{Message}Event` classes, while the service constructs them: `CS0246`, at `0.16.0` and `0.15.0`. The unfiltered control builds clean. Filed as `clabonte/generator#2639` (2026-10-13).
### 53. Generator `0.17.0` pin bump (kit `0.26.0`)
@@ -1459,7 +1459,51 @@ The C# server prefixes every route with the path part of the server URL; the cli
1. **A tenancy marker named like the generated one inherits itself.** With `tenancy.tenant.marker: "ITenantScoped"` and an entity named `Tenant`, the C# output declares `interface ITenantScoped : ITenantScoped` and fails `CS0529` / `CS0540`, on `0.16.0` and `0.17.0`. No `validate` finding reports it. Choose a marker name that differs from `I{Tenant}Scoped`.
2. **`heldEntityConfiguration` does not compile in a Domain-only group.** It imports `{basePackage}.Converters` and uses `EnumValueConverter<>`, which only `infrastructureProject` emits, on both jars. The §14.10 artifact set therefore generates but does not build on its own; the earlier builds in this file were on unbound groups.
-3. **`Error.code` and `domainExceptions`** are as §52 item 1 records, unchanged at `0.17.0`.
+3. **`Error.code` and `domainExceptions`** are as §52 item 1 records, unchanged at `0.17.0`. Resolved kit-side in §54.
+
+### 54. The kit's `Error` takes the HTTP status in `code` and the rule name in `reason` (authoring#159)
+
+**Status:** kit side **done**. Generator side: `clabonte/generator#2671`, open.
+
+**The decision.** Through `0.26.0` the kit's `Error` had a snake_case string `code` naming the rule that failed, an integer `status` and a `format: uuid` `id`. That shape never compiled with the C# `domainExceptions` artifact (§52 item 1). It now matches the shape RestoManager has run from spec to C# Backend and Flutter since its first commit (2025-10-15), with `reason` added 2026-10-12:
+
+| Field | Before | After |
+|---|---|---|
+| `code` | string, the rule name | **integer, the HTTP status** |
+| `reason` | — | **string, the rule name**: `^[a-z][a-z0-9_]*$`, `maxLength: 64`, optional |
+| `status` | integer, the HTTP status | **removed** (duplicated `code`) |
+| `id` | `format: uuid` | **plain string** |
+| `type` | — | **`$ref` to a new `ErrorType` enum** (`x-extensible`, the eight values `domainExceptions` requires) |
+
+The alternative, keeping string `code` and asking the generator for a string `Code` and a `Status`, was proposed in the first version of `#2671` and withdrawn. It would have made the generator carry two `Error` shapes for no project that uses the string one, and it misdescribed RestoManager's integer `code` as a workaround. It was the generator's shape from the start.
+
+**`type` is new, and required for `domainExceptions`.** Measured on `0.17.0`: a `hello-orders` copy registering `domainExceptions` fails `generate` with two `ARTIFACT_SCHEMA_DEPENDENCY` ERRORs until `Error.type` is typed by an `ErrorType` enum with `validationError`, `invalidToken`, `insufficientPrivileges`, `resourceNotFound`, `resourceConflict`, `preconditionFailed`, `businessRuleViolation` and `internalError`. No handbook said so; `API_Handbook.md` §11 now does.
+
+**Measured** (generator `0.17.0`, `hello-orders` copy, freshly bundled, Domain group `enum`, `valueObject`, `domainModel`, `domainExceptions`; the generated `Exceptions/`, `Models/Error.g.cs`, `Enums/`, `ValueObjects/` and `Converters/` compiled with `dotnet build`, .NET 10):
+
+- Old `Error` plus `type`/`ErrorType`: **22** distinct `CS0029`, one `Id` (`string` to `Guid?`) and one `Code` (`int` to `string`) per `Error` the exceptions build.
+- New `Error`: **0** errors.
+- `validate` on the changed `hello-orders`: `0` errors, `19` warnings, `11` suggestions, as before. Its own Domain group now generates 15 files, not 14: the new `Enums/ErrorType.g.cs`. Kit Spectral on its bundle: the same 20 findings, `0` errors.
+- A fresh `init` scaffold validates with the same five finding codes as in §53.
+
+The full Domain set does not build from `hello-orders` alone for unrelated reasons (`CallerContext` needs `Users`; `serviceInterface` needs `event`, which needs `eventRuntime` and a tenancy block), so the probe compiles the files the shape decides and nothing else.
+
+**What changed.**
+
+- `API_Handbook.md` §11: the schema, a table of what `code`, `type` and `reason` are each for, the `ErrorType` requirement, how to set `reason` from generated C# until `#2671` ships, and the error-code table recast as `type` plus common `reason` values. §2.1, the 412 and soft-delete examples, and the 409 recovery snippet move rule names from `code` to `reason`.
+- The `init` scaffold's and `hello-orders`' `common/responses/errors.yaml`, plus an `ErrorType` entry in each `openapi.yaml` `components.schemas`. `samples/endpoint-samples.yaml`.
+- The `spec-drafting` skill's worked example asserts `errors[0].reason`.
+- CI: `example-regen.yml`'s `generate` job generates `domainExceptions` from a copy of `hello-orders` and builds it with `.github/ci/error-shape-probe/ErrorShapeProbe.csproj`, so a pin bump that breaks the shape, or a kit edit to `Error`, fails CI instead of a consumer's build.
+
+**Migration for a project on the old shape** (any project scaffolded by kit `≤ 0.26.0`). Schema and docs only: the generated C# exceptions never compiled against the old shape, so no generated code depends on it.
+
+1. Move each rule name from `code` to `reason`: in `Error` examples, in response `description`s that list conflict codes, and in prose (`error.code = x` → `error.reason = x`).
+2. Retype `code` as `type: integer`, the HTTP status, and delete `status`.
+3. Drop `format: uuid` from `id`.
+4. Add `type` and `ErrorType` if the project registers, or will register, `domainExceptions`.
+5. Clients: branch on `reason` where they branched on `code`, and read the status from `code` where they read `status`. This is a breaking wire change for any client already deployed against the old shape.
+
+**Generator follow-up (`#2671`):** `(string reason, string message)` constructors on the six exceptions a rule can refuse with, and `Id` following the schema's `format`. Until then, `reason` is set by building the `Error` and passing it to the `(Error error)` constructor.
## Outstanding kit-side work
diff --git a/examples/hello-orders/api/specs/v1/common/responses/errors.yaml b/examples/hello-orders/api/specs/v1/common/responses/errors.yaml
index 9f372ac..8449b37 100644
--- a/examples/hello-orders/api/specs/v1/common/responses/errors.yaml
+++ b/examples/hello-orders/api/specs/v1/common/responses/errors.yaml
@@ -8,23 +8,39 @@ Error:
x-domain: common
required: [code, message]
properties:
+ # Not `format: uuid`: the generated C# exceptions fill `id` with a string of
+ # their own, and a uuid-typed `Id` does not compile (CS0029).
id:
type: string
- format: uuid
- description: Unique identifier for this error instance (for tracking)
+ description: >-
+ Unique identifier for this error instance, for support correlation.
+ Opaque: clients must not parse it.
+ type:
+ allOf:
+ - $ref: '#/ErrorType'
+ description: Broad category of the failure. See ErrorType.
code:
+ type: integer
+ description: >-
+ HTTP status of the response carrying this error (400, 409, …). The
+ rule that failed is named by `reason`, not here.
+ example: 409
+ reason:
type: string
- description: Machine-readable error code (snake_case)
- example: invalid_page_size
+ maxLength: 64
+ pattern: '^[a-z][a-z0-9_]*$'
+ description: >-
+ Stable snake_case name of the rule that failed (`template_in_use`,
+ `invalid_page_size`). Clients branch on this, never on `message`.
+ Absent when the error has no rule-level identity, such as a plain
+ schema violation.
+ example: template_in_use
message:
type: string
description: Human-readable error message (localized via Accept-Language)
field:
type: string
description: Field name that caused the error (for validation errors)
- status:
- type: integer
- description: HTTP status code
retryable:
type: boolean
description: Whether this error can be retried
@@ -47,6 +63,25 @@ Error:
type: string
format: date-time
+# `type` is required by the C# `domainExceptions` artifact: the generator refuses
+# to generate it (ARTIFACT_SCHEMA_DEPENDENCY) unless `Error.type` is typed by an
+# `ErrorType` enum carrying at least these eight values. Open set
+# (`x-extensible`): a client built before a new type ships must still decode
+# the error body, and falls back to the HTTP status. API Handbook §11.
+ErrorType:
+ type: string
+ x-domain: common
+ x-extensible: true
+ enum:
+ - validationError
+ - invalidToken
+ - insufficientPrivileges
+ - resourceNotFound
+ - resourceConflict
+ - preconditionFailed
+ - businessRuleViolation
+ - internalError
+
UnauthorizedError:
description: The authentication token is missing or expired
headers:
diff --git a/examples/hello-orders/api/specs/v1/openapi.yaml b/examples/hello-orders/api/specs/v1/openapi.yaml
index 7e7d8ad..8d696a0 100644
--- a/examples/hello-orders/api/specs/v1/openapi.yaml
+++ b/examples/hello-orders/api/specs/v1/openapi.yaml
@@ -101,6 +101,7 @@ components:
schemas:
Error: { $ref: './common/responses/errors.yaml#/Error' }
+ ErrorType: { $ref: './common/responses/errors.yaml#/ErrorType' }
PaginationLinks: { $ref: './common/schemas/PaginationLinks.yaml' }
Tenant: { $ref: './domains/tenant/models/Tenant.yaml' }
diff --git a/handbooks/API_Handbook.md b/handbooks/API_Handbook.md
index 6148869..83b84ff 100644
--- a/handbooks/API_Handbook.md
+++ b/handbooks/API_Handbook.md
@@ -649,7 +649,9 @@ Content-Type: application/json
{
"errors": [{
- "code": "precondition_failed",
+ "type": "preconditionFailed",
+ "code": 412,
+ "reason": "precondition_failed",
"message": "Resource was modified by another request. Current state included for merge.",
"field": null,
"retryable": true,
@@ -936,7 +938,9 @@ Response: 409 Conflict
{
"errors": [{
- "code": "delete_not_allowed",
+ "type": "resourceConflict",
+ "code": 409,
+ "reason": "delete_not_allowed",
"message": "Cannot delete order in 'submitted' status. Cancel order first.",
"retryable": false,
"requiredAction": "transition_to_cancelled"
@@ -1116,7 +1120,9 @@ Content-Type: application/json
{
"errors": [{
- "code": "template_in_use",
+ "type": "resourceConflict",
+ "code": 409,
+ "reason": "template_in_use",
"message": "Cannot delete template - currently used by 5 tenants",
"field": null,
"retryable": false,
@@ -1126,9 +1132,9 @@ Content-Type: application/json
```
**Required fields**:
-- `code`: Machine-readable error code (snake_case): a **string** naming the rule that failed. The kit's `Error` carries the HTTP status in its own integer field, `status`.
-
-> **⚠ The generated C# exceptions disagree (generator `0.16.0`).** The `domainExceptions` templates set `Error.Code` to an **integer**, either the HTTP status (`Code = StatusCode`) or an `int errorCode` constructor argument, and set `Error.Id` to a string. Generated against the kit's `Error` (`code` a string, `id` a `format: uuid`), the exceptions fail to compile with `CS0029`. `hello-orders` does not register `domainExceptions`, which is why its build is clean. Until the kit and the generator agree on the shape (`compatibility.md` §52), a C# project that uses `domainExceptions` types `code` as an integer and `id` as a plain string, and carries the rule name in a separate string field. `reason` (`^[a-z][a-z0-9_]*$`) is the name one consumer chose. Never document a snake_case value as `error.code` on such a project, because the wire cannot carry it there.
+- `code`: the HTTP status, an integer (`409`).
+- `reason`: the snake_case name of the rule that failed (`template_in_use`). Clients branch on it. A rule name never goes in `code`: prose, examples and `409` descriptions say `error.reason = template_in_use`, never `error.code = template_in_use`.
+- `type`: the broad category, from the `ErrorType` enum. The generated C# `ConflictException` sets `resourceConflict`.
- `message`: Human-readable explanation
- `retryable`: Usually `false` for business rule violations
- `requiredAction`: Guidance on how to resolve (recommended)
@@ -1223,7 +1229,7 @@ The validation script enforces these rules:
#### AI Agent Considerations
**When encountering 409**:
-1. **Parse the error** - Extract `code`, `message`, and `requiredAction`
+1. **Parse the error** - Extract `reason`, `message`, and `requiredAction`
2. **Understand the conflict** - The request is valid but state prevents it
3. **Resolve the conflict** - Follow `requiredAction` guidance to change system state
4. **Retry after resolution** - Once state is corrected, retry the original operation
@@ -1238,7 +1244,7 @@ async function deleteTemplate(templateId) {
if (error.status === 409) {
const conflict = error.body.errors[0];
- if (conflict.code === 'template_in_use') {
+ if (conflict.reason === 'template_in_use') {
// Resolve conflict: remove template from tenants
await removeTemplateFromTenants(templateId);
@@ -1258,7 +1264,7 @@ When including 409 in an operation spec, document the specific business rules th
```yaml
'409':
description: |
- Business rule conflict prevents operation:
+ Business rule conflict prevents operation (`reason`):
- `template_in_use`: Template is currently used by one or more tenants
- `has_active_assignments`: Customer has active assignments
- `pending_orders_exist`: Tenant has pending orders that must be completed first
@@ -1270,7 +1276,9 @@ When including 409 in an operation spec, document the specific business rules th
templateInUse:
value:
errors:
- - code: template_in_use
+ - type: resourceConflict
+ code: 409
+ reason: template_in_use
message: Cannot delete template - currently used by 5 tenants
retryable: false
requiredAction: remove_template_from_tenants
@@ -2554,7 +2562,7 @@ The two extensions are independent and may coexist on a single operation if it h
## 11) Error model
-All error responses use a consistent structure with machine-readable codes and human-readable messages.
+All error responses use a consistent structure: the HTTP status in `code`, the failed rule's machine-readable name in `reason`, and a human-readable `message`.
### Error Response Structure
@@ -2574,11 +2582,20 @@ Error:
properties:
id:
type: string
- format: uuid
- description: Unique identifier for this error instance (for tracking)
+ description: Unique identifier for this error instance (for support correlation). Opaque.
+ type:
+ allOf:
+ - $ref: '#/components/schemas/ErrorType'
+ description: Broad category of the failure
code:
+ type: integer
+ description: HTTP status of the response carrying this error
+ example: 400
+ reason:
type: string
- description: Machine-readable error code (snake_case)
+ maxLength: 64
+ pattern: '^[a-z][a-z0-9_]*$'
+ description: Stable snake_case name of the rule that failed. Absent when the error has no rule-level identity.
example: invalid_page_size
message:
type: string
@@ -2588,10 +2605,6 @@ Error:
type: string
description: Field name that caused the error (for validation errors)
example: pageSize
- status:
- type: integer
- description: HTTP status code
- example: 400
retryable:
type: boolean
description: Whether this error can be retried
@@ -2610,55 +2623,46 @@ Error:
type: string
format: date-time
description: When the error occurred
+
+ErrorType:
+ type: string
+ x-extensible: true
+ enum: [validationError, invalidToken, insufficientPrivileges, resourceNotFound,
+ resourceConflict, preconditionFailed, businessRuleViolation, internalError]
```
-### Error Code Categories
-
-| Category | HTTP Status | Retryable | Description |
-|----------|-------------|-----------|-------------|
-| `validation_error` | 400 | No | Invalid input data |
-| `authentication_required` | 401 | No | Missing or invalid authentication |
-| `authentication_expired` | 401 | Yes | Token expired, refresh needed |
-| `forbidden` | 403 | No | Insufficient permissions |
-| `not_found` | 404 | No | Resource doesn't exist |
-| `conflict` | 409 | No | Business rule violation |
-| `precondition_failed` | 412 | Yes | Resource modified, retry with new ETag |
-| `rate_limit_exceeded` | 429 | Yes | Too many requests |
-| `server_error` | 500 | Yes | Unexpected server error |
-| `service_unavailable` | 503 | Yes | Temporary unavailability |
-| `resource_expired` | 410 | No | Resource expired (e.g., offer) |
-
-### Standard Error Codes
-
-**Validation Errors (400):**
-- `invalid_request`: Malformed request
-- `invalid_field`: Invalid field value
-- `missing_required_field`: Required field missing
-- `invalid_page_size`: Page size out of bounds
-- `invalid_filter`: Invalid filter expression
-- `invalid_sort`: Invalid sort parameter
-
-**Authorization Errors (401/403):**
-- `authentication_required`: No authentication provided
-- `authentication_expired`: Token expired
-- `invalid_token`: Token invalid or malformed
-- `forbidden`: Insufficient permissions
-- `tenant_mismatch`: Resource belongs to different tenant
-
-**Resource Errors (404/409/410):**
-- `not_found`: Resource not found
-- `resource_expired`: Resource expired
-- `conflict`: State conflict
-- `delete_not_allowed`: Deletion not permitted
-- `precondition_failed`: ETag mismatch
-
-**Rate Limiting (429):**
-- `rate_limit_exceeded`: Rate limit exceeded
-
-**Server Errors (500/503):**
-- `internal_error`: Unexpected server error
-- `service_unavailable`: Service temporarily unavailable
-- `timeout`: Operation timed out
+The three identifying fields answer different questions:
+
+| Field | Answers | Example | Clients use it to |
+|---|---|---|---|
+| `code` | Which HTTP status? | `409` | nothing the status line doesn't already say; it is in the body so each entry of `errors[]` is self-describing |
+| `type` | What kind of failure? | `resourceConflict` | route the error to a generic handler (retry, re-authenticate, show a form error) |
+| `reason` | Which rule failed? | `template_in_use` | handle one specific rule. **Branch on `reason`, never on `message`**, which is human text and may change |
+
+`reason` is optional: a plain schema violation has no rule-level identity, and inventing one adds nothing. Where a handler refuses a request because of a named business rule, it sets `reason`, and the operation's response `description` lists the values it can take (section 2.1).
+
+**Why `code` is the HTTP status.** The generated C# `domainExceptions` set `Error.Code` from the exception's status, and Google's JSON error mapping (AIP-193) uses the same split: an integer `code` for the status, a separate `reason` for the machine-readable cause. An earlier kit `Error` put the rule name in a string `code` and the status in `status`. It never compiled with `domainExceptions` (`compatibility.md` §54, which has the migration).
+
+**`type` and `ErrorType` are required by `domainExceptions`.** The generator refuses to generate that artifact (`ARTIFACT_SCHEMA_DEPENDENCY`, ERROR) unless `Error.type` is typed by an `ErrorType` enum with at least the eight values above. Add values as you need them (`rateLimitExceeded`; `unmaskForbidden` for a denied `?unmask=`, see `Vendor_Extensions.md` §1.5, `x-unmask`); `x-extensible: true` lets a client built before a new value ships still decode the body and fall back to the status.
+
+**Setting `reason` from generated C#.** Through generator `0.17.0` the generated exceptions take an `int errorCode` or a message, and neither sets `Reason`. Build the `Error` yourself and pass it to the exception's `(Error error)` constructor; `clabonte/generator#2671` asks for `(string reason, string message)` constructors.
+
+### Error Types and Common Reasons
+
+| HTTP status | `type` | Retryable | Common `reason` values |
+|---|---|---|---|
+| 400 | `validationError` | No | `invalid_request`, `invalid_field`, `missing_required_field`, `invalid_page_size`, `invalid_filter`, `invalid_sort` |
+| 401 | `invalidToken` | Only `authentication_expired`, after a token refresh | `authentication_required`, `authentication_expired`, `invalid_token` |
+| 403 | `insufficientPrivileges` | No | `forbidden`, `tenant_mismatch` |
+| 404 | `resourceNotFound` | No | `not_found` |
+| 409 | `resourceConflict`, `businessRuleViolation` | No | `delete_not_allowed`, and each operation's own rules (`template_in_use`) |
+| 410 | `resourceNotFound` | No | `resource_expired` |
+| 412 | `preconditionFailed` | Yes | `precondition_failed` |
+| 429 | `rateLimitExceeded` (add it to `ErrorType`) | Yes | `rate_limit_exceeded` |
+| 500 | `internalError` | Yes | `internal_error`, `timeout` |
+| 503 | `internalError` | Yes | `service_unavailable` |
+
+A `reason` that only restates the status (`not_found` on a 404) is optional; set one when a client could act on it differently from the status alone.
### Example Error Responses
@@ -2666,11 +2670,12 @@ Error:
```json
{
"errors": [{
- "id": "550e8400-e29b-41d4-a716-446655440000",
- "code": "invalid_page_size",
+ "id": "Orders.Api-638962560000000000",
+ "type": "validationError",
+ "code": 400,
+ "reason": "invalid_page_size",
"message": "pageSize exceeds maximum of 250",
"field": "pageSize",
- "status": 400,
"retryable": false,
"details": {
"maxAllowed": 250,
@@ -2686,9 +2691,10 @@ Error:
```json
{
"errors": [{
- "code": "precondition_failed",
+ "type": "preconditionFailed",
+ "code": 412,
+ "reason": "precondition_failed",
"message": "Resource was modified by another request",
- "status": 412,
"retryable": true,
"retryAfter": 0,
"details": {
@@ -2703,9 +2709,10 @@ Error:
```json
{
"errors": [{
- "code": "rate_limit_exceeded",
+ "type": "rateLimitExceeded",
+ "code": 429,
+ "reason": "rate_limit_exceeded",
"message": "Rate limit exceeded. Try again in 60 seconds.",
- "status": 429,
"retryable": true,
"retryAfter": 60,
"details": {
@@ -2755,7 +2762,7 @@ async function retryWithBackoff(operation, maxRetries = 3) {
### Localization
- Error `message` is localized based on `Accept-Language` header
-- Error `code` is always in English (machine-readable)
+- Error `reason` and `type` are always in English (machine-readable)
- Default language: English (en)
- Supported languages: project-defined (declare in the project's overlay)
diff --git a/plugins/specfuse-authoring/skills/spec-drafting/SKILL.md b/plugins/specfuse-authoring/skills/spec-drafting/SKILL.md
index 5ef03c5..392faa7 100644
--- a/plugins/specfuse-authoring/skills/spec-drafting/SKILL.md
+++ b/plugins/specfuse-authoring/skills/spec-drafting/SKILL.md
@@ -361,7 +361,7 @@ enforces a maximum page size to protect downstream query performance.
Returns HTTP 201 on first creation and HTTP 200 on repeat (idempotent).
- `GET /users/{user_id}/bookmarks` returns a paginated JSON array of bookmarked
items. The default page size is 20 when `page_size` is absent; `page_size`
- values over 100 are rejected with HTTP 400 and error code
+ values over 100 are rejected with HTTP 400 and error reason
`page_size_over_limit`.
- `DELETE /users/{user_id}/bookmarks/{item_id}` removes a bookmark. Returns
HTTP 204 on success and HTTP 404 if the bookmark does not exist.
@@ -414,7 +414,7 @@ endpoint.
objects when no `page_size` parameter is supplied.
- **AC-4** — `GET /users/{user_id}/bookmarks?page_size=101` (operationId:
- `listBookmarks`) returns HTTP 400 with error code `page_size_over_limit`.
+ `listBookmarks`) returns HTTP 400 with error reason `page_size_over_limit`.
- **AC-5** — `DELETE /users/{user_id}/bookmarks/{item_id}` (operationId:
`deleteBookmark`) returns HTTP 204 when the bookmark exists, and HTTP 404
@@ -572,7 +572,7 @@ Pre-validation review for INIT-2026-0042:
- AC-2 → covers=create-repeat, commands=[POST twice], expected=200+object
- AC-3 → covers=list-default-page, commands=[GET], expected=200+array≤20
- AC-4 → covers=list-over-limit, commands=[GET ?page_size=101],
- expected=400+error code
+ expected=400+error reason
- AC-5 → covers=delete-exists-and-missing, commands=[DELETE],
expected=204 or 404
[x] Spec files: inventory-api-bookmarks.yaml parses as valid OpenAPI 3.0.3
@@ -593,7 +593,7 @@ Pre-validation review for INIT-2026-0042:
| AC-1 | "AC-1: POST /users/{user_id}/bookmarks creates a bookmark and returns 201." | `curl -sS -X POST -H 'Content-Type: application/json' -d '{"item_id":"item-1"}' -o body.json -w '%{http_code}' http://localhost:/users/user-1/bookmarks` | "HTTP status is 201; body.json contains item_id == 'item-1' and a valid created_at datetime." |
| AC-2 | "AC-2: POST /users/{user_id}/bookmarks with an already-bookmarked item returns 200 (idempotent)." | `curl -sS -X POST ... [same item_id a second time] -o body.json -w '%{http_code}' ...` | "HTTP status is 200; body.json contains the same bookmark object as AC-1." |
| AC-3 | "AC-3: GET /users/{user_id}/bookmarks returns up to 20 bookmarks when page_size is absent." | `curl -sS -o body.json -w '%{http_code}' http://localhost:/users/user-1/bookmarks` | "HTTP status is 200; body.json parses as a JSON array with at most 20 elements." |
-| AC-4 | "AC-4: GET /users/{user_id}/bookmarks?page_size=101 returns 400 with error code page_size_over_limit." | `curl -sS -o body.json -w '%{http_code}' 'http://localhost:/users/user-1/bookmarks?page_size=101'` | "HTTP status is 400; body.json contains error.code == 'page_size_over_limit'." |
+| AC-4 | "AC-4: GET /users/{user_id}/bookmarks?page_size=101 returns 400 with error reason page_size_over_limit." | `curl -sS -o body.json -w '%{http_code}' 'http://localhost:/users/user-1/bookmarks?page_size=101'` | "HTTP status is 400; body.json contains errors[0].reason == 'page_size_over_limit'." |
| AC-5 | "AC-5: DELETE /users/{user_id}/bookmarks/{item_id} returns 204 when the bookmark exists, 404 when it does not." | `curl -sS -X DELETE -w '%{http_code}' http://localhost:/users/user-1/bookmarks/item-1` | "HTTP status is 204 (bookmark existed) or 404 (bookmark absent)." |
**(b) Scope language is unambiguous per F3.32 guidance:**
diff --git a/samples/endpoint-samples.yaml b/samples/endpoint-samples.yaml
index 712b380..8d58420 100644
--- a/samples/endpoint-samples.yaml
+++ b/samples/endpoint-samples.yaml
@@ -364,7 +364,8 @@ components:
required: [id, code, message]
properties:
id: { type: string }
- code: { type: string }
+ code: { type: integer } # the HTTP status
+ reason: { type: string, pattern: '^[a-z][a-z0-9_]*$' } # the rule that failed
message: { type: string }
field: { type: string }
details: { type: string }
diff --git a/templates/project-init/api/specs/v1/common/responses/errors.yaml.template b/templates/project-init/api/specs/v1/common/responses/errors.yaml.template
index 7135ec8..4f46895 100644
--- a/templates/project-init/api/specs/v1/common/responses/errors.yaml.template
+++ b/templates/project-init/api/specs/v1/common/responses/errors.yaml.template
@@ -11,23 +11,39 @@ Error:
x-domain: common
required: [code, message]
properties:
+ # Not `format: uuid`: the generated C# exceptions fill `id` with a string of
+ # their own, and a uuid-typed `Id` does not compile (CS0029).
id:
type: string
- format: uuid
- description: Unique identifier for this error instance (for tracking)
+ description: >-
+ Unique identifier for this error instance, for support correlation.
+ Opaque: clients must not parse it.
+ type:
+ allOf:
+ - $ref: '#/ErrorType'
+ description: Broad category of the failure. See ErrorType.
code:
+ type: integer
+ description: >-
+ HTTP status of the response carrying this error (400, 409, …). The
+ rule that failed is named by `reason`, not here.
+ example: 409
+ reason:
type: string
- description: Machine-readable error code (snake_case)
- example: invalid_page_size
+ maxLength: 64
+ pattern: '^[a-z][a-z0-9_]*$'
+ description: >-
+ Stable snake_case name of the rule that failed (`template_in_use`,
+ `invalid_page_size`). Clients branch on this, never on `message`.
+ Absent when the error has no rule-level identity, such as a plain
+ schema violation.
+ example: template_in_use
message:
type: string
description: Human-readable error message (localized via Accept-Language)
field:
type: string
description: Field name that caused the error (for validation errors)
- status:
- type: integer
- description: HTTP status code
retryable:
type: boolean
description: Whether this error can be retried
@@ -50,6 +66,25 @@ Error:
type: string
format: date-time
+# `type` is required by the C# `domainExceptions` artifact: the generator refuses
+# to generate it (ARTIFACT_SCHEMA_DEPENDENCY) unless `Error.type` is typed by an
+# `ErrorType` enum carrying at least these eight values. Open set
+# (`x-extensible`): a client built before a new type ships must still decode
+# the error body, and falls back to the HTTP status. API Handbook §11.
+ErrorType:
+ type: string
+ x-domain: common
+ x-extensible: true
+ enum:
+ - validationError
+ - invalidToken
+ - insufficientPrivileges
+ - resourceNotFound
+ - resourceConflict
+ - preconditionFailed
+ - businessRuleViolation
+ - internalError
+
UnauthorizedError:
description: The authentication token is missing or expired
headers:
diff --git a/templates/project-init/api/specs/v1/openapi.yaml.template b/templates/project-init/api/specs/v1/openapi.yaml.template
index ba20e62..4b9aaa5 100644
--- a/templates/project-init/api/specs/v1/openapi.yaml.template
+++ b/templates/project-init/api/specs/v1/openapi.yaml.template
@@ -77,6 +77,7 @@ components:
schemas:
Error: { $ref: './common/responses/errors.yaml#/Error' }
+ ErrorType: { $ref: './common/responses/errors.yaml#/ErrorType' }
security:
- bearerAuth: []