Skip to content

fix: a nullable value object that is a component keeps its null as a get-only property, a field and an oblivious one - #63

Merged
CaffeinatedCoder merged 3 commits into
release/2.2.0from
fix/component-keeps-null
Oct 18, 2026
Merged

CaffeinatedCoder merged 3 commits into
release/2.2.0from
fix/component-keeps-null

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Problem

C21OpenapiSchemaParityProbe found, and the code-analyst confirmed on 2026-10-18 by reproduction and a seeded fix, that a nullable value object written as a component lost its null in three positions where its wrapped twin kept it:

  • a get-only property bound by the constructor,
  • a field under IncludeFields,
  • a reference-type property in #nullable disable code.

ASP.NET adds oneOf [null, $ref] around a component only when the schema carries x-is-nullable-property. ShouldApplyNullablePropertySchema sets it, and only for a PropertyInfo whose NullabilityInfoContext WriteState is Nullable. That is false for a get-only property (Unknown), a field (a FieldInfo) and an oblivious reference type. The inlined twin gets its null from JsonPropertyInfo.IsGetNullable || IsSetNullable, which is true in all three. Decision 36 and the AspNetCore README promise that a nullable value object admits null wherever it appears.

Change

  • ValueObjectSchemaTransformer: where the value object is not inlined, has a component, and its JsonPropertyInfo is nullable, the schema gets the x-is-nullable-property mark. ASP.NET then writes the same null-or it writes for a settable annotated property.
    • This reads ASP.NET's internal key (OpenApiConstants.NullableProperty, 10.0.12), the way decision 106 reads x-schema-id.
    • The mark is only ever added, never removed.
    • The analyst's "not over object" condition is left out. It can never apply: IValue<object> is refused at build time (CMTK1005).
  • One rule for every OpenAPI version, as the maintainer decided. Under 2.0 such a property becomes ASP.NET's documented form, allOf [{x-nullable: true}] without the reference (KB-026). That is the loss the README already states, and the form a settable annotated property already had.
  • C21 harness (HttpBaseline.SchemaParity, a separate test: commit): the not-promised entry openapi-2-nullable-component-loses-its-type is now also cited by the row's axes. That covers OpenAPI 2.0 rows with a nullable value object that is not inlined, where its property is the null flag without its reference and the twin's admits null. The existing citation needs side (1) to show the same form, which ASP.NET alone writes only for a settable annotated property.
  • C21's seed nullable-admits-null-as-the-wrapped-does is regenerated against the new code, with the same defect (a separate test: commit, 89b310a).
  • One crunchy 2.2.0 changelog line. No sentence of the AspNetCore README or docs/aspnet-core.md changes: they already say a nullable property is the component or null, and that under 2.0 it loses its type.

Measurement: the three positions, before and after (components, minimal API body)

Position Twin (3.1) Value object before, 3.1 after, 3.1 before, 3.0 after, 3.0 before, 2.0 after, 2.0
get-only ProbeDuration? P { get; } (ctor-bound) vs TimeSpan? {"type":["null","string"],"pattern":…} {"$ref":"ProbeDuration"} {"oneOf":[{"type":"null"},{"$ref":"ProbeDuration"}]} {"$ref":"ProbeDuration"} {"oneOf":[{"enum":[null],"nullable":true},{"$ref":"ProbeDuration"}]} {"$ref":"ProbeDuration"} {"allOf":[{"x-nullable":true}]}
field ComponentField<ProbeDuration?>.P (IncludeFields) vs TimeSpan? P; {"type":["null","string"],"pattern":…} {"$ref":"ProbeDuration"} {"oneOf":[{"type":"null"},{"$ref":"ProbeDuration"}]} {"$ref":"ProbeDuration"} {"oneOf":[{"enum":[null],"nullable":true},{"$ref":"ProbeDuration"}]} {"$ref":"ProbeDuration"} {"allOf":[{"x-nullable":true}]}
#nullable disable ProbeLabel P vs string P {"type":["null","string"]} {"$ref":"ProbeLabel"} {"oneOf":[{"type":"null"},{"$ref":"ProbeLabel"}]} {"$ref":"ProbeLabel"} {"oneOf":[{"enum":[null],"nullable":true},{"$ref":"ProbeLabel"}]} {"$ref":"ProbeLabel"} {"allOf":[{"x-nullable":true}]}

Each "after" equals what ASP.NET writes for a settable annotated ProbeDuration? Q { get; set; } / ProbeLabel? Q { get; set; } beside it in the same class. Under 2.0 the twin is {"type":"string",…,"x-nullable":true}.

