Validate requests with data annotations (task 59) #21

Merged
rob merged 6 commits from feat/request-validation into main 2026-08-03 16:42:50 +00:00
Owner

Validation rules are data-annotation attributes on the contracts, read by our own endpoint filter, so failures flow through ApiExceptionHandler and there is one 400 shape. System.ComponentModel.Annotations is in the base framework, so PlaceMark.Contracts stays a leaf (ADR-0009) and the front end can enforce the same rules. errors keys are the wire names, resolved through the same JsonSerializerOptions the request was bound with. Recorded in ADR-0025.

Microsoft.Extensions.Validation was built and rejected on three measured grounds: its filter runs outside application filters, so failures cannot reach our handler; its Accept: text/html body drops status and traceId, against ADR-0022; and its generator discovers only public types, so an internal request record silently accepted invalid input.

Program.cs gains three lines: RouteHandlerOptions.ThrowOnBadRequest in every environment, ApiExceptionHandler claiming BadHttpRequestException with a 4xx, and RequestCharsetMiddleware. The option defaults to IsDevelopment(), so the same malformed body previously produced different answers per environment, and with the option on but no handler branch the middleware answers 500 rather than reading the exception's status.

An unresolvable charset is refused before routing, by middleware, not by interpreting an exception afterwards. An earlier revision did the latter and review found it masked faults: on a bodyless endpoint the header is never inspected, so the endpoint ran, faulted, and the branch then read the header — turning every InvalidOperationException into a 400 and erasing it from the error log. RefusalStatusCode no longer takes an HttpContext at all, so it cannot consult the request even by accident.

The log names fields, never values: JsonException.Path is logged rather than the message, which quotes the offending literal verbatim.

Needs a decision:

  • 413 and 415 are still bare statuses — both short-circuit without throwing, so nothing in the pipeline can claim them; reaching 413 would mean taking body-size enforcement off Kestrel. Task 138, with routing's 404/405.
  • Consistency across write endpoints is a convention, not a guarantee. The filter is declared per MapGroup; a group that omits it is silently unvalidated. Making it global needs app.MapGroup("").
  • Validator.TryValidateObject is shallow — a nested contract would be waved through. v1's contracts are flat; the method carries the warning.
Validation rules are data-annotation attributes on the contracts, read by our own endpoint filter, so failures flow through `ApiExceptionHandler` and there is one 400 shape. `System.ComponentModel.Annotations` is in the base framework, so `PlaceMark.Contracts` stays a leaf (ADR-0009) and the front end can enforce the same rules. `errors` keys are the wire names, resolved through the same `JsonSerializerOptions` the request was bound with. Recorded in ADR-0025. `Microsoft.Extensions.Validation` was built and rejected on three measured grounds: its filter runs outside application filters, so failures cannot reach our handler; its `Accept: text/html` body drops `status` and `traceId`, against ADR-0022; and its generator discovers only public types, so an `internal` request record silently accepted invalid input. `Program.cs` gains three lines: `RouteHandlerOptions.ThrowOnBadRequest` in every environment, `ApiExceptionHandler` claiming `BadHttpRequestException` with a 4xx, and `RequestCharsetMiddleware`. The option defaults to `IsDevelopment()`, so the same malformed body previously produced different answers per environment, and with the option on but no handler branch the middleware answers 500 rather than reading the exception's status. An unresolvable charset is refused **before routing**, by middleware, not by interpreting an exception afterwards. An earlier revision did the latter and review found it masked faults: on a bodyless endpoint the header is never inspected, so the endpoint ran, faulted, and the branch then read the header — turning every `InvalidOperationException` into a 400 and erasing it from the error log. `RefusalStatusCode` no longer takes an `HttpContext` at all, so it cannot consult the request even by accident. The log names fields, never values: `JsonException.Path` is logged rather than the message, which quotes the offending literal verbatim. Needs a decision: - **413 and 415 are still bare statuses** — both short-circuit without throwing, so nothing in the pipeline can claim them; reaching 413 would mean taking body-size enforcement off Kestrel. Task 138, with routing's 404/405. - **Consistency across write endpoints is a convention, not a guarantee.** The filter is declared per `MapGroup`; a group that omits it is silently unvalidated. Making it global needs `app.MapGroup("")`. - `Validator.TryValidateObject` is shallow — a nested contract would be waved through. v1's contracts are flat; the method carries the warning.
Validate requests with data annotations
All checks were successful
CI / build (pull_request) Successful in 53s
19fbc2c9b8
Rules are attributes on the contracts, read by our own endpoint filter.
That keeps PlaceMark.Contracts a leaf — System.ComponentModel.Annotations
is in the base framework, Blazor WebAssembly included — so ADR-0009 holds
and the front end can enforce the same rules in a form.

