Skip to content

feat: EDM-driven OData open types on complex types, opt-in (#389) - #395

Open
engenb wants to merge 5 commits into
developfrom
feat/389-open-types
Open

feat: EDM-driven OData open types on complex types, opt-in (#389)#395
engenb wants to merge 5 commits into
developfrom
feat/389-open-types

Conversation

@engenb

@engenb engenb commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Implements OData open types (dynamic property bags) on complex types, driven entirely by the EDM — the consumer's model needs no [JsonExtensionData] or any other attribute.

Closes #389.

What it does

ODataConventionModelBuilder — the builder OhData already uses — infers a dynamic-property container from an IDictionary<string, object>-assignable member, marks the containing type OpenType="true" in the CSDL, omits the member from the declared properties, and records the backing PropertyInfo as a DynamicPropertyDictionaryAnnotation. This PR reads that annotation back at MapOhData() and marks exactly that member as System.Text.Json extension data, so dynamic keys serialize and bind flat — siblings of the declared properties rather than nested under the container's name.

The same registration that produces the CSDL produces the wire shape, so the two cannot drift. Nothing is matched by property name or by convention.

public record ExternalReferenceMetadata
{
    public IDictionary<string, object?>? KeyValuePairs { get; set; }   // no attributes
}
"Metadata": { "organizationCreatedDate": "2026-01-01T00:00:00.0000000+00:00", "tier": 3 }

KeyValuePairs appears nowhere on the wire and nowhere in $metadata.

Opt-in — and why

builder.Services.AddOhData(b => b.WithOpenTypes());   // default: off

The first cut of this applied unconditionally, on the reasoning that the CSDL already said OpenType="true" so we were only making the JSON agree with its own metadata. Adversarial review showed why that is wrong: the CSDL was the half nobody was reading, and the wire was the contract in practice.

Once the container is extension data it is no longer a declared property, so an existing adopter's body

{ "Meta": { "Region": "r", "Bag": { "a": 1 } } }

binds "Bag" as a dynamic key holding the old dictionary — the handler persists Bag = { "Bag": {"a":1} } — and the response echo is byte-identical to the previous one. Silent write corruption with no runtime signal.

Off by default ⇒ the container map is never built, Build returns the base JsonSerializerOptions reference-equal, no per-request validation runs, and behaviour is byte-identical to develop. OpenTypeOptInTests pins both halves.

Review findings addressed

Two-pass local review on the first commit returned 1 critical + 3 high, all invisible in the response body — which is why the original 21 tests passed. Each is fixed and pinned:

Finding Fix
1 CRITICAL silent re-bind of existing payloads opt-in switch, above
2 HIGH @odata.* and non-identifier keys persist into the bag and echo on every read — a stored deserialization-fault vector against other OData consumers FindInvalidDynamicKey walks the raw JsonElement against JsonTypeInfo; 400 + OData envelope with target = the offending key, on POST/PUT/PATCH and property-route writes
3 HIGH a bag key equal to a declared property name emits duplicate JSON keys, and every .NET reader tested resolves in the bag's favour — making the declared value unreachable container getter wrapped: declared wins, shadowed key dropped, warning logged. Same reference returned when there is no collision, so the deserialize path is untouched
4 HIGH PATCH is whole-value replace; docs claimed undeclared keys survive semantics unchanged (pre-existing, and a recorded 1.0.0 scope decision); docs rewritten with a read-modify-write recipe, and the misleading test renamed to Patch_ReplacesTheWholeComplexValue_SeededDynamicKeysAreLost — it previously observed the wipe and read as a pass
5 ValidateOrThrow did not cover what its docstring promised broader catch + wrap, probes derived open types, explicit extension-member count; docstring now honest about the instance-dependent fault it cannot see
6 getter-only containers silently skipped, reproducing the exact EDM/wire mismatch the feature declines to ship for entity roots throws at MapOhData() naming type, member and fix
7 modifier-ordering rationale in CLAUDE.md and the factory was falseIgnore() cannot reach a complex type, so the two modifiers can never see the same JsonTypeInfo removed from all four places; composition test relabelled honestly
8 docs stated the opposite of actual behaviour for new-shadowed containers confirmed it is flattened; docs and comment corrected, pinning test added
9 HasSameMetadataDefinitionAs comment claimed an identity match it does not provide (GBag<int>/GBag<string> match) comment rewritten to state the real invariant — base-chain walk against a DeclaringType-keyed map — and to warn that re-keying would convert a declared property into a bag
10 null/empty container asymmetry undocumented documented + test

Three review findings were pushed back on with evidence rather than accepted:

  • Finding 3's "also reject at write time" is unreachable — STJ binds a body key matching a declared name to the declared property; it never reaches the bag. The serialization-side guard is the whole fix.
  • Finding 5 understated the hole: GetTypeInfo does not catch the competing-[JsonExtensionData] case at all (it resolves fine; only Serialize throws), so the method's stated primary purpose was entirely unmet rather than under-covered.
  • Finding 6's "keep the type-assignability check" needed a justification, and there is one: it is unreachable. The convention builder only infers IDictionary<string,object>-assignable members, and a consumer cannot write the annotation by hand — EdmAnnotationExtensions exposes only a getter and DynamicPropertyDictionaryAnnotation is internal. It stays as a defensive guard with that reason recorded.

A bug in the fix itself was found and fixed during implementation: returning a plain Dictionary from the collision-filtering getter throws InvalidCastException when the container is declared as a custom subclass, because STJ casts back to the declared type. The clone now preserves the runtime type and degrades with a logged error rather than faulting on a read path.

Scope

Complex types only. Entity-root dynamic containers are deliberately not handled: the PATCH delta loop resolves body members through FindClrPropertyByEdmName and skips what it cannot resolve, so a root-level undeclared key would be silently dropped on write. Half-working is worse than absent. Documented as a known limitation in docs/open-types.md.

#325/#326 clause-bounded serialization is not widened. Review confirmed against a develop baseline that bag values already reached System.Text.Json before this change — they were simply written one level deeper — and that no code path added here can reach ResolveNavTreatment, TryBuildEngagedExpand, or the pushdown gate. Complex types carry no EDM navigation properties, so delegate safety is untouched.

Review rounds

Three adversarial rounds ran on this branch. Each is recorded because the pattern matters more than any single finding: the code was broadly right every time; the prose describing it was not.

round target outcome
1 b65154c (feature) 1 CRITICAL + 3 HIGH → opt-in switch, key validation, collision guard, PATCH doc correction (a2f0536)
2 a2f0536 (round-1 fix) 2 HIGH + 3 MEDIUM → the @odata.type mitigation was one level deep and never recursed into a dynamic key's value; nav-POST and action routes were unwired; a GET could 500; the identifier check rejected valid non-ASCII identifiers (9cb2524)
3 9cb2524 (round-2 fix) clean above LOW → one remaining coverage gap through dictionary-valued members, plus prose/message corrections (f31ab8f)

Round 2 also found that round 1's justification comment for an untested branch was false, and round 3 found that round 2's replacement justification was false as well — the real reason that branch is unreachable is that AddDynamicPropertyDictionary self-validates the container type, measured by reflection rather than reasoned about.

Round 3 verified, empirically rather than by reading: every one of the eight body-reading write routes rejects first-level and nested reserved keys (including the two the tests did not cover — entity-level bound action and unbound action); $ref writes are correctly excluded; JsonDocument's 64-depth cap terminates before the walk is entered; cost is linear (50,000 sibling keys → 106 ms); the ABNF leading/following split, surrogate handling and 128-code-point cap are exact; and named registrations do not leak opt-in statev1 with the flag rejects while v2 over the same CLR model returns 201 unflattened.

Verification

  • 1669 passing in the core suite, 2130 across the solution, 0 failures (baseline 1582, +87). Build clean, 0 warnings, net8.0 and net10.0.
  • Merged with develop (0552f34, including the five dependabot bumps); merge touched only .csproj and workflow files.
  • Zero-delta when off, verified byte-for-byte across a 25-case transcript — status and full response body identical to develop@0552f34 for malformed, empty, array, null, trailing-garbage and 70-level-deep bodies, wrong Content-Type, well-formed writes on every route, plus $metadata and the service document.
  • New tests were checked for revert-sensitivity: reverting the nested-key recursion, the dictionary-member walk, the DropShadowedKeys fix and the OpenTypesActive gate each fails specific tests. A test that still passes with its fix removed is not a regression test.
  • One earlier solution-wide run showed ObservabilityTests.Metadata_Operation_IsTagged failing. It passes in isolation on both this branch and clean develop, and the full project passes on consecutive runs — a load-dependent flake, filed as Flaky test: ObservabilityTests.Metadata_Operation_IsTagged captures no activity under solution-wide parallel load #394, not a regression from this change.

Performance

No benchmarks were run for this PR, and here is the honest reason rather than a silent omission: with the feature off — the default, and what every existing deployment gets — Build returns the base JsonSerializerOptions reference-equal and no code added here executes, so there is nothing to measure. OpenTypesActive narrows this further: a registration that opts in but whose model has no open complex types also does no per-request work.

With the feature on, two costs are added:

  • one JsonElement walk per write request against the already-resolved JsonTypeInfomeasured linear, 50,000 sibling keys under one dynamic key in 106 ms, and JsonDocument's 64-level cap rejects deep bodies before the walk is entered;
  • one pass over the bag's keys per open-complex-type instance serialized (O(bag size), HashSet lookups, no allocation unless a collision is found) — unmeasured.

Neither is on the collection read path for a model that does not opt in. If the opt-in path becomes hot for an adopter, the serialization-side pass should be benchmarked then.

Follow-ups filed

engenb added 3 commits August 14, 2026 06:19
A complex type carrying an `IDictionary<string, object?>` member now serializes
and binds FLAT — dynamic keys as siblings of the declared properties, never
nested under the container property's own name.

Support is driven entirely from the EDM. `ODataConventionModelBuilder` already
infers the container, marks the type `OpenType="true"`, omits it from the CSDL,
and records the backing `PropertyInfo` as a `DynamicPropertyDictionaryAnnotation`.
`OpenTypeJsonOptions` reads that annotation back at `MapOhData()` and layers one
`TypeInfoResolver` modifier onto the registration's serializer options that sets
`JsonPropertyInfo.IsExtensionData` on exactly that member. The consumer's CLR
model therefore needs no `[JsonExtensionData]` — or any other — attribute, which
was the hard constraint: a shared contract package must not pay framework cost.

Covered: collection GET, GET by key, navigation and property routes, `$expand`
targets, POST/PUT/PATCH (undeclared keys reach the handler), property-route
writes, `$select=<container>`, complex-type inheritance, and `OpenType="true"`
in `$metadata`.

Integration notes:
- The modifier is added AFTER the ignored-property modifier (so `Ignore()` on a
  container still wins) and BEFORE the per-request nav-suppression modifier,
  which derives from these options. All three chain via `WithAddedModifier`;
  `OpenTypeCompositionTests` proves an expanded navigation, an ignored property
  and a flattened bag all hold simultaneously, including on a cyclic model.
- Clause-bounded serialization (#325/#326) is not widened. The bag's values
  already reached System.Text.Json; only the emitted key placement changes. No
  entity type and no navigation property is touched.
- The container is matched by `MemberInfo.HasSameMetadataDefinitionAs`, not `==`:
  the builder's annotation and STJ's `AttributeProvider` come from independent
  reflection walks that disagree on `ReflectedType`.
- Zero delta when unused: a model with no open complex type gets no derived
  options at all (asserted reference-equal).

Out of scope, documented in docs/open-types.md and locked down as behavior in
OpenTypeLimitationTests: entity-ROOT dynamic containers, and `$filter`/`$orderby`
over an individual dynamic key (Microsoft's query binder faults building the
property-bag indexer access, so nothing reaches the database).
…ims (#389)

Adversarial-review fixes on top of the #389 feature commit.

CRITICAL - open types are now OPT-IN via AddOhData(o => o.WithOpenTypes()),
default OFF. Flattening RE-BINDS a body an existing adopter already sends:
once the container is extension data it is no longer a declared property, so
{"Meta":{"Bag":{"a":1}}} stops meaning "the Bag property" and starts meaning
"a dynamic key named Bag" - and the echo of the mis-bound value is
byte-identical to the correct one, so the corruption is invisible from the
wire. When off, the container map is not built, Build returns the base options
reference-equal, and no write-path validation runs.

HIGH - reserved/non-conformant dynamic keys are rejected on POST/PUT/PATCH and
property-route writes with 400 + the OData error envelope naming the key. A
bag key is persisted verbatim and echoed on every later read, so an unpoliced
'@odata.type' inside a complex value is a STORED deserialization-fault vector
against conforming readers. Implemented as a JsonElement walk against
JsonTypeInfo (OpenTypeJsonOptions.FindInvalidDynamicKey) rather than a CLR
graph walk after binding: JsonTypeInfo already is the plan, needs no cycle
guard, and adds one call per write route to OhDataEndpointFactory.

HIGH - a bag key equal to a declared property's JSON name used to emit that
name TWICE in one object (measured: {"Region":"declared","Region":"fromBag"}),
which every .NET reader tested resolves in the bag's favour. The declared
property now wins and the bag entry is dropped, with a warning naming the type
and key. Done by wrapping JsonPropertyInfo.Get; the same reference is handed
back when there is no collision, so the deserialize path (which also calls the
getter) is untouched. The filtered clone preserves the container's RUNTIME
type - substituting a plain Dictionary for a `MyBag : Dictionary<...>`
container throws InvalidCastException mid-serialization (measured).

HIGH - PATCH of a complex member is whole-value REPLACE, not merge. Semantics
unchanged (pre-existing for any complex member), but the docs claimed
otherwise and Patch_UndeclaredKeysSurviveAlongsideADeclaredProperty OBSERVED
the wipe while reading as a pass. Test split and renamed to assert the loss
explicitly, plus its true complement (omitting the member preserves the bag);
docs rewritten with a read-modify-write recipe.

MEDIUM - ValidateOrThrow now covers what it promised. Its own primary case -
a competing [JsonExtensionData] member - is NOT caught by GetTypeInfo, which
accepts a two-extension-member contract and only fails from Serialize
(measured); an explicit extension-member count catches it. Also probes derived
open complex types (they have their own contracts but collapse onto the base's
map entry), wraps any exception, and the docstring is now honest that an
instance-dependent fault - a writable container holding a read-only dictionary
- cannot be caught at startup.

MEDIUM - a getter-only container (the idiomatic `{ get; } = new();`) now
throws at MapOhData naming the type, member and fix. Silently skipping left
the CSDL saying OpenType="true" while the wire nested the bag anyway, and
marking it would silently DROP every incoming dynamic key (both measured).

Corrected false claims, all re-verified by running code:
- The "added after the ignored-property modifier so Ignore() still wins"
  rationale in CLAUDE.md, OhDataEndpointFactory and OpenTypeJsonOptions was
  UNREACHABLE: the ignored-property map is keyed by profile.ModelType (an
  entity type) and Ignore() takes a root member of that entity, while a
  container lives on a complex type. The two modifiers can never see the same
  JsonTypeInfo. OpenTypeCompositionTests' comment relabelled honestly.
- A derived type shadowing the container with `new` is FLATTENED, not "left
  serializing as it does today" - the builder records the derived member for
  the derived EDM type. Docs, comment and a pinning test corrected.
- HasSameMetadataDefinitionAs is not whole-member identity: it matches across
  generic instantiations (measured). The real invariant is the base-chain walk
  against a DeclaringType-keyed map; the comment now says so, and warns that
  re-keying the map would convert a declared property into a bag.
- The type-assignability guard is genuinely unreachable (the builder only
  infers IDictionary<string, object>-assignable members, and the annotation
  cannot be written by hand - the setter is not public and the annotation type
  is internal), so it stays a defensive guard and the comment says why.

Docs also record the null/empty asymmetry (a POST with no undeclared keys
leaves the container null, not empty) and the detection recipe for whether the
opt-in affects a given model.

Tests: new OpenTypeJsonOptionsTests.cs (hostless, mirroring
IgnoredPropertyJsonOptionsTests) holding the container-map, zero-delta,
modifier, ValidateOrThrow and dynamic-key-grammar coverage - including the two
unit-level tests that previously spun up a full TestHostBuilder just to obtain
an IEdmModel. 1582 -> 1635 passing.
Comment on lines +352 to +359
var req = new HttpRequestMessage(HttpMethod.Patch,
$"/odata/ExternalReferences({ExternalReferenceStore.Seed})")
{
Content = Json(
"""
{ "Source": "Patched", "Metadata": { "patchedKey": "patchedValue" } }
"""),
};
Comment on lines +389 to +393
var req = new HttpRequestMessage(HttpMethod.Patch,
$"/odata/ExternalReferences({ExternalReferenceStore.Seed})")
{
Content = Json("""{ "Source": "Patched" }"""),
};
Comment on lines +462 to +466
var req = new HttpRequestMessage(HttpMethod.Patch,
$"/odata/ExternalReferences({ExternalReferenceStore.Seed})")
{
Content = Json("""{ "Metadata": { "@odata.id": "http://evil/x" } }"""),
};
Comment on lines +479 to +483
var req = new HttpRequestMessage(HttpMethod.Put,
$"/odata/ExternalReferences({ExternalReferenceStore.Seed})/Metadata")
{
Content = Json("""{ "value": { "@odata.type": "#Evil.Type" } }"""),
};
Comment on lines +540 to +544
var req = new HttpRequestMessage(HttpMethod.Put,
$"/odata/ExternalReferences({ExternalReferenceStore.Seed})/Metadata")
{
Content = Json("""{ "value": { "onlyKey": 42 } }"""),
};
Comment on lines +763 to +765
new StringContent(
"""{ "Source": "S", "Xref": "X", "Metadata": { "KeyValuePairs": { "a": 1 } } }""",
Encoding.UTF8, "application/json"));
Comment on lines +780 to +782
new StringContent(
"""{ "Source": "S", "Xref": "X", "Metadata": { "KeyValuePairs": { "@odata.type": "#Evil" } } }""",
Encoding.UTF8, "application/json"));
Comment on lines +300 to +303
catch (Exception)
{
return null;
}
Comment on lines +357 to +361
catch (Exception ex)
{
throw ContractRejected(openClrType, containers,
$"resolving its JSON contract threw {ex.GetType().Name}: {ex.Message}", ex);
}
Comment on lines +121 to +130
foreach (IEdmComplexType complexType in model.SchemaElements.OfType<IEdmComplexType>())
{
if (!complexType.IsOpen) continue;
PropertyInfo? container = model.GetDynamicPropertyDictionary(complexType);
if (container?.DeclaringType is null) continue;
ThrowIfUnusableAsExtensionData(container, complexType);
byDeclaringType[container.DeclaringType] = container;
Type openClrType = container.ReflectedType ?? container.DeclaringType;
if (!openClrTypes.Contains(openClrType)) openClrTypes.Add(openClrType);
}
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.63736% with 45 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/OhData.AspNetCore/OpenTypeJsonOptions.cs 82.42% 22 Missing and 23 partials ⚠️

📢 Thoughts on this report? Let us know!

@engenb
engenb marked this pull request as draft August 15, 2026 03:01
engenb added 2 commits August 14, 2026 22:26
…fault reads (#389)

Round-2 adversarial review findings on the open-types feature.

H1 - FindInvalidDynamicKey never descended into a dynamic property's VALUE, so
only the first level of bag keys was policed and the stored-@odata.type vector
the check exists to close stayed open one level down. Adds a type-free JSON walk
(a dynamic value has no declared type by construction) over every object key at
every depth, arrays included. Linear in body size; relies on JsonDocument's
existing 64-level parse cap rather than adding a second bound. The test named
FindInvalidDynamicKey_FindsAReservedKeyNestedInAComplexValue tested a top-level
bag key, not a nested one - renamed to what it asserts, with real nested and
in-array coverage added.

H2 - the check was wired into POST/PUT/PATCH and the property-write route but
not into the navigation-POST create route or the action routes. The nav route is
a documented create route and is now wired in with the same buffer-then-bind
shape PUT uses. The action routes are wired in too, per PARAMETER against that
parameter's declared type: an action parameter binds into the same CLR types and
reaches the same handlers, so a persisted parameter stores a reserved key exactly
as an entity body would, and checking per parameter means the {"paramName":value}
envelope is never itself treated as a bag. Docs and code now agree.

M1 - DropShadowedKeys assumed Activator.CreateInstance returned an empty
dictionary and then used IDictionary.Add, so a container type whose parameterless
constructor seeds an entry faulted a plain GET with 500 (ArgumentException on the
duplicate key), refuting both the "caller degrades rather than throwing" comment
and the docs' "a read is never faulted over a data condition". Clears the clone,
rejects a read-only one, and assigns through the indexer so a comparer mismatch
degrades to last-write-wins instead of faulting.

M2 - documents the pre-seeded-container corner in docs/open-types.md. It is
stronger than the code comment claimed: every dynamic key in the request is
dropped and the write still reports 201. Left uncovered deliberately - the
condition depends on the runtime instance, so startup cannot see it, and the
getter cannot tell the serialize path from the deserialize path.

M3 - IsValidDynamicPropertyName used char.IsLetter/IsLetterOrDigit, which is
narrower than the odataIdentifier ABNF and rejected legitimate keys: combining
marks (Mn/Mc), Nl, and NFD-decomposed Latin. macOS normalises to NFD and Windows
to NFC, so the same key got two different status codes depending on the client OS.
Implements the actual category set (L/Nl leading; L/Nl/Nd/Mn/Mc/Pc/Cf following)
over rune enumeration, and counts the 128 cap in code points rather than UTF-16
code units so an astral-plane identifier is not charged double.

L1 - WithOpenTypes() was documented as a byte-identical no-op on a model with no
open complex type and was not one: the PUT path and the write-body walk gated on
OpenTypesEnabled (did the consumer opt in?) rather than on whether the EDM
actually produced an open type, so such a registration still buffered every PUT
body - which changed the malformed-body error message. Adds
OhDataRegistration.OpenTypesActive, set from MapAll only when the container map
is non-empty, and gates every per-request path on it.

L3 - the justification comment on ThrowIfUnusableAsExtensionData's type branch was
false in both halves: DynamicPropertyDictionaryAnnotation is an exported public
type with a public ctor(PropertyInfo), and AddDynamicPropertyDictionary,
StructuralTypeConfiguration.ModelBuilder and ODataModelBuilder.AddComplexType are
all public. Measured by reflection. The raw builder is also reachable from what
AdvancedConfigure is handed - EntitySetConfiguration<T>.EntityType.BaseType is the
non-generic EntityTypeConfiguration, which IS a StructuralTypeConfiguration, and
its ModelBuilder is the very instance OhData is building with. The branch is
unreachable for a different, verified reason: AddDynamicPropertyDictionary itself
throws ArgumentException for any member not assignable to IDictionary<string,
object>, so a wrong-typed container cannot enter the model by hand either.

L4 - collapses the shadowed-key warning from one record per key to one per
container instance. Amplification across a page is left bounded by page size; the
comment records why going further is worse.

1663 tests pass (1635 before).
…#389)

Round-3 review cleanup. LOW/INFO only -- no semantic change to PATCH, entity-root
scope, delegate safety, or the zero-delta-when-off guarantee.

LOW-1  FindInvalidDynamicKey bailed on any JsonTypeInfoKind that was not Object, so
       a declared IDictionary<string, TOpenComplex> member (Kind == Dictionary)
       stopped the walk one member short of the bag System.Text.Json binds straight
       into. Measured: {"MetaMap":{"one":{"@odata.type":"#Evil"}}} was accepted with
       a 201 and echoed on every later read, while the byte-identical keys one member
       over through a plain complex member were correctly rejected with a 400. Adds a
       Dictionary branch that recurses into the VALUES; the dictionary's own map keys
       are keys of a declared property, not dynamic property names, and are
       deliberately not held to the identifier grammar. The value type comes from CLR
       reflection, not JsonTypeInfo.ElementType, because this assembly also targets
       net8.0. This is what makes the "applies at every depth" prose true.

LOW-2  The 400 message still described the pre-M3 ASCII grammar ("a letter or '_'
       followed by letters, digits or '_'"), which would wrongly imply non-Latin and
       NFD-spelled names are invalid, and never mentioned the 128 cap a name can also
       be rejected for. Reworded in plain terms; docs carry the formal grammar.

INFO-2 "NFD and NFC spellings can never get different status codes" is false at the
       cap boundary: 128 x U+00EF is accepted, its 256-code-point NFD form is not.
       The implementation is right (the grammar defines the limit in characters); the
       absolute wording was not. Qualified in CHANGELOG.md, CLAUDE.md and the
       IsValidDynamicPropertyName remarks, and pinned by a test.

INFO-3 BoundAction_StillAcceptsAConformantParameterAndBindsDynamicKeys did not
       discriminate -- its envelope key "meta" is itself a valid identifier, so the
       test passed identically under the buggy logic of checking the whole envelope
       against the parameter's declared type (verified by measurement). The envelope
       now carries meta@odata.type, which fails the moment the envelope is policed.

INFO-1 Documents that Cf (format) characters, including bidi controls, are permitted
       by the normative grammar in following position and are echoed verbatim, so a
       consumer rendering dynamic keys should treat them as untrusted display text.
       No behavior change -- the grammar is not deviated from.
@engenb
engenb marked this pull request as ready for review August 15, 2026 04:05
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.

Support OData open types / dynamic property bags (EDM-driven, no model attributes)

1 participant