From 5c9f74f5f7c3b557f6aff5f103c8472c4adaf286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Labont=C3=A9?= Date: Wed, 14 Oct 2026 11:14:04 -0400 Subject: [PATCH] feat: Error carries the HTTP status in `code` and the rule name in `reason` (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kit's Error had a string `code` naming the rule, an integer `status` and a uuid `id`. It never compiled with the C# domainExceptions artifact (22 CS0029 on generator 0.17.0). Adopt the shape RestoManager runs end to end: integer `code` (HTTP status), optional snake_case `reason` (the rule), plain-string `id`, no `status`. Add `type` and an `ErrorType` enum, which domainExceptions requires (ARTIFACT_SCHEMA_DEPENDENCY) and no handbook mentioned. - API_Handbook §11 rewritten, §2.1 and every example move rule names to `reason`; scaffold, hello-orders and endpoint-samples updated. - spec-drafting skill asserts errors[0].reason. - example-regen generates domainExceptions from a hello-orders copy and builds it (.github/ci/error-shape-probe), so the shape is compile-checked on every PR and pin bump. - compatibility.md §54 records the decision, the measurement and the migration; §51, §52 and §53 point to it. Closes #159. Co-Authored-By: Claude Opus 5.5 --- .../error-shape-probe/ErrorShapeProbe.csproj | 29 ++++ .github/workflows/example-regen.yml | 31 ++++ compatibility.md | 50 +++++- .../api/specs/v1/common/responses/errors.yaml | 49 +++++- .../hello-orders/api/specs/v1/openapi.yaml | 1 + handbooks/API_Handbook.md | 153 +++++++++--------- .../skills/spec-drafting/SKILL.md | 8 +- samples/endpoint-samples.yaml | 3 +- .../v1/common/responses/errors.yaml.template | 49 +++++- .../api/specs/v1/openapi.yaml.template | 1 + 10 files changed, 279 insertions(+), 95 deletions(-) create mode 100644 .github/ci/error-shape-probe/ErrorShapeProbe.csproj 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: []