Microsoft.Extensions.Validation was built and run before being rejected,
on three measurements. Its endpoint filter runs outside every filter the
application adds, so a validation failure cannot reach
ApiExceptionHandler. Its Accept: text/html response diverges from
ADR-0022 exactly where that record is careful, sending a reduced body
where every other refusal sends none. And its generator discovers only
public types: an internal request record produced a resolver returning
false unconditionally, accepting latitude 120 with a 200 and no warning
at build or run time.

FluentValidation lost on where the rules would live — a package in
Contracts, or rules the client cannot see.

Failures flow through the existing handler: ValidationFailedException
carries an optional field dictionary that the handler writes as errors,
camel-cased to the names the caller sent. One 400 shape, one writer.

The worked example is a request record and endpoint in the test project,
mapped into the real application at start-up, following ADR-0022's
precedent — the API carries no endpoint that exists only to be invalid.

Accepted cost: the filter is declared per feature group, so a group that
forgets the line is silently unvalidated. That is what the framework
mechanism would have given free, and it is recorded.
Author
Owner
  1. A second 400 shape the ADR does not admit. POST {"name":"ok","latitude":"nope"} returns 400 with no body, no Content-Type and no traceId — minimal-API binding short-circuits before any filter, since RouteHandlerOptions.ThrowOnBadRequest is false. ADR-0025's "one 400, one place that writes it" is therefore false for the commonest malformed request. Either set ThrowOnBadRequest so it reaches ApiExceptionHandler, or amend the ADR and raise a follow-up.

  2. errors keys are camel-cased CLR names, not what the caller sent. With [JsonPropertyName("place_name")] a caller sending place_name gets "name" back. RequestValidator's own remark admits this; the ADR and PR body assert the opposite. Resolve the wire name through the serialiser options and cover it with a renamed-property test.

Nits: an IValidatableObject cross-field failure lands under key ""; a refusal logs no field names.

The flagged gaps (per-group opt-in, shallow TryValidateObject, __EndpointRouteBuilder) are acceptable as recorded.

Verdict: changes required

1. **A second 400 shape the ADR does not admit.** `POST {"name":"ok","latitude":"nope"}` returns 400 with no body, no `Content-Type` and no `traceId` — minimal-API binding short-circuits before any filter, since `RouteHandlerOptions.ThrowOnBadRequest` is false. ADR-0025's "one 400, one place that writes it" is therefore false for the commonest malformed request. Either set `ThrowOnBadRequest` so it reaches `ApiExceptionHandler`, or amend the ADR and raise a follow-up. 2. **`errors` keys are camel-cased CLR names, not what the caller sent.** With `[JsonPropertyName("place_name")]` a caller sending `place_name` gets `"name"` back. `RequestValidator`'s own remark admits this; the ADR and PR body assert the opposite. Resolve the wire name through the serialiser options and cover it with a renamed-property test. Nits: an `IValidatableObject` cross-field failure lands under key `""`; a refusal logs no field names. The flagged gaps (per-group opt-in, shallow `TryValidateObject`, `__EndpointRouteBuilder`) are acceptable as recorded. Verdict: changes required
Route binding failures through the handler, and key errors by wire name
All checks were successful
CI / build (pull_request) Successful in 49s
21b9dca8cf
Two things the record claimed were not true of the code.

