Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1f1b7d1
build: a probe that runs nothing under src/ is recorded by what it wa…
CaffeinatedCoder Oct 18, 2026
76b17d2
test: the document obligations do not apply under Preserve, and rows …
CaffeinatedCoder Oct 18, 2026
9b6f58e
docs: C21 does not promise the twin's schema where a nullable value o…
CaffeinatedCoder Oct 18, 2026
7b0edf0
test: C21's schema comparison cites KB-001 for a nullable body's form…
CaffeinatedCoder Oct 18, 2026
ed1069b
test: C17's and C19's rows written again, without the refused kinds t…
CaffeinatedCoder Oct 18, 2026
dfc841a
test: L02's parsing obligations do not apply to TryFrom or formatting…
CaffeinatedCoder Oct 18, 2026
689fed9
test: E02 compares an enum with >= as C# does, excludes >= over strin…
CaffeinatedCoder Oct 18, 2026
53050b7
test: rows and exclusions for the scattered pairs of C02, C03, C05, L…
CaffeinatedCoder Oct 18, 2026
4675d2e
test: KB-042, ASP.NET refuses a char's schema under WriteAsString, an…
CaffeinatedCoder Oct 18, 2026
82756b9
docs: C21 does not promise the twin's schema for an enum component's …
CaffeinatedCoder Oct 18, 2026
2fab368
test: C21's schema comparison cites KB-026 after the null normalisati…
CaffeinatedCoder Oct 18, 2026
ec7a4f9
test: C21 compares schemas under the media types both bodies list whe…
CaffeinatedCoder Oct 18, 2026
7565175
test: rows for C21's last uncovered pairs, and KB-026 told where the …
CaffeinatedCoder Oct 18, 2026
562c367
test: C21's obligations do not apply to a wrapped record class under …
CaffeinatedCoder Oct 18, 2026
60de568
docs: the roadmap's 2.2.0 gate item is done
CaffeinatedCoder Oct 18, 2026
cfe942a
Merge branch 'release/2.2.0' into build/gate-closes
CaffeinatedCoder Oct 18, 2026
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,30 @@
---
verified: 2026-10-18
pins:
- Microsoft.AspNetCore.OpenApi 10.0.12
- sdk 10.0.4xx
source: the document Microsoft.AspNetCore.OpenApi writes for a minimal API whose body holds a char, and one whose body holds an int, under JsonNumberHandling.WriteAsString and the default number handling, measured in a host without TypeKit; ASP.NET Core 10's OpenApiSchemaService builds each schema with System.Text.Json's JsonSchemaExporter under the host's options and reads the node back as its own OpenApiJsonSchema, and a char's schema is the exporter's minLength and maxLength of 1
turned-on: row 408 of C21OpenapiSchemaParityProbe (claim C21-openapi-schema-parity), under json.NumberHandling=WriteAsString with json.MaxDepth=3, where AddTypeKit() refused the document with this JsonException, asking for the char a value object wraps, and ASP.NET alone with MaxDepth's InvalidOperationException, met first at another endpoint
test: test/CodoMetis.TypeKit.Facts/KB042.cs
fixture: test/CodoMetis.TypeKit.Facts/KB042.fixture.json
---

# KB-042. Under `NumberHandling` with `WriteAsString`, ASP.NET refuses the OpenAPI document of an endpoint whose schema holds a `char`

ASP.NET builds a schema with System.Text.Json's schema exporter, under the host's JSON options, and reads the node
back as its own `OpenApiJsonSchema`. A `char`'s schema is a string of `minLength` and `maxLength` 1. With
`JsonSerializerOptions.NumberHandling` set to `WriteAsString`, those two numbers reach the reader as strings, and
generating the document fails with a `JsonException`, "The JSON value could not be converted to OpenApiJsonSchema",
whose inner exception cannot read a string token as a number. A host without TypeKit does so for any endpoint
whose body holds a `char`. A number's schema is written under the same option, and a `char`'s under the default
number handling.

| A minimal API's body of `Holder<T>` | `NumberHandling = WriteAsString` | the default number handling |
|---|---|---|
| `T` is `char` | `JsonException`, the document is not written | `{"maxLength":1,"minLength":1,"type":"string","format":"char"}` |
| `T` is `int` | `{"pattern":"^-?(?:0\|[1-9]\\d*)$","type":["integer","string"],"format":"int32"}` | (not measured) |

