Skip to content

Type handlers: honor runtime registrations, behind [UseRuntimeTypeHandlers] - #206

Closed
mgravell wants to merge 6 commits into
mainfrom
typehandlers
Closed

mgravell wants to merge 6 commits into
mainfrom
typehandlers

Conversation

@mgravell

@mgravell mgravell commented Aug 20, 2026

Copy link
Copy Markdown
Member

Draft. Reopened and reworked: the behavior is the same as before, but it is now opt-in rather than the default, which is what makes it defensible — and what shrinks the diff from 107 files to 24.

What it does

Generated code can honor type-handlers registered at runtime with SqlMapper.AddTypeHandler, by deferring to Dapper's own decision procedure when it executes: LookupDbType on the write side, and on the read side a bridge installed from a module initializer, compiled against the consumer's own Dapper (the library cannot reference Dapper — a consumer may be on Dapper or Dapper.StrongName, and referencing either loads both and splits the registry). Design and probed contracts in notes/typehandlers-design.md.

Why it is off by default

Honoring a registration made at run time is not a neutral choice:

  • it is not AOT-safe. What it defers to reaches SqlMapper.TypeHandlerCache<T>, a generic instantiated over a runtime-chosen type. ILC cannot know which instantiations to keep and nothing warns at publish; issue TypeHanderCache issue #165 is that crash on a deployed app;
  • it keeps the world open, so nothing reachable from the registry can be trimmed;
  • it costs a lookup per parameter on unrecognized types;
  • it cannot be checked at build, where a declared handler can be;
  • generated code baked its decision at compile time, so a registration arriving later is either ignored or forces a per-operation check to catch it.

So: [module: UseRuntimeTypeHandlers], default off, and DAP053 reports that attribute combined with PublishAot=true — the one configuration where it is a trap rather than a trade.

Assembly/module scope only, enforced by AttributeUsage rather than by a diagnostic we would have to invent: a handler registration is a property of a type, so it cuts across every call-site touching that type, and per-method scope would let one type bind two different ways in one process.

Measured both ways

Same build, Dapper test suite, net10.0, local SQL Server:

harness passed failed skipped interception
[module: UseRuntimeTypeHandlers] 705 64 24 533 / 725
default (no attribute) 677 92 24 533 / 725

The default line is the clean-main baseline exactly — nothing changes for anyone who does not ask. The corpus keeps its 705 for one line in DapperAotEnable.cs, so no test in that suite needs editing, and interception is identical either way: the gate changes how unrecognized member types bind, not which call-sites are handled.

When off, the emitted dispatch disappears from every call-site and the module initializer is not emitted, so the bridge short-circuits on a null delegate. The residue in the library is one null check on the flexible read path and one Resolve call per query.

Still open

@mgravell

Copy link
Copy Markdown
Member Author

Two additions from the design discussion: enum parameters no longer pay the lookup unless Settings.PreferTypeHandlersForEnums is actually on (a static bool read guards it; vanilla only consults enum handlers under that setting anyway), and the design note now records the agreed direction — declarative [module: ...] config attributes as the primary spelling (better scoped than the process-global registries they replace, per-assembly and deterministic), with this PR's runtime bridge as the compatibility tier and a strict switch to come. Post-gate cost profile: recognized scalars pay zero, enums pay a field read, one dictionary hit per query for the whole-type check — the remaining per-parameter lookups land only on types that were previously an exception. Suite holds at 705/793.

SqlMapper.AddTypeHandler registrations now work under interception, in both
directions, by delegating to vanilla's own decision procedure at execution
time rather than trying to see runtime state at compile time:

- writes: a member type the generator does not recognize emits a dispatch
  through SqlMapper.LookupDbType (public, CS0618-suppressible - the same
  tier as PackListParameters): handler present -> handler.SetValue (with
  DBNull for null, never null itself - the TypeHandler<T> interface impl
  NREs on a raw null); otherwise any returned DbType is applied and the
  value binds raw as before (demand:false deliberately - modern providers
  natively handle types vanilla's map does not, DateOnly being the live
  case until the Dapper re-enable ships). Update-mode mirrors it, so
  command reuse stays legal (the parameter shape is stable); an expandable
  member checks the handler *first*, which is the order vanilla applies
  (a handled collection type must not list-expand - Issue253).
