# Contract: error-shape

**Component**: WebApi (`SatelliteProvider.Api`) — applies to every public HTTP endpoint
**Producer task**: AZ-795 — `_docs/02_tasks/done/AZ-795_strict_validation_epic.md`
**Consumer tasks**: every per-endpoint child of AZ-795 (first: AZ-796) plus every `gps-denied-onboard` HTTP client and every future browser/CLI consumer
**Version**: 1.0.0
**Status**: frozen
**Last Updated**: 2026-05-22

## Purpose

Defines the uniform RFC 7807 ProblemDetails / ValidationProblemDetails shape every public endpoint emits for client (4xx) errors. The contract exists so consumers can pattern-match against a single error payload regardless of which endpoint they called and regardless of whether the failure happened at the deserializer (unknown field, type mismatch) or at a FluentValidation rule (missing field, out-of-range value, business invariant).

The contract is enforced by two collaborating pieces of shared infrastructure:

1. **Deserializer-level rejection** — `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (.NET 8+) catches unknown fields, type mismatches, and malformed JSON. The framework's `BadHttpRequestException` carries a `System.Text.Json.JsonException` as its inner exception; `GlobalExceptionHandler` (`SatelliteProvider.Api/GlobalExceptionHandler.cs`) extracts the JSON path and emits a `ValidationProblemDetails` body.
2. **Business-rule rejection** — `FluentValidation` (12.0.0) validators wired through the generic `ValidationEndpointFilter<T>` (`SatelliteProvider.Api/Validators/ValidationEndpointFilter.cs`). Endpoints opt in via `RouteHandlerBuilder.WithValidation<T>()`. The filter calls `Results.ValidationProblem(result.ToDictionary())`, which produces an identically-shaped body.

Both paths produce `Content-Type: application/problem+json`. Both populate the same `errors` map keyed by request-body field path.

## Shape

### Validation failures (HTTP 400)

```jsonc
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "tiles[0].z": ["The z field is required."],
    "tiles[1]": ["The JSON property 'tileZoom' could not be mapped to any .NET member contained in type 'TileCoord'."]
  }
}
```

Per-field rules:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | URI string | yes | RFC 7231 §6.5.1 link for 400 (FluentValidation default) or RFC 9110 link for 5xx (server errors). |
| `title` | string | yes | Human-readable summary. Validation failures use `"One or more validation errors occurred."`; non-validation 400s use `"Bad Request"`. |
| `status` | integer | yes | Echoes the HTTP status code. |
| `errors` | object\<string, string[]\> | required for validation failures | Keys are JSON-path-style request-body field names. Values are arrays of error messages. Empty array is not allowed for a present key. |
| `traceId` | string | optional | Correlation identifier for log lookup. Populated for 5xx; may be populated for 4xx if FluentValidation surfaces it. |

### Field-path keys in `errors`

Field-path keys MUST follow the same casing as the request body's JSON (camelCase root + dotted/indexed access for nested types). Examples:

| Failure type | Example field-path key |
|--------------|------------------------|
| Missing root field | `tiles` |
| Missing nested field on tile entry | `tiles[0].z` |
| Out-of-range value | `tiles[3].z` |
| Unknown root field | `unknownField` (or `$` if path unavailable) |
| Unknown field inside nested object | `tiles[0].foo` |
| Both/neither XOR violation | `$` (request-body root) |

The deserialization-failure path (unknown field, type mismatch) sets the key to the JSON path System.Text.Json reports. FluentValidation paths use the property names from `RuleFor(x => x.Tiles)` etc., which automatically produce camelCase paths matching the request body.

### Generic 4xx errors (no validation context)

Some 4xx responses (auth failures, not-found, framework binding errors that aren't JSON deserialization) emit the simpler ProblemDetails shape — no `errors` map:

```jsonc
{
  "type": "https://datatracker.ietf.org/doc/html/rfc9110#name-401-unauthorized",
  "title": "Unauthorized",
  "status": 401
}
```

| Status | Title | Notes |
|--------|-------|-------|
| 400 (non-validation) | `"Bad Request"` | Framework binding failures that don't carry a JsonException (rare). |
| 401 | (varies) | Emitted by the JwtBearer middleware via `WWW-Authenticate`; body content depends on framework version. |
| 403 | (varies) | Authorization failure. Body shape governed by ASP.NET Core defaults. |
| 404 | (varies) | Per-endpoint default; some endpoints emit a custom NotFound body (e.g. region/route). |
| 501 | `"Not implemented"` | Stub endpoints (e.g. `/api/satellite/tiles/mgrs`). |

### 5xx errors

Server errors emit the simpler ProblemDetails shape with a `correlationId` extension property pointing at the server log entry. The body NEVER contains the original exception message or stack trace (sanitization landed in AZ-353):

```jsonc
{
  "type": "https://datatracker.ietf.org/doc/html/rfc9110#name-500-internal-server-error",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred. Use the correlationId to look up the server log entry.",
  "correlationId": "0HMBR..."
}
```

## Invariants

- **Inv-1**: Every 4xx and 5xx response sets `Content-Type: application/problem+json`.
- **Inv-2**: Validation failures (HTTP 400 from FluentValidation OR from JSON deserialization with a JsonException inner exception) always include an `errors` object.
- **Inv-3**: Each `errors` entry has at least one message. Empty arrays are forbidden.
- **Inv-4**: Field-path keys in `errors` use the same casing as the request body (camelCase root, dotted/indexed access for nested types).
- **Inv-5**: 5xx responses include a `correlationId` extension property; 4xx responses do not. No 4xx response leaks server-internal state (DB connection strings, secrets, internal stack frames).
- **Inv-6**: Unknown fields at root or in any nested object are rejected with HTTP 400 — not silently dropped. The error key names the offending field path.
- **Inv-7**: Type mismatches (e.g. string where integer expected) are rejected with HTTP 400 and the error key names the offending field path.

## Non-Goals

- **Not covered**: i18n / translated error messages. Messages are English-only; consumers translate on their side if needed.
- **Not covered**: error codes. The `errors` map carries human-readable strings, not stable error codes. Consumers MUST NOT pattern-match on the string content; they pattern-match on field paths.
- **Not covered**: rate-limit or quota errors. Those are a separate concern with their own contract (TBD).
- **Not covered**: 1xx / 3xx responses. Those are framework-level and not shaped by this contract.

## Versioning Rules

- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behavior.
- **Minor (1.x.0)**: Adding an optional extension field to ProblemDetails (e.g., `correlationId` becoming standard for 4xx as well as 5xx). Adding new field-path conventions that are backward-compatible (e.g., a new `[i]` indexing rule).
- **Major (2.0.0)**: Changing `errors` map shape (e.g. swapping to error-code keys). Changing `Content-Type`. Renaming `errors` to anything else. Removing `correlationId` from 5xx.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| validation-missing-field | Inventory request with `tiles: [{ "z": 18 }]` (x, y missing) | HTTP 400 + `errors["tiles[0].x"]` and `errors["tiles[0].y"]` populated | Inv-2, Inv-4 |
| validation-out-of-range | Inventory request with `tiles: [{ "z": 30, "x": 1, "y": 1 }]` | HTTP 400 + `errors["tiles[0].z"]` mentioning supported zoom range | Inv-2 |
| validation-unknown-root-field | Body `{ "unknownField": 42, "tiles": [...] }` | HTTP 400 + `errors["unknownField"]` populated with "could not be mapped" | Inv-6 |
| validation-unknown-nested-field | Body `{ "tiles": [{ "z": 18, "x": 1, "y": 1, "foo": 42 }] }` | HTTP 400 + `errors["tiles[0].foo"]` populated | Inv-6 |
| validation-type-mismatch | Body `{ "tiles": [{ "z": "eighteen" }] }` | HTTP 400 + `errors["tiles[0].z"]` populated | Inv-7 |
| validation-xor-both-populated | Body with both `tiles` and `locationHashes` populated | HTTP 400 + `errors["$"]` (or root key) populated | Inv-2 |
| 5xx-includes-correlation-id | Endpoint throws unhandled exception | HTTP 500 + `correlationId` extension matching `httpContext.TraceIdentifier` | Inv-5 |
| 5xx-no-secret-leak | Exception message contains a connection string with `Password=hunter2` | HTTP 500 body contains neither the password nor the connection string | Inv-5 |

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-22 | Initial contract — uniform RFC 7807 ValidationProblemDetails shape for FluentValidation business-rule failures + JSON deserialization failures, including unknown-field rejection (`UnmappedMemberHandling.Disallow`). Sanitized ProblemDetails for 5xx (preserves AZ-353). Produced by AZ-795. | autodev (Step 10, cycle 7) |