A value object over `char` is described with that same schema, asked of ASP.NET, so under this option its document
is refused as the wrapped type's is. Which of two refusals a document meets first follows the order ASP.NET visits
its endpoints and schemas in, so under a second refusing option (`MaxDepth`) a host with TypeKit, which asks for the
`char` while it describes the value object, can meet this one where ASP.NET alone meets the other.
1 change: 1 addition & 0 deletions design/knowledge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,4 @@ test and later for the harness's `Baseline.Fact`.
| [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` |
| [KB-042](KB-042-a-char-schema-is-refused-under-write-as-string.md) | Under `NumberHandling` with `WriteAsString`, ASP.NET refuses the OpenAPI document of an endpoint whose schema holds a `char` | OpenApi 10.0.12, SDK 10.0.4xx | `KB042` |
6 changes: 1 addition & 5 deletions design/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,8 @@ refusal may relax in any minor version; an Error that reports more waits for a m
([decision 97](adr/0097-2-2-0-is-a-consolidation-release-and-the-gate-binds.md)). Any gap below that is a
defect, a cost or a missing guard may land here; one that adds surface waits for 2.3.0.

- Grow the probe corpus until `prober gate` passes on probes and reviewed waivers alone. Every claim
touched since v2.1.0 is covered pairwise, and no finding is open.
- The probing harness (WS2 of the agentic issue-finding concept) is done
([decision 108](adr/0108-a-probe-check-earns-credit-against-a-baseline-and-ws2-closes.md)). What it leaves the gate: the pairs of C12, C13, C16 and C21 that only a document
ASP.NET alone refuses had credited (a declaration of the document obligations' inapplicability under
`json.ReferenceHandler=Preserve` and `json.MaxDepth=3`, and rows for the rest), and C22's empty footprint.
([decision 108](adr/0108-a-probe-check-earns-credit-against-a-baseline-and-ws2-closes.md)).
WS3 (beyond pairs), WS4 (the mutation nightly), the second half of WS5 (the finder benchmark at equal budgets) and
WS6 (PostgreSQL in CI) were dropped on 2026-10-17 by the maintainer's decision, and are not scheduled.
- The generated-surface snapshot under "Generators" below.
Expand Down
8 changes: 5 additions & 3 deletions src/CodoMetis.TypeKit.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ element schema, so `Quantity` gets no component at all, and both parameters are

OpenAPI 2.0 can't put null around a reference. ASP.NET writes a nullable property of any component as
`{"allOf":[{"x-nullable":true}]}` there, without the reference. So under 2.0 a nullable value-object
property loses its type, where the underlying type's nullable property keeps it. Inline value objects
if your 2.0 document needs it.
property loses its type, where the underlying type's nullable property keeps it. A nullable element of
a list or a dictionary loses its type the same way, since it's the component or null too. Inline value
objects if your 2.0 document needs it.

- **Put rules in `Create`, not on the property.** A validation attribute on a value-object property
(`[MaxLength]`, `[Range]`) ends up in the value object's shared component, as ASP.NET does for any
Expand Down Expand Up @@ -160,7 +161,8 @@ source-generated JSON can do, because the patch's converter hides the value obje

**A described type.** ASP.NET can't see through a JSON converter you write yourself. It writes the
type as `{}`, or with only the `format` its CLR type maps to. It also drops a list's `items` and a
dictionary's `additionalProperties` of that type.
dictionary's `additionalProperties` of that type. The same goes for a converter a library registers for
its own type, such as NodaTime's for `Instant`.

Two things fix that, and the document then describes the type as they say:

Expand Down
72 changes: 72 additions & 0 deletions test/CodoMetis.TypeKit.Facts/KB042.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace CodoMetis.TypeKit.Facts;

/// <summary>
/// design/knowledge/KB-042: under <c>JsonSerializerOptions.NumberHandling</c> with <c>WriteAsString</c>, ASP.NET refuses the OpenAPI document of
/// an endpoint whose schema holds a <c>char</c>, with a <c>JsonException</c> reading the schema back as its <c>OpenApiJsonSchema</c>: the
/// exporter's <c>minLength</c> and <c>maxLength</c> of a <c>char</c> are written as strings. A number's schema is written, and so is a
/// <c>char</c>'s under the default number handling.
/// </summary>
public sealed class KB042
{
public sealed class Holder<T>
{
public T P { get; set; } = default!;
}

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

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

/// <summary>The document of a minimal API whose one endpoint takes a body of <c>Holder&lt;T&gt;</c> for the answer's type.</summary>
private static async Task<JsonNode> DocumentAsync(IReadOnlyDictionary<string, string> when)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = Environments.Production });
builder.WebHost.UseTestServer();
builder.Logging.ClearProviders();
builder.Services.ConfigureHttpJsonOptions(options =>
{
if (when["json.NumberHandling"] == "WriteAsString") options.SerializerOptions.NumberHandling = JsonNumberHandling.WriteAsString;
});
builder.Services.AddOpenApi();

await using var app = builder.Build();
_ = when["shape.wrapped"] switch
{
"char" => app.MapPost("/kb042", ([FromBody] Holder<char> body) => Results.Ok()),
"int" => app.MapPost("/kb042", ([FromBody] Holder<int> body) => Results.Ok()),
var other => throw new ArgumentOutOfRangeException(nameof(when), other, "the fixture's types are char and int"),
};
app.MapOpenApi();
await app.StartAsync(TestContext.Current.CancellationToken);

var text = await app.GetTestClient().GetStringAsync("/openapi/v1.json", TestContext.Current.CancellationToken);
return JsonNode.Parse(text)!;
}

/// <summary>Each answer of KB042.fixture.json: the document of a body holding the answer's type, under the number handling it names.</summary>
[Theory, MemberData(nameof(Answers))]
public async Task ASP_NET_writes_or_refuses_the_document_as_the_fixture_holds(string id)
{
var answer = Fixture[id];

if (answer.Kind == FactAnswer.Exception)
{
var thrown = await Should.ThrowAsync<Exception>(() => DocumentAsync(answer.When));
answer.Mismatch(thrown).ShouldBeNull();
}
else
{
answer.Mismatch(await DocumentAsync(answer.When)).ShouldBeNull();
}
}
}
54 changes: 54 additions & 0 deletions test/CodoMetis.TypeKit.Facts/KB042.fixture.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"fact": "KB-042",
"answers": [
{
"id": "char-under-write-as-string",
"kind": "exception",
"when": {
"json.NumberHandling": "WriteAsString",
"shape.wrapped": "char"
},
"value": {
"type": "System.Text.Json.JsonException",
"operation": "document",
"stage": "reading the char's schema back as ASP.NET's OpenApiJsonSchema",
"message": "could not be converted to OpenApiJsonSchema"
},
"note": "a minimal API's body holding a char, under NumberHandling WriteAsString: the document is refused"
},
{
"id": "char-by-default",
"kind": "document",
"when": {
"json.NumberHandling": "default",
"shape.wrapped": "char"
},
"at": "/components/schemas/HolderOfchar/properties/p",
"value": {
"maxLength": 1,
"minLength": 1,
"type": "string",
"format": "char"
},
"note": "the same body under the default number handling: written"
},
{
"id": "int-under-write-as-string",
"kind": "document",
"when": {
"json.NumberHandling": "WriteAsString",
"shape.wrapped": "int"
},
"at": "/components/schemas/HolderOfint/properties/p",
"value": {
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int32"
},
"note": "a body holding an int under NumberHandling WriteAsString: written"
}
]
}
41 changes: 41 additions & 0 deletions test/CodoMetis.TypeKit.Prober.Tests/RowsPerProbeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,47 @@ public void A_change_to_a_watched_path_changes_the_footprint_since_a_release()
ledger.FootprintChangedSince("P", "v1", ledger.Footprints()).ShouldBeTrue();
}

/// <summary>A replay's record of a probe that runs nothing under src/ in its own process, as C22, which builds its consumer in a child process.</summary>
private (int Exit, string Output, string Error) RanNothingUnderSrc()
{
var report = Path.Combine(fx.Root, "coverage.cobertura.xml");
File.WriteAllText(report, $"""<coverage><sources><source>{fx.Root}</source></sources><packages><package><classes><class filename="test/Harness.cs"><lines><line number="1" hits="1" /></lines></class></classes></package></packages></coverage>""");
var result = fx.Ledger("footprint", "--probe", "P", "--root", fx.Root, "--cobertura", report);
File.Delete(report);
return result;
}

[Fact]
public void A_probe_that_ran_nothing_under_src_is_recorded_by_what_it_watches()
{
Registry(watched: true);
fx.Write(Catalogue, "class Catalogue {}\n");
fx.Commit("a probe watching a catalogue, building in a child process");
var (exit, output, error) = RanNothingUnderSrc();
exit.ShouldBe(0, output + error);
var line = JsonNode.Parse(File.ReadLines(Path.Combine(fx.BranchDir, "footprints.jsonl")).Last())!;
line["footprint"]!.AsObject().Count.ShouldBe(0);
line["watched"]![Catalogue]!.GetValue<string>().ShouldBe(fx.Sh("git", "rev-parse", $"HEAD:{Catalogue}").Trim());
fx.Ledger("record-run", "--probe", "P", "--result", "pass", "--evidence", fx.Evidence([R("1", "1"), R("2", "2")])).Exit.ShouldBe(0);
Stale().ShouldBeFalse("what P watches is unchanged");
fx.Covered().ShouldBe(2);
ChangeTheCatalogue();
Stale().ShouldBeTrue("the catalogue P watches changed");
fx.Covered().ShouldBe(0);
}

[Fact]
public void A_probe_that_ran_nothing_under_src_and_watches_nothing_is_refused()
{
Registry(watched: false);
fx.Write(Catalogue, "class Catalogue {}\n");
fx.Commit("a probe watching nothing, building in a child process");
var (exit, output, error) = RanNothingUnderSrc();
exit.ShouldBe(1, output + error);
(output + error).ShouldContain("refusing to record an empty footprint");
File.Exists(Path.Combine(fx.BranchDir, "footprints.jsonl")).ShouldBeFalse();
}

[Theory]
[InlineData(true)]
[InlineData(false)]
Expand Down
10 changes: 7 additions & 3 deletions tools/Prober/Commands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,13 +352,17 @@ public static int Footprint(Ledger l, Args a)
}
}
}
if (hit.Count == 0)
throw new Refusal("the coverage report shows no executed file under " + string.Join(", ", include) + "; refusing to record an empty footprint (it would never go stale)");
// A probe whose own process runs nothing under the include paths (one that builds a consumer in a child process, as C22
// does) is recorded by what its registry entry watches; with nothing watched either, the footprint would never go stale.
var watchedPaths = l.Watched(a.Need("probe"));
if (hit.Count == 0 && watchedPaths.Count == 0)
throw new Refusal("the coverage report shows no executed file under " + string.Join(", ", include) +
" and the registry entry watches nothing; refusing to record an empty footprint (it would never go stale): name what the probe depends on in its entry's watched");
var rev = Git.Out(worktree, "rev-parse", "HEAD");
var footprint = new JsonObject(hit.Select(p => KeyValuePair.Create(p, (JsonNode?)Git.ObjectId(worktree, p))));
var compileTime = new JsonObject(a.All("compile-time").Select(p => KeyValuePair.Create(p, (JsonNode?)Git.ObjectId(worktree, p))));
// What the probe's registry entry watches beyond what ran: recorded with the ids it ran at, so a change stales it.
var watched = new JsonObject(l.Watched(a.Need("probe")).Select(p => KeyValuePair.Create(p, (JsonNode?)Git.ObjectId(worktree, p))));
var watched = new JsonObject(watchedPaths.Select(p => KeyValuePair.Create(p, (JsonNode?)Git.ObjectId(worktree, p))));
Files.AppendLine(l.P("footprints.jsonl"), new JsonObject
{
["schemaVersion"] = Files.SchemaVersion, ["probe"] = a.Need("probe"), ["commit"] = rev, ["footprint"] = footprint,
Expand Down
4 changes: 2 additions & 2 deletions tools/Prober/Ledger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,13 @@ public Dictionary<string, JsonObject> Footprints() =>
/// <summary>
/// A probe's footprint is current when every path it ran, every compile-time path, every path its registry entry
/// watches and the toolchain are unchanged at HEAD. A watched path the footprint did not record (one added to the
/// entry since) is not current.
/// entry since) is not current. A footprint that ran nothing under <c>src/</c> is current only by watched paths it recorded.
/// </summary>
public bool FootprintCurrent(string probe, Dictionary<string, JsonObject> footprints)
{
if (currentCache.TryGetValue(probe, out var cached)) return cached;
var ok = false;
if (footprints.TryGetValue(probe, out var r) && r["footprint"] is JsonObject fp && fp.Count > 0)
if (footprints.TryGetValue(probe, out var r) && r["footprint"] is JsonObject fp && (fp.Count > 0 || r["watched"] is JsonObject { Count: > 0 }))
{
var watched = r["watched"] as JsonObject ?? [];
ok = Watched(probe).All(watched.ContainsKey)
Expand Down
4 changes: 3 additions & 1 deletion tools/Prober/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ footprint cannot see, since the footprint is what ran in the probe's own process
over the analyzer tests' `TypeArgumentSites` and `TypeArgumentKinds`), a build input a child process reads is
another (C18's consumer build compiles against the base package and evaluates `src/Directory.Build.*`). The
replay records each with its object id in the footprint line, a change to one stales the footprint like a path
the probe ran, and the gate counts it as touching the probe's claim.
the probe ran, and the gate counts it as touching the probe's claim. A probe whose own process runs nothing under `src/`
(C22 builds its consumer in a child process) is recorded by what its entry watches alone; where nothing ran and nothing
is watched, `footprint` refuses to record an empty footprint, which would never go stale.

The round lock, `round.lock` and its mutex, sits in the branch's directory (`branches/<branch>/`), beside
everything a round or a replay writes, so rounds and replays of two branches run at once and two of one branch
Expand Down
Loading
Loading