A body the framework cannot bind — {"latitude":"nope"} — was answered with
a bare 400: no body, no Content-Type, no traceId, and nothing in the log
but the request line. Minimal API binding short-circuits before any
endpoint filter, so ADR-0025's "one 400, one place that writes it" was
false for the commonest malformed request there is. Worse,
RouteHandlerOptions.ThrowOnBadRequest defaults to IsDevelopment(), so the
shape differed between environments — the defect ADR-0022 exists to
prevent. Program.cs now sets it in every environment, and
ApiExceptionHandler claims BadHttpRequestException alongside ApiException:
the exception's own status, and a constant detail. The framework's message
names the parameter and the type behind it, so it goes to the log rather
than to the caller, and the exception joins ApiException on
SuppressDiagnosticsCallback — a caller sending an unreadable body is not
the server failing, and could otherwise fill the log with Error records at
will.

An unsupported Content-Type is still a bare 415, measured: the media-type
check short-circuits without throwing, so there is nothing for the handler
to claim. It is recorded under what this does not cover rather than
glossed, with a follow-up written up on ticket #59.

The errors keys were camel-cased CLR names, not the names the caller sent:
a contract with [JsonPropertyName("place_name")] answered a caller who
sent place_name with "name", which is unmatchable. The wire name is now
asked of the serialiser — JsonSerializerOptions.GetTypeInfo, from the same
options the request was bound with — so the naming policy, JsonPropertyName
and any custom resolver all reach the answer by one route. The example
contract renames a property so the pipeline tests exercise it end to end.

Also, from the review's non-blocking notes: a cross-field failure from
IValidatableObject is keyed by the empty string, which is where ASP.NET
Core's own model state puts a message about the request rather than one of
its fields, and it now has a test; and a refusal logs the field names at
Information, so a support question about someone else's 400 is answerable
from the records. The names come from the contract, not from the request,
so nothing the caller sent is written down.
Author
Owner

Actioned at 21b9dca. Both findings fixed rather than caveated, both nits fixed, description corrected. Detail is in ADR-0025; the remaining 415 boundary is task 138.

Actioned at `21b9dca`. Both findings fixed rather than caveated, both nits fixed, description corrected. Detail is in ADR-0025; the remaining 415 boundary is task 138.
Author
Owner
  1. 413 does not come out of the handler. Under real Kestrel with MaxRequestBodySize exceeded — content-length and chunked alike — the answer is a bare 413 with Content-Length: 0, no Content-Type, no traceId and no ApiExceptionHandler record; a probe middleware inside UseExceptionHandler sees no exception at all, because the binder sets the status and short-circuits exactly as the 415 does. So ApiExceptionHandler.Describe's "413 for one that is too large — so its choice is kept" and ADR-0025's "413 for one too large" are both false, and 413 is a third bare-status shape the record does not name while claiming the exceptions are named rather than glossed. Drop the claim from both, and put 413 on task 138 beside the 415.

  2. The binder's reason does not reach the log. The record carries only Failed to read parameter "ExampleRequest request" from the request body as JSON. The JsonException inner — the part that says Path: $.latitude — is dropped, and ADR-0022 suppresses the middleware's Error record, so nothing anywhere says which field failed. That is precisely the support question the record uses to justify logging field names on the validation path, and the response withholds on the promise the log has it. Pass the exception to LogUnreadableRequest; its inner message names the JSON path, not the value.

  3. A BadHttpRequestException carrying a 5xx — public constructor, so application code can produce one — is answered 500 with detail: "The request could not be read." and logged only at Information with no stack trace. Gate both the branch and the suppression predicate on a 4xx status so a 5xx falls through to the generic 500 and is logged as a fault.