Tests

  • Regression: ComponentNullablePropertyTests checks the three positions under 3.1, 3.0 and 2.0 (9 cases). Each is compared with the settable annotated sibling (ASP.NET's own form) and with the twin (TimeSpan?, string).
  • Seed check (verify-the-guard): with the one line of the fix reverted and the tests kept, 9 of 9 fail with the bare $ref where the null-or, or the 2.0 null flag, was expected. With the fix restored, all pass.
  • Variants (decision 95). The input class is a property's nullability as NullabilityInfoContext reads it, against JsonPropertyInfo. There is no catalogue for it, so the test holds one: 31 members × {3.1, 3.0, 2.0} × {components, inlined} × RespectNullableAnnotations {off, on} = 372 cases.
    • Struct value object: settable, get-only, computed, field, readonly field, init, required init, positional record, [JsonInclude] private setter, get-only on a struct, and non-nullable settable, get-only and field.
    • Class value object: annotated settable, get-only, field, required init and positional; oblivious settable, get-only and field; [AllowNull], [MaybeNull], [DisallowNull], [NotNull]; typed by a type parameter as settable, get-only, field and required init; non-nullable settable and get-only.
    • Reverted, 93 of the class's 381 cases fail: the 9 regression cases, and 14 members in every component row. Siblings of the same cause, fixed by the same line: a computed property, a readonly field, a get-only property of a struct, [MaybeNull], [DisallowNull], and a type parameter's get-only property and field.
    • Already correct, and unchanged by the fix: settable, init, required init, positional, the private setter, [AllowNull], [NotNull], a type parameter's settable and required-init property, every non-nullable member, and every inlined row.
    • No sibling needed a fix of its own, so there is no Found-By: VA commit.
    • The shape.* catalogue has no value for a get-only property, an oblivious one or a nullability attribute. They are held in the test rather than added to .claude/probe-axes.md, because the axes file is part of every probe's identity (decision 108).
    • The type-parameter members and the RespectNullableAnnotations axis come from the catalogue of a500025, an unpushed fix of the same defect made in another session.
  • Polymorphic base: PolymorphicBaseTests gains a get-only nullable value object. The base's own branch (walked by TypeKit) now writes the null-or the derived branch does. This test passes either way, so it is coverage, not a guard.
  • Weaving review, section 5 (OpenAPI):
    • Only property positions change; elements, dictionary values and parameters don't (NullablePositionTests in full, plus the inlined variants).
    • The mark is on the schema of one use and does not reach the component: the non-nullable members in the same document stay a bare $ref.
    • The component's content and its name are unchanged.
    • Risk: if a future ASP.NET renames its internal key, the null goes missing without a build error. These tests then fail on the upgrade, and KB-026's pins name the version measured.
  • Test projects (verdict): AspNetCore.Tests 1,930 passed (1,549 before, plus the 381 new cases). Conventions.Tests 1,500 passed, including CommitTrailerTests. Samples 144 passed.

Probes

prober try --probe C21OpenapiSchemaParityProbe, with release/2.2.0's rows file (not #62's):

At expected-refusal inapplicable inconclusive pass violation
release/2.2.0 (938d8f8) 129 4 6 155 0
the fix alone 129 3 6 154 2 (rows 178, 194)
this branch 129 5 6 154 0, exit 0
  • Rows 178 (a record class over DateTime, generic container) and 194 (a record class over TimeSpan, nullable property) are OpenAPI 2.0 rows. There the value object's property is now the documented null flag without its reference, while the twin's keeps its type beside x-nullable. Both now cite openapi-2-nullable-component-loses-its-type by the row's axes.
  • Row 181 (a validated class over Uri) went from inapplicable to pass.

prober replay --family http from an empty PROBE_STATE_DIR, with footprints: 13 passed, 0 failed, 478 s. Every probe's row outcomes match decision 108's at release/2.2.0, except C21's above.

Probe outcomes
BaseProbe expected-refusal 123, inapplicable 109, inconclusive 43, pass 136
C03ComposedProbe expected-refusal 397, pass 251
C04WithoutFaultsProbe expected-refusal 135, pass 162
C05RuleProbe pass 645
C06FaultKindsProbe pass 306
C07BlanketMappingProbe expected-refusal 44, pass 367
C08SameNameWarningProbe pass 302
C12DescribedConverterProbe expected-refusal 156, pass 145
C13PatchRequiredProbe expected-refusal 82, inconclusive 51, pass 163
C14DeclaredProblemProbe expected-refusal 273, pass 148
C15MvcFaultStatusDocProbe expected-refusal 104, pass 301
C16OptionsAgreementProbe expected-refusal 93, pass 203
C21OpenapiSchemaParityProbe expected-refusal 129, inapplicable 5, inconclusive 6, pass 154