- reads: the lib cannot reference Dapper (a consumer may use Dapper or
  Dapper.StrongName, and a hard reference would split the handler registry
  between the two), so generated code installs a TypeHandlerBridge from a
  module initializer, compiled against the consumer's own Dapper; the
  flexible read path consults it, and a whole-type handler overrides a
  generated row factory (RowFactory<T>.Resolve), matching vanilla's
  handler-before-member-binding order. A ModuleInitializerAttribute
  polyfill is emitted for down-level targets, probe-gated like the
  interceptor attribute; the whole feature is inert against a Dapper too
  old to have HasTypeHandler/LookupDbType.
- char/char? stay excluded (their StringFixedLength map entry pads the
  round-trip); object/dynamic stay excluded (runtime-typed values);
  ParamMember gains TypeOfName, mirroring RowMember's, because typeof on
  an annotated reference type or dynamic does not compile.

This also clears the bare-DataTable TVP shape and the Xml types for free -
vanilla registers DataTableHandler and the XML handlers by default, and the
dispatch reaches them like any other registration.

Dapper test suite: 677 -> 705/793. Design and probed facts in
notes/typehandlers-design.md; prior art PRs #117 and #162 (the announced-
attribute tier) remain as the static-dispatch optimization, redone on the
plain-data model.
Enum parameters are common, and the runtime dispatch was charging them a
dictionary lookup per execution for a feature that only applies under
Settings.PreferTypeHandlersForEnums (vanilla consults enum handlers only
under that setting too). The lookup is now guarded by the static bool, so
enum parameters pay a single field read unless the feature is actually in
use; on a Dapper too old to have the setting, enums keep the fully baked
path (probe-gated, like the rest).

Also records the agreed direction in the design note: declarative
[module: ...] config attributes as the primary spelling - better scoped
than the process-global registries they replace - with the runtime bridge
as compatibility and a strict switch to disable it outright.
…lers]

Honoring SqlMapper.AddTypeHandler means deferring to a decision made at run
time, and that cannot be the default. It is invisible to the generator, so
nothing about it can be checked at build; it keeps the world open, so nothing
reachable from the registry can be trimmed; it costs a lookup per parameter on
unrecognized types; and - the part that decides it - what it defers to reaches
SqlMapper.TypeHandlerCache<T>, a generic instantiated over a runtime-chosen
type, which ILC cannot resolve. That is issue #165: a published app failing
with "missing native code or metadata", with no warning at publish.

So the behavior now sits behind [module: UseRuntimeTypeHandlers], off by
default, and DAP053 reports the combination of that attribute with
PublishAot=true.

Assembly/module scope only, enforced by AttributeUsage rather than by a
diagnostic: a handler registration is a property of a *type*, so it cuts across
every call-site touching that type, and per-method scope would let one type bind
two different ways in one process.

When off, the emitted dispatch disappears from every call-site and the module
initializer that installs the read bridge is not emitted - so the hundred golden
files this branch used to touch are untouched again, and the whole diff is the
feature plus its fixture. The residue in the library is one null check on the
flexible read path and one Resolve call per query.

The Dapper test suite keeps its runtime registrations and its 705/793 with a
single module-level attribute in the harness, so nothing there needs editing to
stay green while the declarative form lands separately.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY
@mgravell mgravell changed the title Type handlers: runtime SqlMapper.AddTypeHandler registrations honored end-to-end Type handlers: honor runtime registrations, behind [UseRuntimeTypeHandlers] Aug 23, 2026
mgravell added a commit that referenced this pull request Aug 25, 2026
… to DAP054

Three things that stood between the declarative registration and being usable.

**Tokenize is wired.** A handler's per-column decision was being thrown away -
generated code always passed token 0 - so anything the protocol exists for
(which shape did the provider actually give us?) had to be re-decided per row.
The handler's token now travels in the row-factory's existing `state` channel:
Tokenize allocates one int array per query, a second pass fills it by walking
the token span (so it works the same whichever shape the mapping loop took, and
does not care whether that loop advanced columnOffset), and Read indexes it
positionally. Members sharing a handler share one case-arm. Costs one array per
query and nothing per row.

TypeHandlerProtocolTests pins that contract from the outside: a hand-written
factory in the shape the generator emits, asserting Tokenize runs once per
column no matter how many rows are read, that its token reaches every Parse,
and that a factory reading a slice asks about its own column rather than
column 0. Written against the public API, so it fails if either side drifts.

**DAP055** reports a registration naming something generated code cannot use -
implements neither contract, handles the wrong value type, no public
parameterless constructor, abstract, static, inaccessible. These were skipped
silently, which is exactly the failure mode declarative registration exists to
remove; the message names the reason and says the registration is ignored. It
is a warning, not an error: the code still compiles and runs, it just binds
without the handler.