Nit: ToWireName's remark says a member the contract marks JsonIgnore "is reported as the rule named it" — it is not; such properties are still in JsonTypeInfo.Properties, so [JsonIgnore] string SecretValue keys as secretValue. The ?? memberName fallback fires only for a name that is not a property at all, and nothing tests it.

Verdict: changes required

1. **`413` does not come out of the handler.** Under real Kestrel with `MaxRequestBodySize` exceeded — content-length and chunked alike — the answer is a bare `413` with `Content-Length: 0`, no `Content-Type`, no `traceId` and no `ApiExceptionHandler` record; a probe middleware inside `UseExceptionHandler` sees no exception at all, because the binder sets the status and short-circuits exactly as the 415 does. So `ApiExceptionHandler.Describe`'s "413 for one that is too large — so its choice is kept" and ADR-0025's "`413` for one too large" are both false, and 413 is a third bare-status shape the record does not name while claiming the exceptions are named rather than glossed. Drop the claim from both, and put 413 on task 138 beside the 415. 2. **The binder's reason does not reach the log.** The record carries only `Failed to read parameter "ExampleRequest request" from the request body as JSON.` The `JsonException` inner — the part that says `Path: $.latitude` — is dropped, and ADR-0022 suppresses the middleware's Error record, so nothing anywhere says which field failed. That is precisely the support question the record uses to justify logging field names on the validation path, and the response withholds on the promise the log has it. Pass the exception to `LogUnreadableRequest`; its inner message names the JSON path, not the value. 3. A `BadHttpRequestException` carrying a 5xx — public constructor, so application code can produce one — is answered `500` with `detail: "The request could not be read."` and logged only at `Information` with no stack trace. Gate both the branch and the suppression predicate on a 4xx status so a 5xx falls through to the generic 500 and is logged as a fault. Nit: `ToWireName`'s remark says a member the contract marks `JsonIgnore` "is reported as the rule named it" — it is not; such properties are still in `JsonTypeInfo.Properties`, so `[JsonIgnore] string SecretValue` keys as `secretValue`. The `?? memberName` fallback fires only for a name that is not a property at all, and nothing tests it. Verdict: changes required
Correct the 413 claim, log the binding reason, and gate on 4xx
All checks were successful
CI / build (pull_request) Successful in 47s
7e040e9839
The 413 in the previous commit was asserted, not measured, and it is
wrong. Under real Kestrel, a body over MaxRequestBodySize — declared
Content-Length and chunked alike — is answered with a bare 413,
Content-Length: 0 and Connection: close, and a probe middleware placed
inside UseExceptionHandler sees no exception at all: the status is set and
the request short-circuited, exactly as the 415 does. So 413 never reaches
ApiExceptionHandler, it is reachable at the 30 MB default, and it belongs
beside the 415 as a shape this record does not produce. Both the code
comment and the ADR now say so, and ticket #138 holds the decision.

The binder's reason did not reach the log either. LogUnreadableRequest
took only the outer message, which names the parameter; the inner
JsonException — the part that says Path: $.latitude — was dropped, and
ADR-0022 suppresses the middleware's own report, so nothing anywhere
recorded which field failed to bind. That is the support question the
record uses to justify logging field names on the validation path, and the
response withholds the detail on the promise that the log has it. The
exception itself is passed to the record now.

A BadHttpRequestException carrying a 5xx is no longer swallowed. The
constructor is public and takes any status, so application code can
produce one; it was answered 500 with "The request could not be read." and
logged at Information with no stack trace. Both the handler branch and the
suppression predicate are gated on a 4xx, so a 5xx falls through to the
generic 500 and is logged as the fault it is. No framework path produces
one today — this is a guard, not a branch anything reaches.

Also, ToWireName's remark claimed a JsonIgnore member reaches the fallback.
Measured false: such a property stays in JsonTypeInfo.Properties, with a
null accessor, so a rule on it is reported under the name it would have
travelled under. The remark says what happens, and both that and the real
fallback — a member name that is no property at all — now have tests.
Author
Owner