prober seeds (decision 107): run per probe with --probe for the 13 http probes, from the replay's state. 11 of them have the transformer in their footprint, and the harness file is in all of their identities. By footprint alone, with no earlier seed run on record for this branch, it would have run all 45 probes (169 seeds). 54 of 54 seeds caught:

Probe seeds
BaseProbe 1 caught
C03ComposedProbe 5 caught
C04WithoutFaultsProbe 4 caught
C05RuleProbe 5 caught
C06FaultKindsProbe 4 caught
C07BlanketMappingProbe 6 caught
C08SameNameWarningProbe 5 caught
C12DescribedConverterProbe 4 caught
C13PatchRequiredProbe 3 caught
C14DeclaredProblemProbe 4 caught
C15MvcFaultStatusDocProbe 3 caught
C16OptionsAgreementProbe 5 caught
C21OpenapiSchemaParityProbe 5 caught, at 89b310a (nullable-admits-null-as-the-wrapped-does: 7 of 294 rows)

The first run, at ed94ae6, found that C21's nullable-admits-null-as-the-wrapped-does seed no longer applied, because the fix moved its context. CI's SeedsTests failed on the same thing. 89b310a regenerates the patch with the same defect, and Prober.Tests passes locally (268).

Relation to other work

  • a500025, on the local branch fix/nullable-reference-property, is a fix of the same defect made in another session. It stays unpushed; this PR was compared against it, and its source change is the same. Its type-parameter members and RespectNullableAnnotations axis are part of the variant catalogue here.
  • build: answer the gate's refusals with rows, declarations and exclusions; the gate passes #62 (build/gate-closes) has its own structural KB-026 branch at the same spot in HttpBaseline.SchemaParity, and it covers rows 178 and 194 too. When gate-closes takes release/2.2.0, resolve that conflict by keeping gate-closes's branch and dropping this one, together with TheNullFlagWithoutItsReference.

🤖 Generated with Claude Code

CaffeinatedCoder and others added 3 commits October 18, 2026 08:56
…get-only property, a field and an oblivious one

C21OpenapiSchemaParityProbe found, and the code-analyst confirmed on 2026-10-18 by
reproduction and a seeded fix, that a nullable value object written as a component lost
its null in three positions where its wrapped twin kept it: a get-only property bound by
the constructor, a field under IncludeFields, and a reference-type property in
#nullable disable code. The value object's property was the bare $ref; the twin's
admitted null. Decision 36 and the AspNetCore README promise null wherever a nullable
value object appears.

Cause. ASP.NET adds oneOf [null, $ref] around a component only when the schema carries
x-is-nullable-property, set by ShouldApplyNullablePropertySchema, which needs a
PropertyInfo whose NullabilityInfoContext WriteState is Nullable: not a get-only
property (Unknown), a field (a FieldInfo) or an oblivious reference type. The inlined
wrapped type takes its null from JsonPropertyInfo.IsGetNullable || IsSetNullable, true
in all three. ValueObjectSchemaTransformer left the null to ASP.NET wherever the value
object is a component.

Change. Where the value object is not inlined, has a component and the property's
contract says it is nullable, the transformer marks the schema with
x-is-nullable-property, so ASP.NET writes the null-or as it does for a settable
annotated property. That reads ASP.NET's internal key (OpenApiConstants.NullableProperty,
Microsoft.AspNetCore.OpenApi 10.0.12) the way decision 106 reads x-schema-id. It never
takes a mark away. The analyst's candidate also excluded a value object over object;
that is unreachable, since the generators refuse IValue<object> (CMTK1005), so it is
left out. By the maintainer's decision the rule holds under every OpenAPI version: under
2.0 the property becomes ASP.NET's documented form, allOf [{x-nullable: true}] without
the reference (KB-026, the loss the README states for a nullable value-object property),
as a settable annotated one already was.

Tests. ComponentNullablePropertyTests: the three positions under 3.1, 3.0 and 2.0, each
against the settable annotated property of the same value object beside it (ASP.NET's
own form) and against the twin (TimeSpan? for ProbeDuration?, string for ProbeLabel):
9 cases. Reverted, all 9 fail with the bare $ref where the null-or (3.x) or the 2.0
null flag was expected. PolymorphicBaseTests' fixture gains a get-only nullable value
object, which the base's own branch now writes as the derived branch does (it passes
either way; coverage, not a guard).