**DAP053 becomes DAP054** here, because #206 takes DAP053 for the
[UseRuntimeTypeHandlers]/PublishAot warning; ids are allocated so the two PRs
can land in either order.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY
mgravell added a commit that referenced this pull request Aug 25, 2026
#206's rule doc prescribes the attribute that only exists in #208, so merging
#206 first would ship a diagnostic whose documented fix does not compile. #208
goes first, or #206's doc gets softened. Also records the id allocation
(DAP053/#206, DAP054+DAP055/#208) so the two can land in either order, and that
closing #206 entirely remains a live option.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY
mgravell added a commit that referenced this pull request Aug 25, 2026
* Notes: type-handler registration - the feature never landed, and the route out

The attribute and base class ship, but nothing in the analyzer or generator
reads them, so every [module: TypeHandler<,>] written to date is a silent
no-op. Records that, the three unmerged attempts and what each is worth, the
richer protocol sitting on the incomplete type-handler branch, the constraints
on a replacement (binary compat; no generic attributes, per the netfx
GetCustomAttributes throw; not Conditional if cross-assembly), and the agreed
route: obsolete the old pair, new typeof-based registration plus a new runtime
API, and a diagnostic when a runtime registration has no attribute counterpart.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY

* Notes: a state-of-play page, and the harness recipe that actually works

Losing a session cost most of a morning re-deriving which branch held what, so:
state-of-play.md records where each piece of work is, the agreed landing order
and why, the diagnostic-id allocation, and the adjacent things that are easy to
forget - the incomplete in-repo type-handler branch, the two external PRs
awaiting a decision, and the unreleased Dapper APIs we are pinned behind.

The harness section gets the three ways a repack silently measures stale code
(pack does not build; pack skips when the nupkg exists; the package cache is
redirected on this machine), plus the golden-regeneration workaround for
deterministic source paths - all of which bit today.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY

* Notes: #208 is complete, and the landing order was wrong

#206's rule doc prescribes the attribute that only exists in #208, so merging
#206 first would ship a diagnostic whose documented fix does not compile. #208
goes first, or #206's doc gets softened. Also records the id allocation
(DAP053/#206, DAP054+DAP055/#208) so the two can land in either order, and that
closing #206 entirely remains a live option.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY
@mgravell

Copy link
Copy Markdown
Member Author

Closing this. The gate made it opt-in, but it does not make it free: the bridge's read-side check sits in RowFactory.GetValue<T>, which is the type-flexible arm of every mapped member — around 130 call sites across the interceptor goldens, taken whenever a column type does not exactly match the member type. That path picks up a DBNull type test it did not have, a typeof(T) materialisation, and a static delegate probe, and it stops being a single-expression method that inlines into generated code. Everyone pays that, including the large majority who would never turn the feature on.

I could push the check behind the opt-in at emission time rather than at runtime, the way the write side already works. But it buys a second read path to maintain, in the runtime library, for a mode whose whole purpose is to be temporary — and one that cannot be published with native AOT anyway, since what it defers to reaches SqlMapper.TypeHandlerCache<T>.

So the declarative form in #208 is the answer, and this is not needed alongside it. Existing handlers written against Dapper are not orphaned: name the type in [module: TypeHandler(typeof(Money), typeof(MoneyHandler))] and generated code adapts a SqlMapper.ITypeHandler through a shim, so migrating is moving the registration out of a startup method, not rewriting the handler.

The branch stays for reference — the probed contracts in notes/typehandlers-design.md (handlers want DBNull rather than null; char has to stay out of the dispatch because StringFixedLength pads) were paid for and are worth keeping.

@mgravell mgravell closed this Aug 25, 2026
mgravell added a commit that referenced this pull request Aug 25, 2026
Gating stopped the bridge emitting anything for consumers who did not opt in,
but its read-side check still sat in RowFactory.GetValue<T> - the type-flexible
arm of every mapped member - so everyone paid for a feature almost nobody would
enable. Records that, the emission-time fix that was considered and rejected
(a second read path in the runtime library, for a mode that is temporary and
cannot be published under AOT anyway), the reclaimed diagnostic ids, and the
corpus consequence: the suite sits at 677 until it declares its handlers.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY
mgravell added a commit that referenced this pull request Aug 25, 2026
…dlers (#208)

* Type handlers: attribute registration, a new runtime API, and a shim for vanilla handlers

The shipped [TypeHandler<TValue, THandler>] was never read by anything, so
there is no behavior to preserve - only metadata. Both it and its TypeHandler<T>
constraint are now [Obsolete(error)], naming the replacement; the types stay, so
binary compatibility holds.

The replacement is non-generic on purpose. .NET Framework's GetCustomAttributes
throws NotSupportedException for the whole call when an assembly or type carries
a generic attribute, poisoning unrelated reflection - the same wall protobuf-net
hit, with the same typeof-based resolution:

    [module: TypeHandler(typeof(LocalDate), typeof(LocalDateHandler))]

and deliberately not [Conditional("DEBUG")], unlike most of our attributes, so a
package can declare handlers for the types it owns.

The runtime contract is IDbValueHandler<T> (with a DbValueHandler<T> base for
convenience): SetValue / SetNullValue - so a handler over a struct is never
handed a null it cannot express - Parse for output parameters, and a
Tokenize/Parse(reader, ordinal, token) pair mirroring how generated row
factories already work.

It is an interface rather than a base class because that is what makes existing
Dapper handlers portable across the snk boundary. The runtime library cannot
reference Dapper (a consumer may use Dapper *or* Dapper.StrongName, and
referencing either loads both and splits the registry) - but the generator can
see the consumer's symbols, and generated code compiles against whichever Dapper
they actually have. So a registration naming a SqlMapper.ITypeHandler emits

    new VanillaTypeHandler<Money>(new MoneyHandler())

where VanillaTypeHandler<T> : IDbValueHandler<T> is generated into the same
file: our interface, their implementation, a shim in between.

Generated code dispatches on all four paths - AddParameters, the in-place
UpdateParameters used by command caching, PostProcess for output parameters, and
the row-factory read - with one static per registration, emitted only for
registrations the code actually reached. A handler owns the parameter's type and
size, so CanPrepare is cleared for those sites.

DAP053 reports SqlMapper.AddTypeHandler in a Dapper.AOT compilation with no
attribute registration for that type, naming the attribute to paste. It only
sees calls in the compilation being built, so it narrows the silent-wrongness
surface rather than closing it.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY

* Type handlers: wire Tokenize, refuse unusable registrations, renumber to DAP054

Three things that stood between the declarative registration and being usable.

**Tokenize is wired.** A handler's per-column decision was being thrown away -
generated code always passed token 0 - so anything the protocol exists for
(which shape did the provider actually give us?) had to be re-decided per row.
The handler's token now travels in the row-factory's existing `state` channel:
Tokenize allocates one int array per query, a second pass fills it by walking
the token span (so it works the same whichever shape the mapping loop took, and
does not care whether that loop advanced columnOffset), and Read indexes it
positionally. Members sharing a handler share one case-arm. Costs one array per
query and nothing per row.

TypeHandlerProtocolTests pins that contract from the outside: a hand-written
factory in the shape the generator emits, asserting Tokenize runs once per
column no matter how many rows are read, that its token reaches every Parse,
and that a factory reading a slice asks about its own column rather than
column 0. Written against the public API, so it fails if either side drifts.

**DAP055** reports a registration naming something generated code cannot use -
implements neither contract, handles the wrong value type, no public
parameterless constructor, abstract, static, inaccessible. These were skipped
silently, which is exactly the failure mode declarative registration exists to
remove; the message names the reason and says the registration is ignored. It
is a warning, not an error: the code still compiles and runs, it just binds
without the handler.

**DAP053 becomes DAP054** here, because #206 takes DAP053 for the
[UseRuntimeTypeHandlers]/PublishAot warning; ids are allocated so the two PRs
can land in either order.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY

* Don't advertise a member-scoped [TypeHandler] until it is read

The attribute accepted a one-argument, member-scoped form that nothing consults
- which is precisely the silent no-op the shipped generic attribute was
obsoleted for. Targets are now assembly/module only, matching what the generator
actually reads.

Both halves of this are non-breaking to add back: widening AttributeUsage and
adding a constructor. So the narrower scope stays a plausible future addition,
just not a shipped promise.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY

* Renumber to DAP053/DAP054: the runtime-bridge PR is closed, so its id is free

The migration diagnostic becomes DAP053 and the unusable-handler refusal
DAP054, keeping the library block contiguous now that nothing is going to take
DAP053 for [UseRuntimeTypeHandlers].

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY

* Prove the prior-art fixture: #117 and #162's scenarios, in this PR's shapes

Both external PRs propose the same fixture - module-scoped registration of a
handler for a custom type, then three call sites exercising it - so it is a
usable statement of intent. Translated verbatim (same call sites, type names
and member names) into the registration and handler contract this PR ships, so
the two can be compared line for line.

All three generate through the handler: the read of a handled member on a
mapped row type, the write of a handled member on an *anonymous* parameter type
(shape witness intact - a case the existing fixture did not cover), and the
output parameter read back via Parse.

The handler is emitted once as a static readonly field shared by every factory
in the file. That was #162's improvement over #117, which allocated at each
parameter and each column read; static readonly also avoids the `??=` its
review flagged as non-atomic.

Two spellings differ, both deliberate: the registration is typeof-based rather
than generic, because .NET Framework's GetCustomAttributes throws for the whole
call when an assembly carries a generic attribute; and SetValueCore/Parse are
abstract rather than defaulted, so a handler that handles nothing is a compiler
error instead of a silent pass-through.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY

* DAP055: report conflicting type-handler registrations instead of dropping one

Reading #162 for the prior-art fixture surfaced a gap it had already solved and
this PR had not: two handlers registered for one type, with the second silently
ignored. That is the same silent-wrongness the declarative form exists to
remove, so it gets a diagnostic - worded like DAP021, which is the identical
situation for parameters, naming both handlers and which one loses.

First registration wins, deterministically: module scope before assembly scope,
source order within each, and the generator no longer carries the losers. An
exact repeat stays quiet, since the outcome is identical either way; a dropped
duplicate is not also graded for usability, so a registration that is both
duplicated and unusable produces one message rather than two.

Also rewords DAP053 and DAP054 as single sentences, per the auto-review: the
analyzer-design rule wants no trailing period on a single sentence and one on a
multi-sentence message, and single-sentence is what DAP050-052 already do.

Claude-Session: https://claude.ai/code/session_01GMLcMi7PXmALVsydfmkcmY
mgravell added a commit that referenced this pull request Sep 11, 2026
Decided 2026-08-26. Dapper's Type-argument overloads pick the row type at
execution time, which is the one thing compile-time generation cannot follow.
Supporting them means a build-time registry of candidate types plus runtime
dispatch keyed on Type - a lookup on the hot path, an open world for the
trimmer, and dispatch ILC cannot resolve unless every announced type is rooted.
That is the shape #206 was closed over; rebuilding it for a lower-value feature
would be incoherent.

Costs no behaviour: these already generated nothing (a five-call probe handled
0 of 5). What changes is what we say. DAP056 fires at the call-site and names
the generic overload, replacing either silence or a confusing DAP009 about an
unexpected 'type' parameter.

Detection is call-site sensitive, not symbol-level, because
GetRowParser<T>(concreteType: null) - the default, and the common case - is
perfectly supportable. Only a call that actually passes a Type defers the
decision. GetRowParser<T>() stays supported and the fixture pins both halves.

GetRowParser(concreteType) goes too, on Marc's call. It is the one row where
"use the generic form" is not available advice - the choice is data-dependent -
so the rule doc gives the pattern that is actually correct under AOT: a switch
over GetRowParser<T>() per candidate, which roots exactly the types used rather
than everything registered.

Reported from the generator rather than the analyzer: most of these overloads
carry no `sql` string, so the analyzer never sees them. SkippedSourceState
gains a reason enum (plain data, so the cached model stays equatable) and
Generate emits from it.

Knock-on: the surface report grows a 'non-goal: Type-based' bucket for overloads
whose Type parameter is required, so the silent count drops 27 -> 22 and
undiagnosed-unsupported 13 -> 12. GetRowParser's fixture goes from 1 silent skip
to 0. The optional-Type overload stays where it was, since the symbol cannot
say what a call will pass.
mgravell added a commit that referenced this pull request Sep 11, 2026
Decided 2026-08-26. Dapper's Type-argument overloads pick the row type at
execution time, which is the one thing compile-time generation cannot follow.
Supporting them means a build-time registry of candidate types plus runtime
dispatch keyed on Type - a lookup on the hot path, an open world for the
trimmer, and dispatch ILC cannot resolve unless every announced type is rooted.
That is the shape #206 was closed over; rebuilding it for a lower-value feature
would be incoherent.

Costs no behaviour: these already generated nothing (a five-call probe handled
0 of 5). What changes is what we say. DAP056 fires at the call-site and names
the generic overload, replacing either silence or a confusing DAP009 about an
unexpected 'type' parameter.

Detection is call-site sensitive, not symbol-level, because
GetRowParser<T>(concreteType: null) - the default, and the common case - is
perfectly supportable. Only a call that actually passes a Type defers the
decision. GetRowParser<T>() stays supported and the fixture pins both halves.

GetRowParser(concreteType) goes too, on Marc's call. It is the one row where
"use the generic form" is not available advice - the choice is data-dependent -
so the rule doc gives the pattern that is actually correct under AOT: a switch
over GetRowParser<T>() per candidate, which roots exactly the types used rather
than everything registered.

Reported from the generator rather than the analyzer: most of these overloads
carry no `sql` string, so the analyzer never sees them. SkippedSourceState
gains a reason enum (plain data, so the cached model stays equatable) and
Generate emits from it.

Knock-on: the surface report grows a 'non-goal: Type-based' bucket for overloads
whose Type parameter is required, so the silent count drops 27 -> 22 and
undiagnosed-unsupported 13 -> 12. GetRowParser's fixture goes from 1 silent skip
to 0. The optional-Type overload stays where it was, since the symbol cannot
say what a call will pass.
mgravell added a commit that referenced this pull request Sep 11, 2026
`state-of-play.md` is the "read first after a break" page and was the most wrong
thing in the repo: it still listed #206/#207/#208 as in flight with a landing order
to follow, and knew nothing of #209-#216. It now says what is true - nothing of ours
in flight, the four open PRs are all external and all awaiting a decision.

It also absorbs the account of **why #206 was closed**, which was sitting unmerged on
the `typehandler-registration-note` branch and existed nowhere in main: gating stopped
the bridge emitting for consumers who did not opt in, but the read-side check still
sat in `RowFactory.GetValue<T>` - the type-flexible arm of every mapped member - so
everyone paid for a feature almost nobody would enable. The emission-time fix that
was considered and rejected is recorded with it, since that is the part most likely
to be re-proposed.

Two things promoted, because they gate real work and were buried:

- the behavioural harness is local-only and does **not** exist on this machine (no
  `aot-harness` branch, no SQL Server, Windows-shaped repack recipe), so no phase-3
  round can be closed and 677/793 cannot be re-measured until it is rebuilt;
- net48 is unverified since #216 and #214, both of which changed interceptor goldens.

Corrections: #117 and #162 are closed, not awaiting a decision; the `type-handler`
branch harvest was filed as "before #208 settles", and #208 has settled; diagnostic
ids as actually shipped (DAP053-056, next free DAP057).

`parity.md`: the `CommandDefinition` row still said "27 overloads, every one skipped
silently" - #214 moved the `Type`+`CommandDefinition` combinations out, so it is 21
of the 22 in that bucket. Cites the report rather than restating a number, which is
what #213 was for.

Also: `typehandlers-design.md` never existed in main - it was a file on the closed
#206 branch - leaving dangling links in three notes. Pointed at the note that did
land, `typehandler-registration.md`.
mgravell added a commit that referenced this pull request Sep 11, 2026
Pre-release verification, and the rig had to be rebuilt first: the old one was
local-only on the Windows box and did not survive the move. The step-by-step for
standing it up from nothing is now at the top of harness-baseline.md, Linux-shaped -
databases from the Dapper suite's own docker compose, the SqlServerConnectionString
env var, the local feed, and the .globalconfig severity downgrades without which
DAP036/DAP037 stop the build outright.

Round 15, net10.0: **729 passed / 800**, 41 failed, against a vanilla control of
770/800 on the same box. Scorecard: handled 432 of 736 enabled call-sites. All 41
divergences are known gaps at x2 providers - TypeHandler, Literal, Misc, Parameter,
Async, plus scattered singles - so no new failure class, and the pass count is up on
round 12's 677.

The question this was run to answer: **the round-12 generator (b411eb4), packed and
run against this same rig, also reads 432 of 736.** So #208-#217 changed interception
not at all, and moved behaviour only upward.

Recorded honestly rather than smoothed over: the 533/725 in round 12 is *not*
reproducible here, and since the round-12 generator does not reproduce it either, the
difference is rig configuration that no longer exists to inspect. DAP051 firing 244
times is the likely candidate but is a hypothesis. The durable lesson is in the note:
absolute call-site counts are rig-specific, so compare within a rig, never across.

Also corrected: the preamble still described round 14's [module: UseRuntimeTypeHandlers]
as part of the setup, and that attribute does not exist - #206 was closed.
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