Actioned at 7e040e9. All three fixed and the nit corrected; the 413 measurement reproduced independently under Kestrel, and 413 is now recorded beside the 415 against task 138.

Actioned at `7e040e9`. All three fixed and the nit corrected; the 413 measurement reproduced independently under Kestrel, and 413 is now recorded beside the 415 against task 138.
Author
Owner
  1. A fourth case the narrowed claim still misses, and it is a caller-triggerable 500. Content-Type: application/json; charset=zzz on /tests/validated throws InvalidOperationException: Unable to read the request as JSON because the request content type charset 'zzz' is not a known encoding out of the same ReadFromJsonAsync. ApiExceptionHandler declines it, so one malformed header token from an unauthenticated client is answered 500 and logged at Error with a stack trace — the log-flooding vector the suppression rationale two paragraphs above disowns. It reaches the application as an exception and is not written by ApiExceptionHandler, so "every failure that reaches the application as an exception is written by ApiExceptionHandler" is still false. Name it beside the 415 and the 413, and put it on #138.

  2. The inner JsonException carries payload text, not only the path. {"place_name":"ok","latitude": nope} logs 'nope}' is an invalid JSON literal, and a body of notjson-SECRETVALUE logs that token in full — Utf8JsonReader quotes the offending literal verbatim whenever it starts n, t or f, and the first byte otherwise. That falsifies "nothing the caller sent is written down" in the consequence this commit extended, and cuts against Program.cs's stated reason for keeping bodies out of the log. Log (unreadableRequest.InnerException as JsonException)?.Path, which is the thing the record actually asks for, rather than the whole exception.

Verdict: changes required

1. **A fourth case the narrowed claim still misses, and it is a caller-triggerable 500.** `Content-Type: application/json; charset=zzz` on `/tests/validated` throws `InvalidOperationException: Unable to read the request as JSON because the request content type charset 'zzz' is not a known encoding` out of the same `ReadFromJsonAsync`. `ApiExceptionHandler` declines it, so one malformed header token from an unauthenticated client is answered `500` and logged at `Error` with a stack trace — the log-flooding vector the suppression rationale two paragraphs above disowns. It reaches the application as an exception and is not written by `ApiExceptionHandler`, so "every failure that reaches the application as an exception is written by `ApiExceptionHandler`" is still false. Name it beside the 415 and the 413, and put it on #138. 2. **The inner `JsonException` carries payload text, not only the path.** `{"place_name":"ok","latitude": nope}` logs `'nope}' is an invalid JSON literal`, and a body of `notjson-SECRETVALUE` logs that token in full — `Utf8JsonReader` quotes the offending literal verbatim whenever it starts `n`, `t` or `f`, and the first byte otherwise. That falsifies "nothing the caller sent is written down" in the consequence this commit extended, and cuts against `Program.cs`'s stated reason for keeping bodies out of the log. Log `(unreadableRequest.InnerException as JsonException)?.Path`, which is the thing the record actually asks for, rather than the whole exception. Verdict: changes required
Claim the unreadable-charset refusal, and log paths rather than payloads
All checks were successful
CI / build (pull_request) Successful in 45s
ccfaf144a1
Content-Type: application/json; charset=zzz throws a bare
InvalidOperationException out of the same ReadFromJsonAsync call the rest
of this work is about — measured, from an unremarkable request. The
handler declined it, so one malformed header token from an
unauthenticated caller was answered 500 and logged at Error with a stack
trace, repeatable at will. That is the log-flooding vector
SuppressDiagnosticsCallback exists to close, left open because the
exception had the wrong shape rather than because anyone decided it.

It is claimed and answered 400. Unlike the 413 and the 415 this one does
reach the application as an exception, so there is something to claim.
Matching InvalidOperationException is the risk, and the branch narrows it
by testing the request rather than the exception: it fires only when
MediaTypeHeaderValue.Encoding cannot resolve the declared charset, which
is the same property the framework resolves it through, so the two agree
by construction rather than by matching a message a reword or a
non-English culture would break. It is safe because the charset is read
before a byte of the body is: no endpoint and no filter has run, so no
application code has had the chance to fail. A framework or container
fault landing on the same request would be answered 400 rather than 500,
and that request was unanswerable anyway.

Which exceptions are refusals is now stated once, as
ApiExceptionHandler.RefusalStatusCode, and Program.cs's suppression
predicate asks that method instead of restating it. The previous two
copies were written to agree; a copy free to drift fails silently both
ways.

The log record no longer carries the binder's exception. Its inner
JsonException quotes the text it choked on — 'nope}' is an invalid JSON
literal, and for a body that is not JSON at all the whole first token, so
notjson-SECRETVALUE was written out in full. That put a fragment of the
request body in the log, which is the one thing AddHttpLogging is
configured to avoid, and falsified the consequence claiming nothing the
caller sent is written down. JsonException.Path is logged instead: the
part that answers "which field?", and a position in the document rather
than anything in it. The charset failure logs nothing from the request at
all. Four tests cover the leak and the new refusal; all four fail against
the previous commit.
Author
Owner

Actioned at ccfaf14. The charset case is claimed and answered 400 rather than ticketed — matched on the request's unresolvable charset, not on the exception type — and the record now logs JsonException.Path instead of the exception, so no payload text reaches the log.

Actioned at `ccfaf14`. The charset case is claimed and answered 400 rather than ticketed — matched on the request's unresolvable charset, not on the exception type — and the record now logs `JsonException.Path` instead of the exception, so no payload text reaches the log.
Author
Owner
  1. The charset branch masks application faults, and the safety argument does not hold. "No application code has had the chance to fail" is only true of an endpoint that binds a JSON body; one that does not never inspects the charset, so the header sits inert until the branch reads it after the fault. Measured on POST /tests/leave mapped as () => Array.Empty<int>().Single() — a bodyless write endpoint, the shape of "leave this group" or "accept invitation", faulting the way Dapper and LINQ fault:

    • Content-Type: application/json500, Error with the exception.
    • Content-Type: application/json; charset=zzz400 "The request could not be read.", Information, no Error record at all.

    One header token on any request carrying a body turns every InvalidOperationException in the API into a client refusal and deletes it from the error log — the inverse of the vector this branch was added to close. Send_InvalidOperationExceptionFromAnOrdinaryRequest_IsStillAFault passes only because it sends no Content-Type. Reject the unresolvable charset before routing — a few lines of middleware, or the existing filter — so nothing has to be inferred from an exception after the fact and no fault can be reached by the branch at all.

  2. The closing claim is still overstated: a caller whose Accept rules out JSON gets the status and an empty body, which Post_UnbindableRequestFromAClientRefusingJson_RespondsWith400AndAnEmptyBody asserts. "Answered with a Problem Details body" needs that carve-out, which ADR-0022 already names.

  3. "Nothing that could be a value reaches the log by this route" is unconditional, but JsonException.Path puts a caller-chosen dictionary key in it — $.tags.attackerKey, measured on a Dictionary<string, double> property. No v1 contract has one; the sentence needs the qualifier rather than the absolute.

Verdict: changes required