Variants (decision 95). The input class: a property's nullability as
NullabilityInfoContext reads it (ASP.NET's, for a component) against JsonPropertyInfo
(the serializer's, and ASP.NET's for an inlined schema). There is no catalogue for it, so
the test holds one: 31 members, each a value object's beside its wrapped type's, under
3.1, 3.0 and 2.0, with value objects as components and inlined, and RespectNullableAnnotations
off and on (372 cases). Struct value object: settable, get-only, computed, field,
readonly field, init, required init, positional record, a [JsonInclude] private setter,
a get-only property of a struct, and non-nullable settable, get-only and field. Class
value object: annotated settable, get-only, field, required init and positional; oblivious
settable, get-only and field; [AllowNull], [MaybeNull], [DisallowNull], [NotNull]; typed
by a type parameter as settable, get-only, field and required init; non-nullable settable
and get-only. The type-parameter members and the RespectNullableAnnotations axis follow
the catalogue of a500025, an unpushed fix of the same defect made in another session.
Reverted, 93 of the class's 381 cases fail: the 9 above and 14 members in every component
row. Besides the three named, they are siblings of the same cause, fixed by the same mark:
a computed property, a readonly field, a get-only property of a struct, [MaybeNull],
[DisallowNull], and a type parameter's get-only property and field. Already right, and
unchanged: settable, init, required init, positional, the private setter, [AllowNull],
[NotNull], a type parameter's settable and required-init property, every non-nullable
member, and every inlined row. The shape.* catalogue has no value for a get-only property,
an oblivious one or a nullability attribute; they are held in the test, not added to
.claude/probe-axes.md, since the axes file is in every probe's identity (decision 108).

AspNetCore README and docs/aspnet-core.md: no sentence changes; the README already says a
nullable property is the component or null, and loses its type under 2.0.

Defect class SDL, silent does-less: the document builds and every value object is described, and
the null is simply missing, with no error and no setting involved. Two of the three positions need
no option at all (a get-only property, an oblivious one), so the cause is not a framework
configuration (FCI), and no more specific class of decision 94's table fits.

Defect-Class: SDL
Found-By: PR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ow's axes

With the fix before this commit, a nullable value object that is a component is
OpenAPI 2.0's allOf [{x-nullable: true}] without the reference wherever the serializer's
contract says the property is nullable. HttpBaseline.SchemaParity cited the not-promised
entry openapi-2-nullable-component-loses-its-type only where side (1), ASP.NET alone,
writes the value object's position the same, and ASP.NET alone adds that null only for
a settable annotated property. Rows 178 (a record class over DateTime, generic container)
and 194 (a record class over TimeSpan, nullable-property in a record class) then failed:
the twin's property keeps its type beside x-nullable, the value object's has the null
flag alone.

The check now cites the same entry by the row's axes as well: OpenAPI 2.0, a nullable
value object, not inlined (CreateSchemaReferenceId other than null-inlines-all, the
entry's own axes), where the value object's property is the null flag without its
reference or type and the twin's property admits null.

prober try --probe C21OpenapiSchemaParityProbe, with the rows file of release/2.2.0:
release/2.2.0 (938d8f8): expected-refusal 129, inapplicable 4, inconclusive 6, pass 155.
With the fix alone: violation 2 (rows 178, 194), pass 154, inapplicable 3. With this
commit: expected-refusal 129, inapplicable 5, inconclusive 6, pass 154, exit 0; row 181
(a validated class over Uri) went from inapplicable to pass, and 178 and 194 are the
cited loss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fix's new lines in ValueObjectSchemaTransformer moved the context of
the seed for nullable-admits-null-as-the-wrapped-does, and `prober seeds
--probe C21OpenapiSchemaParityProbe` reported it as not applying (patch
failed at line 98). The patch is made again against the new code, with the
same defect: a nullable value object's position is Property wherever it is
Nullable<T> or its property is nullable, and None as a parameter, which puts
the null where ASP.NET's exporter does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder
CaffeinatedCoder merged commit 059c0a0 into release/2.2.0 Oct 18, 2026
13 checks passed
@CaffeinatedCoder
CaffeinatedCoder deleted the fix/component-keeps-null branch October 18, 2026 09:43
CaffeinatedCoder added a commit that referenced this pull request Oct 18, 2026
F4's fix (#63), which C21's rows 38, 170, 184, 330 and 415 need.

# Conflicts:
#	tools/probe/corpus/harness/HttpBaseline.cs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant