Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
verified: 2026-10-17
pins:
- sdk 10.0.4xx
source: measured with System.Text.Json of the pinned SDK, a list of int nested in objects, written once by the serializer's own converter and once by a converter of the application's; the API reference of JsonSerializerOptions.MaxDepth says only that it is "the maximum depth allowed when serializing or deserializing JSON", nothing of converters
turned-on: the rows of BaseProbe, C10-preserve-guard, L01-json-bytes and L04-round-trip under json.MaxDepth=3 where a value object, which its converter writes, was refused at a depth its wrapped twin was written at; until WS2's three-sided differential these were told by the refusal's message (note stj-maxdepth), now by side (1), a converter of the application's for the wrapped type without TypeKit
test: test/CodoMetis.TypeKit.Facts/KB040.cs
fixture: test/CodoMetis.TypeKit.Facts/KB040.fixture.json
---

# KB-040. Under `MaxDepth`, System.Text.Json refuses a value a converter of one's own writes at the limit, where its own converter writes it

With `JsonSerializerOptions.MaxDepth = 3`, a list of `int` inside two objects, `{"P":{"P":[1]}}`, is written: the
writer is at depth 3 when it writes the element. Register a converter of your own for `int` that writes the same
number, and the same document is refused with a `JsonException` whose `Path` is the list's, `$.P.P`, before the
converter is asked to write anything. One level less deep, `{"P":[1]}`, the converter's value is written. Reading
is not charged: the converter reads the element of `{"P":{"P":[1]}}` under the same limit.

| The element of a `List<int>` at the writer's depth | the serializer's own converter | a converter of one's own |
|---|---|---|
| 2 (`{"P":[1]}`), `MaxDepth = 3` | written | written |
| 3 (`{"P":{"P":[1]}}`), `MaxDepth = 3` | written | `JsonException` at `$.P.P` |
| 3, read back | read | read |