1. **The charset branch masks application faults, and the safety argument does not hold.** "No application code has had the chance to fail" is only true of an endpoint that binds a JSON body; one that does not never inspects the charset, so the header sits inert until the branch reads it after the fault. Measured on `POST /tests/leave` mapped as `() => Array.Empty<int>().Single()` — a bodyless write endpoint, the shape of "leave this group" or "accept invitation", faulting the way Dapper and LINQ fault: - `Content-Type: application/json` → `500`, `Error` with the exception. - `Content-Type: application/json; charset=zzz` → `400 "The request could not be read."`, `Information`, **no `Error` record at all**. One header token on any request carrying a body turns every `InvalidOperationException` in the API into a client refusal and deletes it from the error log — the inverse of the vector this branch was added to close. `Send_InvalidOperationExceptionFromAnOrdinaryRequest_IsStillAFault` passes only because it sends no `Content-Type`. Reject the unresolvable charset *before* routing — a few lines of middleware, or the existing filter — so nothing has to be inferred from an exception after the fact and no fault can be reached by the branch at all. 2. The closing claim is still overstated: a caller whose `Accept` rules out JSON gets the status and an empty body, which `Post_UnbindableRequestFromAClientRefusingJson_RespondsWith400AndAnEmptyBody` asserts. "Answered with a Problem Details body" needs that carve-out, which ADR-0022 already names. 3. "Nothing that could be a **value** reaches the log by this route" is unconditional, but `JsonException.Path` puts a caller-chosen dictionary key in it — `$.tags.attackerKey`, measured on a `Dictionary<string, double>` property. No v1 contract has one; the sentence needs the qualifier rather than the absolute. Verdict: changes required
# Conflicts:
#	docs/adr/README.md
Refuse an unreadable charset before routing, not after the exception
All checks were successful
CI / build (pull_request) Successful in 51s
98920294fd
The charset branch added last round masks application faults, and the
argument that justified it was wrong. "The charset is read before a byte
of the body" is a property of endpoints that bind a JSON body, not of
requests. An endpoint taking no body — leave a group, accept an invitation
— never inspects Content-Type, so the header sits inert, the endpoint runs
and can fault on its own, and the branch then reads the header after the
fact. Measured on a bodyless endpoint failing the way a Single() over an
empty result set does:

    application/json               → 500, Error with the exception
    application/json; charset=zzz  → 400 "could not be read",
                                     Information, no error record at all

So one header token on any request turned every InvalidOperationException
in the API into a client refusal and erased it from the error log, which
is the inverse of the vector the branch was added to close. The previous
test missed it only because it sent no Content-Type.

The fix is structural. Any decision taken after the exception has to guess
whether the framework or the application threw it, and
InvalidOperationException carries nothing to guess with, so the guess is
removed rather than improved. RequestCharsetMiddleware refuses the header
before routing, throwing ValidationFailedException like any other refusal,
and RefusalStatusCode has no InvalidOperationException arm and no
HttpContext parameter — it cannot consult the request even by accident.
Refusing on the way in also answers better than the branch could: a detail
naming the header the caller can fix, rather than the constant "The
request could not be read."

Two tests hold it: the bodyless endpoint is not reached when the charset
is unreadable, asserted through the endpoint rather than the status code
because a fault dressed as a refusal looks identical from outside; and the
same endpoint on an ordinary request still faults, answers 500 and is
logged as an error.

Two overstatements in the ADR go with it. "Answered with a Problem Details
body" now carries ADR-0022's carve-out, since a caller whose Accept rules
out JSON gets the status and an empty body. "Nothing that could be a value
reaches the log" is now qualified by the contracts v1 has: a
Dictionary<string, T> property would put a caller-chosen key in
JsonException.Path.
Author
Owner

Actioned at 9892029 (main merged in, ADRs back in numeric order). Reproduced the masking on a bodyless endpoint, then removed the guess rather than improving it: RequestCharsetMiddleware refuses the header before routing, and RefusalStatusCode no longer takes an HttpContext at all. Both ADR overstatements corrected.

Actioned at `9892029` (`main` merged in, ADRs back in numeric order). Reproduced the masking on a bodyless endpoint, then removed the guess rather than improving it: `RequestCharsetMiddleware` refuses the header before routing, and `RefusalStatusCode` no longer takes an `HttpContext` at all. Both ADR overstatements corrected.
Author
Owner

Nothing to act on.

Verdict: mergeable

Nothing to act on. Verdict: mergeable
rob merged commit bccccc1f82 into main 2026-08-03 16:42:50 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!21
No description provided.