A value object is written by its generated converter, so under a depth limit it is refused one level earlier than
its wrapped type, which the serializer's own converter writes, and the same holds for any converter an application
registers. The probes' side (1) for such a row is a converter of the application's for the wrapped type, without
TypeKit, or this fixture where it covers the row's limit and the depth the value is written at.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
verified: 2026-10-17
pins:
- sdk 10.0.4xx
source: measured in a host without TypeKit (Microsoft.AspNetCore.App 10.0 of the pinned SDK, an [ApiController] with an int route parameter, MVC's JSON options given a source-generated context listing int alone); ASP.NET Core's web API documentation, "Automatic HTTP 400 responses", names ValidationProblemDetails as the default type of that response
turned-on: rows 286 and 309 of BaseProbe (claim C07-fault-responses-blanket) under json.TypeInfoResolver=source-generated-context, where a value Create refuses answered 500 instead of MVC's 400; until WS2's three-sided differential told by the exception's words (note mvc-400-unwritable-under-source-gen), now by side (1), the wrapped twin sent a text it cannot read on a host without TypeKit
test: test/CodoMetis.TypeKit.Facts/KB041.cs
fixture: test/CodoMetis.TypeKit.Facts/KB041.fixture.json
---

# KB-041. MVC cannot write its automatic 400 under a source-generated context that does not list `ValidationProblemDetails`

An `[ApiController]` action whose parameter MVC cannot bind answers 400 with a `ValidationProblemDetails`, which
MVC writes with its own JSON options. Give those options a source-generated context that lists the action's types
but not `ValidationProblemDetails`, and the 400 is never written: the request fails with System.Text.Json's
`NotSupportedException`, which a test server passes to its client, for a parameter of any type. Under the
serializer's default resolver the same request is a 400.

| `GET /kb041/abc` to an action's `int` route parameter | MVC's JSON options' resolver |
|---|---|
| `NotSupportedException` | a source-generated context listing `int` alone |
| 400 | the serializer's default |

So under such a context a refused binding has no 400 to compare: a value object whose text its `Create` refuses
fails the request as the wrapped type does with a text it cannot read. The HTTP probes show it in the same row with
the twin on a host without TypeKit, or read this fixture where it covers the row.
2 changes: 2 additions & 0 deletions design/knowledge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,5 @@ test and later for the harness's `Baseline.Fact`.
| [KB-037](KB-037-mvc-under-max-validation-depth-1-runs-an-unbound-action.md) | Under `MaxValidationDepth = 1`, MVC runs an action whose parameter it could not bind, with the parameter at its default | SDK 10.0.4xx | `KB037` |
| [KB-038](KB-038-a-byte-array-is-a-base64-string.md) | System.Text.Json writes a `byte[]` as one base64 string, and every other collection as a JSON array | SDK 10.0.4xx | `KB038` |
| [KB-039](KB-039-sqlite-neither-compares-nor-orders-datetimeoffset-timespan-or-ulong.md) | EF Core's SQLite provider neither compares nor orders a `DateTimeOffset`, a `TimeSpan` or a `ulong`, and does both for a `decimal` | EF Core 10.0.12, its SQLite provider 10.0.12 | `KB039` |
| [KB-040](KB-040-a-converter-is-charged-a-level-of-max-depth.md) | Under `MaxDepth`, System.Text.Json refuses a value a converter of one's own writes at the limit, where its own converter writes it | SDK 10.0.4xx | `KB040` |
| [KB-041](KB-041-mvc-cannot-write-its-400-under-a-context-without-validation-problem-details.md) | MVC cannot write its automatic 400 under a source-generated context that does not list `ValidationProblemDetails` | SDK 10.0.4xx | `KB041` |
148 changes: 148 additions & 0 deletions test/CodoMetis.TypeKit.Conventions.Tests/ProbeMessageTextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace CodoMetis.TypeKit.Conventions.Tests;

/// <summary>
/// No probe decides how a row ends by the words of a message, except to fail it: in the probe corpus (harness and probes), a
/// <c>.Message.Contains(</c>, <c>StartsWith(</c>, <c>EndsWith(</c> or <c>IndexOf(</c> stands only in the condition of a failure text,
/// an <c>if</c> whose branch returns one or raises a harness error, or a <c>?:</c> between <c>null</c> and one. A refusal the framework
/// makes on its own is shown by side (1) of the three-sided differential (the harness's <c>Sides</c>), compared by its type and stage;
/// an intended difference by a citation. A message read to excuse a row, the substring excuse decision 99 retired, is refused here.
/// </summary>
/// <remarks>
/// It reads syntax, as <see cref="ProbeNoteKeyTests"/> does: a lambda inside the condition counts as the condition (<c>errors.Any(e =>
/// e.Message.StartsWith(...))</c>), an assignment, a <c>catch ... when</c> or a branch that passes does not. The EF family stays
/// two-sided in WS2's last step and is not changed by it: its one excuse by a message is listed in <see cref="Kept"/> with its reason,
/// and a listed site that no longer reads a message fails, so the list cannot outlive it.
/// </remarks>
public sealed class ProbeMessageTextTests
{
private static string Corpus => Path.Combine(Repository.Root, "tools", "probe", "corpus");

private static readonly string[] Reads = ["Contains", "StartsWith", "EndsWith", "IndexOf"];

/// <summary>The sites kept, by file, each with why: the EF family is two-sided by decision of WS2's last step.</summary>
private static readonly Dictionary<string, string> Kept = new()
{
["tools/probe/corpus/probes/E06OwnConverterNoValueProbe.cs"] =
"EF stays two-sided in WS2's last step: EF's 'could not be translated' refusal is matched by its words until the EF family gets its side (1)",
};

[Fact]
public void The_corpus_reads_of_a_message_are_found() =>
Sources().Sum(source => Sites(source.Text).Count()).ShouldBeGreaterThanOrEqualTo(30,
"the corpus read a message's words at 33 places when this rule was written (WS2's three-sided differential), all but the one EF site a failure's condition");

[Fact]
public void No_message_text_decides_a_row_but_a_failure() =>
Sources().Where(source => !Kept.ContainsKey(source.Path)).SelectMany(source => Problems(source.Path, source.Text)).ShouldBeEmpty();

[Fact]
public void Each_kept_site_still_reads_a_message_outside_a_failure() =>
Kept.Keys.Where(path => !Problems(path, File.ReadAllText(Path.Combine(Repository.Root, path))).Any()).ShouldBeEmpty();

[Fact]
public void A_message_read_to_excuse_a_row_is_found()
{
const string source =
"""
class P
{
string? Check(Check c, Exception x, Exception? y, string[] names)
{
if (x.Message.Contains("maximum allowed depth")) { c.Note(NoteKeys.StjMaxdepth); return null; }
if (!x.Message.Contains("Refused")) return $"does not name the fault: {x.Message}";
if (x.InnerException is { } i && i.Message.Contains("secret")) return "inner quotes the input";
if (!x.Message.StartsWith("V") || x.Message.EndsWith(".")) return "not named: " + x.Message;
if (names.Any(n => !x.Message.Contains(n))) { r.Outcome("harness-error", "not named"); return null; }
var named = x.Message.Contains("V");
try { throw x; } catch (Exception e) when (e.Message.Contains("could not be translated")) { return null; }
var missing = names.Where(n => !x.Message.Contains(n)).ToList();
return x.Message.Contains("V") ? null : $"not named: {x.Message}";
// x.Message.Contains("in a comment") is no call
}
}
""";
Problems("P.cs", source).ShouldBe(
[
"P.cs:5: x.Message.Contains(\"maximum allowed depth\") decides a row by a message's words outside a failure",
"P.cs:10: x.Message.Contains(\"V\") decides a row by a message's words outside a failure",
"P.cs:11: e.Message.Contains(\"could not be translated\") decides a row by a message's words outside a failure",
"P.cs:12: x.Message.Contains(n) decides a row by a message's words outside a failure",
]);
}

private static List<(string Path, string Text)> Sources() =>
[
.. Directory.EnumerateFiles(Corpus, "*.cs", SearchOption.AllDirectories)
.Order(StringComparer.Ordinal)
.Select(path => (Path.GetRelativePath(Repository.Root, path).Replace('\\', '/'), File.ReadAllText(path)))
];

/// <summary>Every <c>X.Message.Contains(...)</c> and its kin in a source.</summary>
private static IEnumerable<InvocationExpressionSyntax> Sites(string text) =>
CSharpSyntaxTree.ParseText(text).GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>()
.Where(call => call.Expression is MemberAccessExpressionSyntax
{
Name.Identifier.ValueText: var read,
Expression: MemberAccessExpressionSyntax { Name.Identifier.ValueText: "Message" },
} && Reads.Contains(read));

private static IEnumerable<string> Problems(string path, string text)
{
foreach (var call in Sites(text))
if (!InAFailuresCondition(call))
yield return $"{path}:{call.GetLocation().GetLineSpan().StartLinePosition.Line + 1}: {call} decides a row by a message's words outside a failure";
}

/// <summary>
/// The read stands in the condition of an <c>if</c> whose branch fails the row (returns a failure text, or raises a harness error), or
/// of a <c>?:</c> between <c>null</c> and a failure text. A lambda on the way up is part of that condition.
/// </summary>
private static bool InAFailuresCondition(SyntaxNode read)
{
for (var node = read; node.Parent is { } parent; node = parent)
{
switch (parent)
{
case IfStatementSyntax branch when branch.Condition == node:
return Fails(branch.Statement);
case ConditionalExpressionSyntax choice when choice.Condition == node:
return (IsNull(choice.WhenTrue) && IsFailure(choice.WhenFalse)) || (IsNull(choice.WhenFalse) && IsFailure(choice.WhenTrue));
case StatementSyntax:
case CatchFilterClauseSyntax:
return false;
}
}

return false;
}

/// <summary>A branch that fails the row: it returns a failure text, or raises a harness error, first.</summary>
private static bool Fails(StatementSyntax statement) => statement switch
{
ReturnStatementSyntax { Expression: { } returned } => IsFailure(returned),
BlockSyntax { Statements: [var first, ..] } => Fails(first),
ExpressionStatementSyntax { Expression: InvocationExpressionSyntax raise } => RaisesAHarnessError(raise),
_ => false,
};

private static bool RaisesAHarnessError(InvocationExpressionSyntax call) =>
call.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "Outcome" }
&& call.ArgumentList.Arguments is [{ Expression: LiteralExpressionSyntax { Token.ValueText: "harness-error" } }, ..];

/// <summary>A failure text: a string, an interpolated one, a concatenation with one, or a <c>?:</c> one of whose branches is one.</summary>
private static bool IsFailure(ExpressionSyntax expression) => expression switch
{
LiteralExpressionSyntax literal => literal.IsKind(SyntaxKind.StringLiteralExpression),
InterpolatedStringExpressionSyntax => true,
BinaryExpressionSyntax add when add.IsKind(SyntaxKind.AddExpression) => IsFailure(add.Left) || IsFailure(add.Right),
ConditionalExpressionSyntax choice => IsFailure(choice.WhenTrue) || IsFailure(choice.WhenFalse),
ParenthesizedExpressionSyntax inner => IsFailure(inner.Expression),
_ => false,
};

private static bool IsNull(ExpressionSyntax expression) => expression.IsKind(SyntaxKind.NullLiteralExpression);
}
73 changes: 73 additions & 0 deletions test/CodoMetis.TypeKit.Facts/KB040.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace CodoMetis.TypeKit.Facts;

/// <summary>
/// design/knowledge/KB-040: under <c>JsonSerializerOptions.MaxDepth</c>, System.Text.Json refuses a value that a converter of one's own
/// writes at the writer's depth equal to the limit, with a <c>JsonException</c> at that value, where its own converter for the same type
/// writes it; one level less deep, the converter's value is written. Reading is not charged: the converter reads its value at the limit.
/// </summary>
public sealed class KB040
{
public sealed class A<T>
{
public T P { get; set; } = default!;
}

/// <summary>A converter of the application's for <c>int</c>, which reads and writes the number as the serializer does.</summary>
public sealed class IntConverter : JsonConverter<int>
{
public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetInt32();

public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => writer.WriteNumberValue(value);
}

private static readonly FactFixture Fixture = FactFixture.Beside("KB-040");

public static TheoryData<string> Answers() => [.. Fixture.Answers.Select(answer => answer.Id)];

private static JsonSerializerOptions Options(IReadOnlyDictionary<string, string> when)
{
var options = new JsonSerializerOptions { MaxDepth = int.Parse(when["json.MaxDepth"], CultureInfo.InvariantCulture) };
if (when["written-by"] == "a-converter") options.Converters.Add(new IntConverter());
return options;
}

/// <summary>A list of one <c>int</c> whose element the writer is at the given depth for: inside the root object, then one more object per level.</summary>
private static object At(string depth) => depth switch
{
"2" => new A<List<int>> { P = [1] },
"3" => new A<A<List<int>>> { P = new() { P = [1] } },
_ => throw new ArgumentOutOfRangeException(nameof(depth), depth, "the fixture's depths are 2 and 3"),
};

/// <summary>Each answer of KB040.fixture.json: the element written by the converter or the serializer, at the depth the answer names.</summary>
[Theory, MemberData(nameof(Answers))]
public void The_serializer_writes_or_refuses_as_the_fixture_holds(string id)
{
var answer = Fixture[id];
var value = At(answer.When["writer-depth"]);

if (answer.Kind == FactAnswer.Exception)
{
var thrown = Should.Throw<Exception>(() => JsonSerializer.Serialize(value, value.GetType(), Options(answer.When)));
answer.Mismatch(thrown).ShouldBeNull();
((JsonException)thrown).Path.ShouldBe("$.P.P");
}
else
{
answer.Mismatch(JsonNode.Parse(JsonSerializer.Serialize(value, value.GetType(), Options(answer.When)))!).ShouldBeNull();
}
}

/// <summary>Reading is not charged: the converter reads the element at the limit, as the serializer's own does.</summary>
[Fact]
public void A_converter_reads_its_value_at_the_limit()
{
var options = Options(new Dictionary<string, string> { ["json.MaxDepth"] = "3", ["written-by"] = "a-converter" });

JsonSerializer.Deserialize<A<A<List<int>>>>("""{"P":{"P":[1]}}""", options)!.P.P.ShouldBe([1]);
}
}
44 changes: 44 additions & 0 deletions test/CodoMetis.TypeKit.Facts/KB040.fixture.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"fact": "KB-040",
"answers": [
{
"id": "a-converter-at-the-limit",
"kind": "exception",
"when": {
"json.MaxDepth": "3",
"written-by": "a-converter",
"writer-depth": "3"
},
"value": {
"type": "System.Text.Json.JsonException",
"operation": "write",
"stage": "the value the converter is handed, before it writes anything"
},
"note": "a list element at the writer's depth 3, written by a converter of the application's: refused, with the path of the element"
},
{
"id": "the-serializer-at-the-limit",
"kind": "document",
"when": {
"json.MaxDepth": "3",
"written-by": "the-serializer",
"writer-depth": "3"
},
"at": "/P/P",
"value": [1],
"note": "the same element, written by the serializer's own converter for int: written"
},
{
"id": "a-converter-below-the-limit",
"kind": "document",
"when": {
"json.MaxDepth": "3",
"written-by": "a-converter",
"writer-depth": "2"
},
"at": "/P",
"value": [1],
"note": "a list element at the writer's depth 2, written by a converter of the application's: written"
}
]
}
Loading
Loading