Skip to content

Latest commit

 

History

History
543 lines (475 loc) · 360 KB

File metadata and controls

543 lines (475 loc) · 360 KB

AGENTS.md — sharp-runtime

Project mission

sharp-runtime is a C++23 static library that reimplements a practical subset of the .NET System.* namespace so that ported C#/XNA game code compiles against C++ headers with minimal changes. It is the foundation for CNA (C++ XNA port) and mobile-eggbert.


Non-negotiable rules

  1. Zero errors, zero warnings before any commit. cmake --build build --parallel 2 must be clean.
  2. No test-count regression. scripts/run_component_tests.sh build must show no failures or skips. The current verified baseline is 17,840 tests across 38 executables with THE GATE GREEN — 17,840 run, 17,840 passed, 0 failed, 0 skipped, measured on 2026-08-22 by post-#1941 consumer-audit ticket #2418 after a cache-disabled full repository build at two jobs and the complete local CI gate. It is +59 on #2417's 17,781 final-audit closure: Core.Base +22, Globalization +1, IO +5, Net +6, Net.Http.Headers +2, TimeZone +18, and Xml +5; all other executables are unchanged. Graph 41 / 96, test-only seams 5 / 22, negative fixtures 55 / 284. The Doxygen 1.9.8 no-regression baseline is 2,675 and is enforced both locally and in CI. Ticket #2419 additionally makes the complete production graph a permanent Clang gate: Clang 19.1.7 builds all 219 first-party translation units with -Werror, 0 warnings and 0 errors, from both local_ci_check.sh and the GitHub full job.
Historical test-count ledger retained verbatim
  1. No test-count regression. scripts/run_component_tests.sh build must show no failures. The current verified baseline is 17,738 tests across 38 executables with THE GATE GREEN -- 17,738 run, 17,738 passed, 0 failed, 0 skipped, measured on 2026-08-20 by the release-identity change that gives this repository a version for the first time, v0.1.0-alpha.1. +6 on the 17,732 below, in SharpRuntimeTests_Core_Base (6,148 -> 6,154); no other executable's count moved, and no production statement changed -- the six cases pin that the CMake-side decision and the generated SharpRuntime/Version.hpp agree, and they are deliberately structural rather than an equality check against "0.1.0-alpha.1", so a later bump cannot satisfy them by editing the test. The version is decided in ONE place -- project(SHARP_RUNTIME VERSION 0.1.0) plus SHARP_RUNTIME_VERSION_PRERELEASE in the root CMakeLists.txt -- and everything else derives from it; docs/releasing.md names the two copies (CHANGELOG.md, Doxyfile's PROJECT_NUMBER) that a bump must still update by hand. The pre-release identifier is a normal variable rather than a cache entry on purpose: a cached copy would keep an already-configured build directory reporting the previous release after a bump, which is exactly the stale claim a release must not make. The generated header rides SharpRuntime::Headers, the interface target every component and consumer already links transitively for SHARP_RUNTIME_HAS_NATIVE_INT128, so a consumer writes #include "SharpRuntime/Version.hpp" without knowing which include root resolves it -- and the cost of that reach is a full rebuild, since a new include root changes every compile command, which is why it was done once and deliberately. The boundary validator had to be taught about it: scripts/validate_module_boundaries.py deliberately runs without a configured build, so a header that exists only in one is unresolvable to it -- the exception is a set of exact paths (GENERATED_INCLUDE_PATHS) rather than a loosening of the whole SharpRuntime/ prefix, and test/validate_module_boundaries_test.py pins both halves, that the generated path resolves and that an unknown SharpRuntime/ header still fails. Both branches of the version logic are measured, not just the shipped one: emptying SHARP_RUNTIME_VERSION_PRERELEASE and configuring gives 0.1.0 with no -, which is the form every final release will take and which no shipped configuration exercises. A pre-existing red gate was found on the way and is reported rather than re-baselined: scripts/check_doxygen_warnings.sh emits 2,675 warnings against a maximum of 1,942 measured 2026-07-25, and it is not my change -- measured twice, with and without the Doxyfile edit, the count is identical, and */tests/* is excluded so the new test file is not even scanned. It runs in .github/workflows/components.yml but not in scripts/local_ci_check.sh, which is #2415's shape exactly: a check nothing local runs is the check that rots. It was 17,732 immediately before, measured on 2026-08-20 by ticket #1944, the last open post-audit implementation ticket. +7 on the 17,725 below, in SharpRuntimeTests_Core_Base (6,141 -> 6,148). All five exact-parsing types gain multi-format ParseExact, sharing one ordered first-success loop -- five copies of one taxonomy is how five doors come to disagree, and .NET writes it twice with the two agreeing, so sharing is faithful rather than a shortcut. Ordering is pinned with an input two formats both accept: 01/02/2024 is 2 January under MM/dd and 1 February under dd/MM, so the two orders give different dates rather than a different code path. An empty ELEMENT aborts the whole loop rather than being skipped -- .NET returns its bad-format-specifier failure immediately, "skip it and carry on" is the plausible wrong implementation, and the pin puts the empty element before a format that would have matched so the wrong rule succeeds where the right one fails. An empty COLLECTION is a format failure, not an argument one, and .NET's null-array arm has no C++ counterpart and is deliberately not reproduced. TWO FAILURE KINDS, BECAUSE .NET GIVES THEM TWO MESSAGES, and this was found by measurement: with the empty-collection guard simply removed the loop body never runs and the fall-through gives the same answer, so the guard would have been a proven equivalence -- carrying .NET's own "No format specifiers were provided." against "was not recognized" makes it load-bearing and gives the right diagnosis, since telling a caller who supplied no formats that their input was bad is the wrong one. THE OVERLOAD HAZARD THE TICKET ANTICIPATED WAS REAL: measured, ParseExact(s, {"a", "b"}) and {"one"} were ambiguous while three or more elements were not, because two const char* in braces match basic_string(InputIt, InputIt) over two unrelated pointers -- and if that candidate had ever won, the result would be undefined behaviour rather than a wrong answer, which is what decided a fix over documenting a papercut. std::initializer_list<std::string> overloads resolve it, a braced list binding by a list-initialization sequence that outranks any user-defined conversion, with a case asserting the unbraced single-format overload is still reachable. Four mutations, three caught; M3 is a proven equivalence recorded at the site -- with the empty-input guard removed every format fails an empty input anyway, and .NET's is an equivalence too, both its arms being SetBadDateTimeFailure. The span-like shapes are recorded rather than taken: every exact door here takes const std::string&, and a second text representation beside it repeats the very hazard this ticket closed, in a place where the wrong branch is silent rather than ambiguous. Downstream zero sites. It was 17,725 immediately before, measured on 2026-08-20 by ticket #2416. +5 on the 17,720 below, in SharpRuntimeTests_Core_Base (6,136 -> 6,141); no other executable moved, so nothing that formatted a date changed its answer. DateTime::ToString had NO standard-format table at all -- measured, ToString("o") emitted the literal "o", ToString("s") returned "0" by reading s as seconds, "d" returned "15" by reading it as the day, and "%d" rendered "%15". Three distinct defects in one member: an unrecognised specifier emitted as a literal; a one-character custom specifier accepted where .NET requires %d, so "s" and "d" gave a plausible wrong answer rather than a visible failure; and % not being the escape at all. It mattered now because #2414 and #1942 gave the PARSE side a table, so the two halves of one type disagreed about what o means -- the #2393 shape, one type over -- and #1939 had already recorded the exact missing rule. THE TABLE ALREADY EXISTED AND WAS SIMPLY NOT CALLED: DateTimeFormatInfo::GetAllDateTimePatterns(char) carries all nineteen specifiers, culture-aware, so this is a wiring repair rather than a new table -- which also means a provider's patterns are honoured for free, #1940's route again. Two exceptions, two contracts: ToString raises FormatException where GetAllDateTimePatterns raises ArgumentException, and emitting the character as a literal was neither. Two custom tokens came with it -- the formatter had no t/tt and no K, both read by the parse side since #1939 and #1942, two more rows where the halves disagreed -- and K for a LOCAL value emits nothing, stated rather than discovered, because a local marker needs a zone Core.Base cannot name and there is no parameter here to carry one; Unspecified emits nothing too, which is the same rule as K matching the empty string when parsing. Seven mutations, all caught, with the round trip asserted as a property rather than two tables that happen to match. A first cut of one case wrote ToString("K") and threw, a one-character format being the standard reading and K not one of the nineteen -- the test tripped over its own subject, and the row is kept as evidence the rule bites. Downstream zero sites. It was 17,720 immediately before, measured on 2026-08-20 by tickets #1943 and #1945 (SA-16.2/16.3/16.5/16.6), which closes #1943. +6 on the 17,714 below: SharpRuntimeTests_Core_Base 6,132 -> 6,136 and SharpRuntimeTests_Xml 524 -> 526 (#1945's declaration pin inverted, three cases in its place). HALF A -- DateTimeOffset::ParseExact, and the measurement that made it possible is that AN OFFSET IS NOT A TIME ZONE: a format carrying an explicit offset needs no zone database whatever, the offset being read from the input and stored. The value is CAPTURED, not adjusted -- this is not the DateTime matrix with a different result type, the wall-clock time staying exactly as written -- and only the no-offset case needs a zone, .NET's own comment saying "AssumeLocal causes the offset to default to Local. This flag is on by default for DateTimeOffset." SA-16.6 accepted the cost knowingly and it is larger here than for DateTime, since every offset-less format needs a zone, so the message names all three routes out -- a caller hitting it has three genuinely different fixes and no way to guess them from "zone was null". The parameter is styles here where DateTime's is style, .NET varying it by overload, both transcribed rather than harmonised. HALF B -- XmlConvert round-trips a kind, and the decision went further than #1945's own sentence: that ticket predicted its pin would fail "the day #1942 teaches Parse to read a Z", and the reading half deliberately does NOT go through DateTime::Parse -- SA-16.4 left it alone, still discarding the zone, so a round trip built on it could never carry a kind however the writer rendered, and .NET does not use DateTime.Parse here either, building an XsdDateTime that parses the zone itself. The writing half is the full XsdDateTime form, two changes rather than one -- appending only the marker would have repaired the round trip and left the document wrong, an XSD dateTime literal requiring the T -- with the fraction trimmed and an Unspecified value writing no marker at all. A numeric offset is CONVERTED rather than stamped, because it names an instant and stamping would make +05:00 and +02:00 give the same wall clock -- the offset read and thrown away again, which is the defect the ticket exists to end -- and the marker is matched as a SHAPE rather than by scanning back for a sign, since 2024-06-15 ends in 06-15. Both ToDateTime doors go through one, the #2393 shape being what a second route would recreate. Fourteen mutations across the two halves, all caught; one was invalid as first written TWICE and is recorded rather than counted -- it left zone->GetUtcOffset running after the guard and segfaulted, and a segfault is undefined behaviour rather than a verdict, which the harness must not read as a pass. FOUND ON THE WAY AND FILED RATHER THAN BUNDLED: #2416 -- a probe measured that DateTime::ToString has NO standard-format table at all, ToString("o") emitting the literal "o", ToString("s") returning "0" by reading s as seconds, and "%d" rendering "%15"; so after #2414 and #1942 gave the parse side a table, the two halves of one type disagree about what o means. Downstream zero sites. It was 17,714 immediately before, measured on 2026-08-20 by ticket #1942. +6 on the 17,708 below, in SharpRuntimeTests_Core_Base (6,126 -> 6,132; two #2414 pins inverted rather than deleted, since they asserted the boundary this ticket moves). #2414 left the style-taking overloads absent and PINNED absent and said why; SA-16.1 took that decision and the zone is a parameter -- #1941 phase 2's own shape, so the port answers the question once rather than twice, with both alternatives declined on the record (accepting only the stamping styles would make a legal .NET style illegal here; a registration hook is the hidden global state #1940 already refused). The grammar gained a zone token -- z/zz/zzz/K, rejected in every mode before -- admitted behind a flag DateTime's doors set and DateOnly's and TimeOnly's do not, pinned; g stays rejected everywhere, being a different absence (no era table). Widths differ and collapsing them changes which inputs parse (zzz and K carry :mm, z and zz do not), and K alone matches the EMPTY string, which is .NET's rule rather than leniency because K renders empty for an Unspecified kind -- so o got its K back, the row #2414's header said a later ticket must revisit. Two cases, and the second is not the first with a default: with a zone in the input the Assume* styles do not apply at all, .NET saying so in its own comment; with none, four of five outcomes return without converting anything and the fifth is the row a reader most expects to be different -- AssumeUniversal ALONE comes back LOCAL, because .NET sets the offset to zero and falls through to the local adjustment. RoundtripKind fires only for a literal Z: .NET tests ParseFlags.TimeZoneUtc, so +00:00 -- the same instant -- is converted rather than stamped, and no assertion about the VALUE can separate the two, only the kind. An offset outside +-14:00 is a failure rather than a clamp. The deviation is one parameter and is stated: a null zone is an error only when a style actually needs one, so the default costs nothing and a converting style raises ArgumentNullException naming zone and telling the caller to pass CurrentTimeZone() -- a diagnostic where #2414 had a silent mismatch, with a case asserting the same input and door succeed under RoundtripKind, which is what shows the throw is about the conversion rather than the token. Nine mutations, all caught; M7 was NOT CAUGHT at first and found a defect in my TEST rather than the code -- the +-14:00 bound looked pinned by "+15:00", which the scanner's own coarse hours > 14 already refuses, so +14:59 is the row that separates the two guards and both signs are now asserted. The tests reuse #1941 phase 2's FixedZone rather than declaring a second, two zone doubles in one file being the shape that lets two suites drift apart. Downstream zero sites. It was 17,708 immediately before, measured on 2026-08-20 by ticket #1943 (TimeSpan half). +8 on the 17,700 below, in SharpRuntimeTests_Core_Base (6,118 -> 6,126). TimeSpan's whole parse surface was Parse and TryParse -- no ParseExact in any spelling -- and TimeSpanStyles existed in modules/globalization with nothing able to consume it, #1997 A-3's shape. TimeSpanStyles.hpp moved into Core.Base, #1940's shape C for the third time and again with not one #include line changed; graph unchanged at 41 / 95. THE OBVIOUS IMPLEMENTATION -- REUSE THE DATE/TIME SCANNER -- WOULD BE WRONG IN A WAY THAT PASSES MOST TESTS, and .NET keeps the two apart for reasons visible in the token table: an unquoted literal is an ERROR here, TryParseByFormat's switch ending in default: SetInvalidStringFailure, so "hh:mm" is not a valid TimeSpan format and the colon must be quoted or escaped -- the date/time scanner matches an unquoted literal instead, so a shared scanner would silently accept a format .NET rejects; there is no sign token at all; each component may appear once; d's digit rule is not the others' (one specifier means 1..8 digits, more than one means exactly that many, so a uniform "exactly tokenLen" gets days wrong and passes every hour/minute/second row); and f requires its digits where F does not, .NET calling the same reader for both and ignoring the result for F. AssumeNegative is therefore the ONLY route to a negative result -- ParseExact("-01:30", "hh':'mm") fails -- which is what makes the style load-bearing rather than decorative; the standard formats ignore it, as .NET does, because c carries its own sign. The bounds are per COMPONENT rather than a total range (days <= 10675199, hours <= 23, minutes <= 59, seconds <= 59), so "25" against "hh" fails rather than carrying into days. c/t/T are implemented as one format under three names; g and G are pinned ABSENT because they are the localized standard formats, needing a culture decimal separator this port has no database for and optional components the custom scanner cannot express. A Try* method that throws: an illegal style raises where a parse failure returns false, and validation runs before the result is written -- two claims, each asserted. Nine mutations, all caught; two were invalid as first written and reformulated rather than counted, M2's anchor never matching the file (an absent anchor is a harness state, not a finding) and M9's first spelling rejected by -Werror=unused-variable. Downstream zero sites. #1943 still has DateTimeOffset::ParseExact, which needs a zone for the no-offset case where .NET's DateTimeStyles.None gives the LOCAL offset -- an offset is not a time zone, so a format carrying an explicit one would need no zone at all, and that route is recorded rather than taken because the default still needs #1942's answer. It was 17,700 immediately before, measured on 2026-08-20 by ticket #2415, unchanged from #1945's reading because #2415 touched no production code -- it repairs a GATE, and the build-policy violation that gate was committing on every run. check_selective_components.sh had been RED since 2026-08-19, behind a green test count: #1889 legitimately gave Text.Json a public Collections.Core dependency -- fail-fast enumeration needs detail::MutationCounter and the boundary validator rejected the private declaration outright -- while forbidden_text_json_collections still asserted List.hpp was unreachable. It went unnoticed because that script is not part of this rule's gate, so every checkpoint since was recorded green while it was red. The fixture is RETARGETED rather than deleted, because this file names it as an invariant: List.hpp was only ever a proxy for "Collections", and the proxy moves to BlockingCollection, the type that sentence is actually about -- strictly stronger, since Collections.Blocking publicly needs Threading, so the include can only compile if Text.Json has acquired a Threading requirement, which is what the surrounding assert_target_absent sharp_runtime_threading exists to prevent, now pinned by compilation rather than by a target name. A SECOND, INDEPENDENT DEFECT in the same script: MATRIX_ROOT="$(mktemp -d)" with nothing in the repository setting TMPDIR, so every run built eight selective component trees into /tmp -- the one place the build-resource policy exists to keep builds out of, and build-tmp/ is in the closed directory list described as exactly this redirect. The mechanism was designed and never wired up. And the proximate cause of the first defect surviving a day is that NOTHING RAN THE SCRIPT: local_ci_check.sh ran the boundary validator, the seam checker and the negative-fixture checker but not this one. It now runs last, costs a measured ~10 minutes at two jobs stated in the script rather than left as a surprise, and is deliberately not behind an opt-out -- a check that can be skipped is the check that rotted. It was 17,700 immediately before, measured on 2026-08-20 by ticket #1945. +6 on the 17,694 below, in SharpRuntimeTests_Xml (518 -> 524). #1945 makes four XmlConvert members honour arguments they accepted and discarded -- two format parameters and two XmlDateTimeSerializationMode parameters, spelled /*format*/ and /*mode*/ in the bodies -- so ToDateTime("2024-06-15", "HH:mm:ss") succeeded, the value having been parsed by an entirely different grammar with no diagnostic: the SR-AUD-168 shape four times over. The mode half carried a premise that had stopped being true, its comment saying System::DateTime does not track a DateTimeKind -- #1941 phase 1 gave it one and phase 2 made it convert by it. This could land while #1942 stays blocked because modules/xml can reach a zone where Core.Base cannot: phase 2 had to take an ILocalTimeZone as a parameter, and here TimeZone depends on Core.Base alone so a private dependency is no cycle and System::TimeZone::CurrentTimeZone() already is an ILocalTimeZone -- so the deviation #1941 recorded is resolved by the module that can actually name the zone, and XmlConvert's signatures stay exactly .NET's with no zone parameter. Only two of the mode matrix's eight cells move the ticks and the rest stamp or pass through, transcribed cell by cell and written once rather than twice, because .NET writes the switch twice and two copies of one matrix is how two doors come to disagree. A limitation was found by a test of mine FAILING and is declared rather than hidden: RoundtripKind exists to carry a kind through a string and here it cannot, since DateTime::ToString() emits no kind marker where .NET's XsdDateTime does and DateTime::Parse reads none -- so through the parse door Local and Utc always stamp and never convert, while through the format door they convert, and RoundtripKind and Unspecified are observationally identical, measured over every input kind and both doors. That is #2414's no-zone-token boundary one level up, and closing it is #1942's work. ToDateTimeOffset(s, format) is composed rather than duplicated: this port has no DateTimeOffset::ParseExact, and with no zone token in the format .NET's DateTimeStyles.None gives the local offset, so the one exact grammar plus the local zone's offset is what .NET computes rather than an approximation of it. Seven mutations, five caught and two proven equivalences -- M4 and M6 swap the RoundtripKind and Unspecified arms, and an assertion catching them would have to distinguish two modes this port cannot; two more were invalid as first written and reformulated rather than counted, M2's first spelling being a no-op (ToUniversalTime on a Utc value returns it) and M7's rejected by -Werror. A mistake of my own is recorded because it is the SECOND occurrence: cat >> to a path that did not exist created a stray untracked test file with no includes, exactly what #2412 recorded -- twice is a pattern, not a slip. Module graph 41 / 94 -> 41 / 95 with the catalogue regenerated; downstream zero sites. Running the selective-component check for this change found it ALREADY RED, and red on a clean tree -- forbidden_text_json_collections compiles because #1889 legitimately gave Text.Json a public Collections.Core dependency, and it has been failing since 2026-08-19 behind a green test count, because that script is not part of this rule's gate; filed as #2415 rather than passed over. It was 17,694 immediately before, measured on 2026-08-20 by ticket #1997 group A-2, which closed #1997. +5 on the 17,689 below, in SharpRuntimeTests_Uri (314 -> 319); SharpRuntimeTests_Net is unchanged at 340, which is the evidence the scanner move below changed no behaviour. Uri::CheckHostName lands, and UriHostNameType had documented it since the enum was ported while nothing in this runtime could produce a value of that type. The recorded cost was understated and the measurement corrects it: #1997 priced A-2 as "a new public module edge to reach System::Net::IPAddress, or a second address-literal parser", and the first is impossible rather than expensive -- modules/net declares PUBLIC_DEPENDENCIES ... Uri, so that edge is a cycle, the dependency inversion Guid.cpp refused for cryptography, which is why a first cut written against IPAddress was rejected by the boundary validator. The route taken is a third one neither option named: modules/net's IPv4 and IPv6 scanners are pure string-to-number scanners with no platform call and no dependency on IPAddress itself, so they moved verbatim into Core.Base -- both modules already depend on it, so the graph does not change (41 / 94), there is one definition instead of two, and IPAddress.cpp is -197 / +11 lines; validatedScopeId, formatIPv4 and formatIPv6 stayed behind, being about that type rather than about the grammar. IPv4-before-DNS is where .NET's order decides an answer rather than tidying: CheckHostName passes allowIPv6=false, unknownScheme=false, which selects ParseNonCanonical, so "1", "0x7F.1" and "3232235777" are IPv4 though all three are also good DNS labels, while "1.2.3.4.5" falls through to Dns. Two rules are new with this member: the label rules -- first character an ASCII letter or digit, length 1..63, and a trailing dot accepted -- which this port's constructor never needed, since it only ever asked about characters; and an UNBRACKETED IPv6 literal is still IPv6, which reads like a bug until the reference is read, .NET retrying IsValid($"[{name}]") (Uri.cs:1320-1324), because the question is what a string is rather than whether it may appear in an authority. The constructor's character loop was factored so there is ONE definition -- two grammars for one question is the #2393 shape -- while the two remain different questions, the constructor applying no label rules, so Uri("http://-x/") parses where CheckHostName("-x") is Unknown, asserted together rather than left to be discovered. Seven mutations, all caught; M6 was NOT CAUGHT at first and found a defect in my TEST rather than in the code -- "the entire name must be consumed" was asserted with "[::1]junk", which fails on the front/back guard and so never enters the bracketed branch at all, so a body measuring to the first ] passed it; the input that separates them is bracketed at both ends with junk inside, "[::1]]". Downstream zero sites, the member having not existed. It was 17,689 immediately before, measured on 2026-08-20 by ticket #1997 group A-4. +6 on the 17,683 below, in SharpRuntimeTests_Uri (308 -> 314). A-4 is the change SA-3's vtable exclusion was blocking and SA-15.3 released, and it closes SR-AUD-146. UriParser is the extensibility point for custom URI schemes and it could not extend anything: Register did not exist at all, so a subclass could be written and then had nowhere to go -- A-3's shape one member over -- and three override hooks were public where .NET's are protected, so any caller holding a UriParser& could invoke another parser's hook directly. .NET states that rule in a comment of its own, describing its internal forwarders as existing "to avoid protected internal signatures in the public docs" (UriSyntax.cs:245-246), and the migration is that same shape rather than a workaround: a subclass publishes a forwarder, exactly as InternalGetComponents is. The registration is OBSERVABLE, which is the whole point -- a Register that validated and stored into a table nothing reads would be accepted-and-ignored, the SR-AUD-168 defect, so IsKnownScheme now consults the registry and mutation M1 breaking that link is caught by four cases. Three rules are transcribed rather than derived: a one-character scheme is refused though CheckSchemeName accepts it -- two rules disagreeing on exactly one input, with the narrower first; the port range is 0..65535 plus the sentinel -1, because .NET's test casts to uint and the cast IS the rule, so a naive port > 0xFFFF accepts -2; and OnRegister runs BEFORE the scheme is stored, so inside the callback the parser does not yet know its own scheme, which is what makes the parameter load-bearing. Two distinct InvalidOperationExceptions are kept distinct because they answer two different questions -- is this parser already registered, is this scheme already taken. A mistake of my own is recorded rather than quietly fixed: .NET keeps built-ins and customs in ONE table, this port MUST split them having no parser objects for the sixteen built-ins, and a first cut checked only the custom map and would have let a caller claim gopher -- the split is permanent, so the same mistake is available to every later change. sizeof(UriParser) 8 -> 48, pinned as a relationship as well as a literal; consumers deriving from it rebuild. SA-15.3's fourth condition is discharged as empty and said so: no exception type was introduced, reparented or removed, so no catch clause changes meaning. Eight mutations, all caught -- M8, republishing a hook, is invisible to gtest because a widened hook behaves identically wherever both compile, and is caught by the negative fixture (52 / 264 -> 53 / 269), verified by running the checker with the mutation applied. One verdict was harness noise and is recorded rather than counted: a re-run of M7 anchored on a substring occurring twice, so the edit never applied and the run reported NOT CAUGHT -- an ambiguous anchor is a harness state, not a finding. Four further .NET hooks stay absent with reasons: OnNewUri would have no caller, since this port's Uri never consults a parser, and InitializeAndValidate/Resolve take an out UriFormatException with no uninvented C++ counterpart and reach uri._syntax, private state this port has not -- and that nothing calls the hooks at all is declared in the header rather than left to be discovered. Downstream zero sites in both consumers, so no #1773-shaped ticket was owed. Module graph unchanged at 41 / 94. #1997 now has only A-2 left. It was 17,683 immediately before, measured on 2026-08-20 by ticket #2414. +7 on the 17,676 below, in SharpRuntimeTests_Core_Base (6,111 -> 6,118). #2414 gives DateTime a ParseExact, which it did not have in any spelling -- its entire parse surface was Parse(s) and TryParse(s, result), so #1942's style contract had nowhere to land, the same cycle #2412 resolved for DateOnly/TimeOnly one type over. The obstacle was the scanner rather than the type: MatchExactFormat took a bool forDate and ran one of two blocks, the date block rejecting every time token and the time block every date token, so a format naming both families could not be matched by any spelling. Admitting both resolves no ambiguity, which is what makes the widening safe -- the sets are disjoint, M being a month and m a minute in two case-sensitive languages -- so the other family's tokens now fall through and each rejection survives guarded on that family not being admitted; DateOnly/TimeOnly are behaviourally unchanged, pinned, and mutation M3 (admit both for DateOnly) is caught. Two of .NET's standard patterns are transcribed with a NAMED loss: o is ...fffffffK there, and since K renders empty for an Unspecified kind the pattern here is .NET's o for that kind while refusing the Z and +hh:mm forms; u's Z is a literal, not a zone token, so it is required and sets no kind. This port's exact grammar carries no zone token at all (z, K, g rejected in every mode), which is also why #1942's RoundtripKind would have nothing to preserve. The style-taking overloads are deliberately absent and pinned absent, and that is a decision rather than an omission: AssumeLocal/AssumeUniversal only stamp a kind, but AdjustToUniversal must convert and conversion needs a local zone -- .NET reaches TimeZoneInfo.Local internally where Core.Base cannot, which is exactly what #1941 phase 2 resolved one level down by taking the zone as a parameter -- so the overload needs the same decision made about its signature, and it was not taken unilaterally; .NET's three-rule, three-message ValidateStyles (DateTimeFormatInfo.cs:1720-1743) is transcribed into #2414's record for whoever takes it. A complete date is mandatory while time components default to midnight, which is .NET's own asymmetry: ParseExact("2024-06-15", "yyyy-MM-dd") is a valid call returning 00:00:00, whereas a time-only format is refused because NoCurrentDateDefault decides that case and is #1942's -- refusing it is what stops an invented default shipping under a ticket that has not been asked about it. Eight valid mutations, all caught; M2 is a proven equivalence recorded at the site, since every date-token arm ends in continue so a date token cannot reach the time block's rejection while the date block runs, and the condition is kept because it states why the rejection is correct. M7 was invalid as first written (-Werror=unused-parameter) and was reformulated rather than counted. A first run of M4-M8 was invalid and is recorded rather than discarded: the restore after M3 used git checkout on a file whose change was still uncommitted, so five consecutive runs reported BUILD FAILED -- the #2374 restore mistake in a new form, caught by the same rule that five identical failures in a row are a harness state, not five findings. Downstream: zero sites in both consumers, the member having not existed. It was 17,676 immediately before, measured on 2026-08-20 by ticket #1980 G-3. +5 on the 17,671 below, in SharpRuntimeTests_Runtime (199 -> 204). G-3 is the first change to land under SA-15.3, which lifted SA-3's exclusion of vtable and base-class changes, and it closes #1980 -- G-1, G-2, G-4 and G-5 landed 2026-08-19. AmbiguousImplementationException is reparented from SystemException to Exception, sealed, and gains .NET's (message, inner) constructor; OSPlatformAttribute is introduced and the five platform attributes derive from it and are sealed. SA-15.3's fourth condition -- enumerate every catch whose meaning changes -- is the part no layout assertion can see, and it was discharged by measurement: the clause that moves is catch (const System::SystemException&), and there are zero such clauses anywhere that can receive this type; the 17 first-party ones are hierarchy tests for other types and cna's single one catches its own NoAudioHardwareException. The pin asserts all three rows -- SystemException, Exception and the type itself -- so a later reparenting cannot quietly take the other two. Before G-3, five of .NET's six platform attributes derived from System::Attribute directly and each carried its own copy of platformName_ -- five duplicates of one fact, and no type through which a caller could handle any platform attribute, which is SR-AUD-163. protected, not private protected: C++ has no "derived classes in the same assembly", and the half that is not expressible is stated rather than pretended. TargetPlatformAttribute is .NET's sixth derived type and is absent, said so five is not mistaken for the set. Nothing grew: the exception stays 168 because SystemException adds no members over Exception, and the attributes stay put because platformName_ moved into the base rather than beside it -- the rebuild is required by the vtable, not the size, and the pins assert relationships rather than only literals. Six mutations, all caught, five at compile time, which is the only way C++ reports a shape; reverting the base is caught twice over. One correction of my own is recorded: a first version of the ticket note said G-4 was still outstanding -- copied from the review plan's original table instead of the ticket's own record, and corrected on re-reading it. Downstream zero sites, recorded as #2413. It was 17,671 immediately before, measured on 2026-08-20 by ticket #1941 phase 2. The current verified baseline is 17,671 tests across 38 executables with THE GATE GREEN -- 17,671 run, 17,671 passed, 0 failed, 0 skipped, measured on 2026-08-20 by ticket #1941 phase 2. +6 on the 17,665 below, in SharpRuntimeTests_Core_Base (6,105 -> 6,111). #1941 phase 2 makes DateTime convert by its Kind, which unblocks #1942, #1943 and #1944. The recorded blocker looked at the wrong type: it said "a date-sensitive timezone/DST model" and the natural place to look is TimeZoneInfo, whose GetUtcOffset(const DateTime&) ignores its argument and whose IsDaylightSavingTime/IsAmbiguousTime/IsInvalidTime are always false -- all documented limitations of that type. System::TimeZone::CurrentTimeZone() is the per-date one, resolving the offset and the DST flag for the instant given; it describes only the process-local zone, which is precisely the zone these two members convert against, so the model the record wanted was present all along, on the other type. Landed as SA-15.1's abstraction: System::ILocalTimeZone in Core.Base, two members and deliberately not a zone in miniature, which System::TimeZone implements at no cost because it already declared both with the same signatures. The deviation is stated rather than discovered later: .NET's ToLocalTime() takes no argument because it reaches TimeZoneInfo.Local directly, and Core.Base has nothing to ask -- so the zone is a parameter, the alternative (a registration hook with hidden global state and a static-initialisation-order dependency) having been rejected for the reason #1940 gave; the no-argument forms stay absent and phase 1's absence pin, which names exactly those, still holds. The Unspecified asymmetry is .NET's: DateTime.cs:1707 tests only the Local bit and :1772 returns early only for Utc, so an unspecified value is read as UTC one way and local the other -- a repair that harmonised them would pass every ordinary case and fail only there, so each direction has its own mutation. Overflow clamps at both ends. LocalAmbiguousDst is still not produced, because it needs an ambiguity answer and this port's IsAmbiguousTime is documented as always false; producing it would fabricate a distinction the runtime cannot make. Seven mutations, all caught -- and the last only after a one-tick zone was added: a clamp written < -1 instead of < 0 still clamps every ordinary underflow and differs on exactly one input, so a first run reported it NOT CAUGHT. A boundary one tick wide needs an input one tick wide. Downstream zero sites. It was 17,665 immediately before, measured on 2026-08-20 by ticket #2410. The current verified baseline is 17,665 tests across 38 executables with THE GATE GREEN -- 17,665 run, 17,665 passed, 0 failed, 0 skipped, measured on 2026-08-20 by ticket #2410. +3 on the 17,662 below, in SharpRuntimeTests_Globalization (688 -> 691). #2410 makes both public doors reject a culture name that is not a well-formed BCP 47 tag. What it replaced was worse than "falls back to invariant": CultureInfo("xx-YY") succeeded, reported "xx-YY", and drew its formats from the invariant culture -- an object that lied about what it was. And CultureNotFoundException already existed and was already thrown, but only from the LCID path, so one door of one type rejected what the other accepted (#2393's shape). The boundary is deliberately wider than .NET's and the header says so rather than implying it: measured, .NET in this port's own invariant-globalization mode accepts only "" and "und" and throws for every other name, "de-DE" included (CultureData.cs:660-675, GlobalizationMode.cs:19); this port has no culture database at all, so that rule would leave CultureInfo unable to represent any named culture. One narrowing past BCP 47 is what makes the check work: RFC 5646 allows a 5-8 letter registered primary subtag, under which "process-default" is well formed -- and a first cut accepted it, missing the exact case the decision names -- so the primary subtag is restricted to the 2-3 letters every real culture name uses, losing the rare registered form, stated in the header. An accepted name still means nothing about available data, which a case asserts: "de-DE"'s month names are still "January". Seven mutations, six caught; two were not caught at first and both were genuine gaps -- the malformed list exercised only the primary subtag, so the over-long and non-alphanumeric checks on later subtags were untested; one is a proven equivalence (breaking "und"'s case-insensitivity changes nothing, because it is a well-formed three-letter primary subtag the general rule accepts anyway); and one was invalid as first written, rejected by -Werror for an unused lambda rather than by the tests. Downstream zero sites. It was 17,662 immediately before, measured on 2026-08-20 by ticket #2412. The current verified baseline is 17,662 tests across 38 executables with THE GATE GREEN -- 17,662 run, 17,662 passed, 0 failed, 0 skipped, measured on 2026-08-20 by ticket #2412. +5 on the 17,657 below, in SharpRuntimeTests_Core_Base (6,100 -> 6,105). #2412 exists because #1942 and #1943 listed each other: #1942 waited for "the relevant exact overload" -- one taking a DateTimeStyles, and measured, nothing in modules/ accepted one at all -- while #1943 waited for "#1940-#1942". What separates cleanly needs no approval: #1942's remaining blocker was "a reliable timezone contract", true only of the kind-affecting styles, and DateOnly/TimeOnly have no DateTimeKind, so .NET rejects every one of those outright and the whole contract is one line (DateOnly.cs:317-320). The styles that would need a timezone contract are exactly the styles that are illegal there. Both types' ParseExact/TryParseExact now take a provider and a style, purely additively (both parameters defaulted). A Try* method that throws -- an illegal style raises where a parse failure returns false, which is .NET's (DateOnly.cs:519-522) -- and validation runs before the result is written, asserted because throws and leaves the out-parameter alone are two claims. AllowInnerWhite skips at a token boundary, not anywhere: a field reader consumes its own digits without consulting the style, so "20 24-06-15" against "yyyy-MM-dd" still fails on the four-digit year -- the row that separates .NET's rule from a blanket skip, and the first cut put the skip in the wrong branch (the %-escape rather than the literal run) where the test caught it. A gap #1939 recorded honestly is now closed: it wrote that longest-first name matching was "defensive rather than load-bearing" because no invariant name is a prefix of another and a first-match mutation went uncaught -- a provider's names carry no such guarantee ("Ma" and "March" are a legal pair), so the rule now decides the answer and M6 is caught. DateTimeStyles.hpp moved into Core.Base -- #1940's shape-C move again, no include line changed, graph still 41 / 94. Eight mutations: five caught, two proven equivalences (both empty-name guards are defence in depth, because len <= bestLen with bestLen starting at zero already excludes a zero-length name arithmetically), and three invalid as first written -- -Werror rejected them for an unused variable, so the verdict said nothing about the tests -- reformulated rather than counted. One mistake of my own is recorded: the first append created a new DateOnlyTests.cpp that did not exist, so the block had no includes; the real home is DateOnlyTimeOnlyTests.cpp and the stray file was removed. #1942 and #1943 stay blocked on their DateTime halves alone, which need #1941 phase 2's unapproved timezone provider. It was 17,657 immediately before, measured on 2026-08-20 by ticket #1940. The current verified baseline is 17,657 tests across 38 executables with THE GATE GREEN -- 17,657 run, 17,657 passed, 0 failed, 0 skipped, measured on 2026-08-20 by ticket #1940. +6 on the 17,651 below, in SharpRuntimeTests_Core_Base (6,094 -> 6,100). #1940 is the root of the remaining date/time chain and it is now closed, which unblocks #1942, #1943 and #1945 directly and #1944 through #1943. The blocker was two things and the ticket's own record named only one: the component cycle and the fact that nothing in this runtime implemented IFormatProvider at all -- not DateTimeFormatInfo, not NumberFormatInfo, not CultureInfo -- so GetFormat had zero implementations and a caller could not build a provider from a culture even in principle; the record's "shape A is feasible today" was true and still not enough, because there was nobody to ask. Shape C's measured claim held exactly: DateTimeFormatInfo.hpp and CalendarWeekRule.hpp moved into Core.Base and git diff --stat showed only the two renames -- ownership is by logical path uniqueness, so not one #include anywhere changed -- graph unchanged at 41 / 94, selective Core.Base consumer build green. CalendarWeekRule had to move too, and that is the whole of two files: leaving it behind would have recreated the cycle through the enum. The provider is honoured rather than accepted and ignored, which is #1940's own criterion and exactly why the overload went on ToString(format, provider) and not on Parse -- the formatter had hard-coded month and day name tables and now reads them from the resolved info, while this port's general date parser reads no culture-driven token at all, so a provider overload there could only accept and ignore one (that is #1942). "Keep all parsers semantically unchanged" is structural rather than asserted: ToString(format) is now literally ToString(format, nullptr), so there is no second formatter left to drift. One index base moved and it is .NET's -- the old tables were 1-based with an empty slot 0, DateTimeFormatInfo's are 0-based with an empty thirteenth slot -- pinned across the full range, because December and January are the two months an off-by-one gets wrong in opposite directions. One deviation is named rather than left to be discovered: GetInstance(nullptr) resolves to the invariant info, not the current culture's, because CultureInfo stays in Globalization; this is not new -- the port has answered CurrentInfo that way since the type was ported, and since #2409 CurrentCulture is the invariant culture until something sets it, so the two agree in the default state; a caller who wants the current culture's info passes that culture. Six mutations, five caught; the sixth is a proven equivalence (removing the as DateTimeFormatInfo shortcut changes nothing, because GetFormat returns this for that type) observable only for a derived class -- a shape .NET forbids by sealing the type where this port does not, an asymmetry recorded and deliberately not closed. Two of the six were NOT CAUGHT at first and both were genuine gaps: a GetFormat answering for any type passed everything, because every case asked only for the one type it should answer for; and no case passed a CultureInfo as a provider at all -- the very route the ticket exists to open. Downstream zero sites. It was 17,651 immediately before, measured on 2026-08-20 by ticket #2409. The current verified baseline is 17,651 tests across 38 executables with THE GATE GREEN -- 17,651 run, 17,651 passed, 0 failed, 0 skipped, measured on 2026-08-20 by ticket #2409. +5 on the 17,646 below, in SharpRuntimeTests_Globalization (683 -> 688). #2409 makes CultureInfo::CurrentCulture per-thread, which its own doc-comment had claimed all along: both current-culture members were process-wide statics, so a set on one thread changed what every other thread read, and a concurrent get/set was an unsynchronised read/write of a non-atomic object. The obvious repair was the wrong one, and that is why this was a decision (SA-14) rather than a transcription: a bare thread_local would have fixed the race and silently removed the process-wide setting this port accidentally had, with no replacement and no diagnostic. .NET's own answer is a second property, so the port grew .NET's three-step chain (CultureInfo.cs:358-366) -- a thread_local that is absent by default, then DefaultThreadCurrentCulture, then the invariant culture. Absent rather than invariant-valued at step 1 is load-bearing: not chosen and chose the invariant culture are different facts and only the first may fall through. The getter returns a reference, so the fallback could have moved the race one level down -- the next store drops the last owner and frees what the caller holds -- and the default is therefore an atomic<shared_ptr<const CultureInfo>> whose loaded pointer the reader parks in a thread_local holder; the held reference may be stale but is never invalid, which a case asserts. Six mutations, all caught, two at compile time. The ownership case had to be made DETERMINISTIC before it caught anything, and that is the result worth carrying forward: run as a race -- a churn thread swapping the default while a reader took and read a reference -- neither gtest nor ASan caught it over 20,000 iterations, the window being nanoseconds wide, with the sanitizer confirmed to build and run clean on the control first; replacing the default from the same thread removes the timing entirely and the mutation is then caught on every run. Two test defects of my own are recorded rather than quietly fixed: the first cut set the culture on the test thread and never restored it, leaking into the binary and making three unrelated pre-existing cases fail under mutation, and a mutation run reported three false NOT CAUGHT results because the harness's filter still named the fixture by its old name -- a harness error, not a result. A consequence found by the first of those deserves its own line: the save/restore idiom previous = get...(); ...; set...(previous) no longer restores what it used to -- it turns chosen nothing into explicitly chose invariant, and only the former falls through to the default; .NET has the same asymmetry, its setter taking a non-null value, so this is faithful rather than a port artefact. Downstream zero sites, recorded as #2411. It was 17,646 immediately before, measured on 2026-08-20 by ticket #2406. The current verified baseline is 17,646 tests across 38 executables with THE GATE GREEN -- 17,646 run, 17,646 passed, 0 failed, 0 skipped, measured on 2026-08-20 by ticket #2406. +6 on the 17,640 below, in SharpRuntimeIntegrationTests (946 -> 952). #2406 closes the component-model sweep with two shape repairs and one declaration, which is the larger half. The eleven ValidationAttribute subclasses validate nothing -- .NET carries IsValid (ValidationAttribute.cs:352), Validate (:468,497), FormatErrorMessage (:330) and RequiresValidationContext (:139), and overrides IsValid on every subclass; none of those exist here. The names are what make that worth a @warning rather than a footnote: a caller who writes RequiredAttribute and sets an error message has every reason to believe something checks it, and nothing does -- there is no member to call, so the mistake surfaces as validation that silently never happened, and the stored message is inert, which a case asserts. It is a declaration rather than a repair because implementing it is not one member -- RegularExpressionAttribute needs the regex engine, EmailAddress/Url/Phone need .NET's exact grammars, and Validate needs ValidationContext and ValidationResult, neither of which exists here -- and the absence is pinned: mutation M7 adds an IsValid and is caught, so the declaration is enforced rather than merely written. DataTypeAttribute was structurally wrong: one public mutable std::string where .NET has two constructors with different meanings (DataTypeAttribute.cs:20,55) -- a known kind from an enum, or a custom one named by the caller -- so the kind and a custom name were the same field and any string was accepted where only seventeen values are meaningful. The DataType enum is transcribed exactly, the string constructor chains to Custom and stores the name beside the kind, and GetDataTypeName() throws .NET's own text for an unnamed custom kind -- on IsNullOrWhiteSpace, not IsNullOrEmpty, which is the row an .empty() reading gets wrong and which mutation M4 makes. .NET reads Enum.GetNames<DataType>(), which is reflection; the substitute is an exhaustive switch with no default:, so a new enumerator is a compile error rather than a silently missing name -- #1980 G-5's idiom, and stronger than a name table, which cannot pin an enum's membership. DisplayFormat is deliberately absent, because the constructor's three-case switch exists only to populate a type this port does not have. What is NOT a divergence is recorded too: RequiredAttribute::AllowEmptyStrings and DisplayAttribute's eight fields stay public data members, because .NET's are { get; set; } and a public field is observationally identical -- #1969's recorded reasoning -- so SA-8 does not reach them; what does still diverge there is nullability (the #2295 shape, eight signatures) and it is left open rather than half-done. Seven mutations, all caught; fixture set 51 / 260 -> 52 / 264; first-party migration zero sites; downstream zero sites, recorded as #2408. It was 17,640 immediately before, measured on 2026-08-20 by ticket #2405. The current verified baseline is 17,640 tests across 38 executables with THE GATE GREEN -- 17,640 run, 17,640 passed, 0 failed, 0 skipped, measured on 2026-08-20 by ticket #2405. +3 on the 17,637 below, in SharpRuntimeTests_ComponentModel (104 -> 107; one vacuous case replaced by one real one, three added). #2405 has two independent halves. Half A: PropertyChangedEventArgs and PropertyChangingEventArgs each carried two representations of one fact -- a private std::optional<std::string> and a public mutable std::string snapshotted from it -- where .NET's whole type is four lines ending in public virtual string? PropertyName { get; }. Three defects lived in that one member: it was lossy (value_or("") collapsed nullopt and "", the #2295 defect, and the absent state is .NET's documented "all properties may have changed"), it was mutable so a subscriber could retarget the args mid-dispatch, and after such a write the field and the accessor disagreed, so the object contradicted itself -- reachable only because the field was public. The header already documented the first defect and kept the field "for existing sharp-runtime consumers that predate the nullable-property port"; that reason was measured and is empty -- zero downstream sites, one first-party read. Half B: System::ComponentModel::Attribute is removed. There is no System.ComponentModel.Attribute in .NET -- measured, no such file in the reference tree -- and measured across this module, 20 attributes derive from System::Attribute, 11 from ValidationAttribute and zero from the removed type, which had no members, no derived classes and no callers. Its only appearance outside its own header was EXPECT_NO_THROW(System::ComponentModel::Attribute{}), the fourth "assertion that cannot fail" this sweep has found; what replaces it dispatches through a base reference, because a static_assert alone would pass against a base nothing can call through. Three mutations, all caught, and the pin they hang on is the point: with the second representation gone, the property worth asserting is that there is exactly one, so the case compares sizeof against a one-optional shadow struct -- which catches a reinstated field on either type where no value-based assertion would. Half B's removal is caught by the negative fixture rather than gtest, the only instrument that can see a type that no longer exists. Fixture set 50 / 256 -> 51 / 260; downstream zero sites for either half, recorded as #2407. DataAnnotations was split out as #2406 rather than bundled, because DataTypeAttribute holds an untyped mutable string where .NET has an enum, a separate custom-type constructor and two accessors -- closing it means adding public surface, not changing an accessor. It was 17,637 immediately before, measured on 2026-08-20 by ticket #2403. The current verified baseline is 17,637 tests across 38 executables with THE GATE GREEN -- 17,637 run, 17,637 passed, 0 failed, 0 skipped, measured on 2026-08-20 by ticket #2403. +6 on the 17,631 below, in SharpRuntimeTests_ComponentModel (98 -> 104; eight migrated sites rewritten in place, six cases added). #2403 gives six System::ComponentModel attributes .NET's shape, and what makes it more than a style point is that the port was inconsistent with itself inside one file: CategoryAttribute, BrowsableAttribute, DisplayNameAttribute and DescriptionAttribute already had the correct shape in that very header and its neighbour, while ReadOnlyAttribute, ImmutableObjectAttribute, LocalizableAttribute, MergablePropertyAttribute, NotifyParentPropertyAttribute and RefreshPropertiesAttribute published a bare mutable public data member and most had no statics and no equality members at all. All six are now final for sealed, with a get-only accessor and the full Yes/No/Default set. RefreshProperties becomes a top-level enum, where this port nested it as RefreshPropertiesAttribute::Refresh -- differing from .NET in both the name and the scope. The defaults are not uniform and that is .NET's: MergablePropertyAttribute::Default is Yes (MergablePropertyAttribute.cs:12) where the other four default to No, so a repair that harmonised the five would be wrong here and nowhere else -- pinned in one case, so it reads as an asymmetry rather than five unrelated literals. One divergence is deliberate and is pinned: .NET's GetHashCode for all six is base.GetHashCode(), identity, while its Equals is value-based, so two equal .NET instances can hash differently -- a hash-contract violation in the reference itself. This port does not reproduce it, and the reason is written down rather than chosen: System/Attribute.hpp's own doc-comment states the house rule ("A subclass that needs value equality must override both Equals and GetHashCode") and the four already-correct siblings use a value hash. Mutation M7 adopts .NET's identity hash and is caught, which is the evidence the choice is load-bearing. The module's whole prior coverage for these six was constructor round-trips through the public field -- nothing asserted the statics, the equality members or the defaults, so .NET's Default values, which are the actual contract of a metadata attribute, were unpinned. Seven mutations, all caught, three at compile time. No vtable change: the three members are already virtual on System::Attribute, so these are overrides of existing slots. First-party migration was 8 sites, all in this module's own test file, every one named by the compiler; fixture set 49 / 248 -> 50 / 256, its second site being the write, which is the spelling a careless migration would have kept longest because it compiled silently while changing the attribute's meaning. Downstream zero sites, recorded as #2404. A process note worth keeping: an early build reported zero errors and was nearly believed -- cmake --build … -k 200 is not keep-going, since -k must follow --, so CMake printed its usage banner and exited. That is this rule's own #2395 trap in a new form, and a mis-passed -k is worse than none, because it looks like success. It was 17,631 immediately before, measured on 2026-08-19 by ticket #2402. The current verified baseline is 17,631 tests across 38 executables with THE GATE GREEN -- 17,631 run, 17,631 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2402. +2 on the 17,629 below, in SharpRuntimeTests_Core_Base (6,092 -> 6,094; one case renamed in place and two added). #2402 closes the entropy sweep by recording what is NOT a defect, which is the half a sweep usually leaves out: the runtime has exactly five sites needing random bytes, two were repaired (#2398, #2401), and two are parity and look exactly like the defect -- HashCode::GlobalSeed and unseeded Random both call std::random_device, which is precisely what #2401 removed from ClientWebSocket, so a later reader would otherwise "fix" them into a divergence. .NET has TWO entropy entry points and picks between them deliberately (Interop.GetRandomBytes.cs:18-27, reaching different minipal functions per pal_random.c:13-27), and HashCode.cs:58,70-75 plus both Random.Xoshiro*Impl.cs:38 call the non-cryptographic one. There is a structural reason too: HashCode is in Core.Base, so a cryptographic seed would put a cryptography component under every consumer of Core.Base -- the inversion Guid.cpp already refused, and #2401's argument (a leaf module, a private edge) does not transfer. One defect did come out of looking: HashCodeTests.Seed_DiffersAcrossProcessesButConsistentWithinOne asserted only the second half of its own name, comparing two accumulators inside one process -- which a constant seed satisfies perfectly -- so the clause the property exists for was untested. That is the third assertion in this sweep that could not fail for the property it claimed, after the two EXPECT_EQ(buffer.size(), N) cases #2398 and #2399 replaced, and the pattern is now recorded in NEXT.md §4b as a search worth running on its own. A plain fork() cannot test it: the seed is a function-local static initialised on first use, so a forked child inherits it and reports the same hash whatever the source -- the child must re-exec, #1979's idiom for the same class of reason. Two children rather than one, so a constant seed is distinguishable from a 1-in-2^32 coincidence, reporting through a pipe on fd 3 so gtest's own output cannot be mistaken for the payload. The child driver uses SUCCEED() rather than GTEST_SKIP() deliberately: it runs in every ordinary run, and a skip would move this gate off "0 skipped" permanently, which is a documented property of this floor rather than an incidental one. Three mutations, all caught -- a constant seed and a build-time-fixed seed only by the new case, which is the evidence the repair was needed, and a per-instance seed by 22 pre-existing cases. One was invalid as first written and was reformulated rather than counted: a seed derived from a stack address is not the defect it was meant to be, because ASLR varies it per process. Record: docs/EntropySourceSweep.md. It was 17,629 immediately before, measured on 2026-08-19 by ticket #2401. The current verified baseline is 17,629 tests across 38 executables with THE GATE GREEN -- 17,629 run, 17,629 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2401. +2 on the 17,627 below, in SharpRuntimeTests_Net_WebSockets (105 -> 107). #2401 was found by asking #2398's question once more: #2228 put a real CSPRNG behind Guid::NewGuid, so what else in this runtime needs unpredictable bytes, and where does it get them? ClientWebSocket drew both its Sec-WebSocket-Key nonce and its per-frame masking key from std::random_device. That is a defect rather than a style point, and this repository had already measured why: the standard explicitly permits a deterministic std::random_device, and Random.cpp:69-70 records in its own comment "on a platform whose random_device is deterministic (MinGW-w64's historically was)" -- MinGW-w64 being a supported compile target here. On such a platform every connection sends the same nonce and every frame's mask is predictable. RFC 6455 does not leave that to taste: §5.3 says the masking key "MUST be derived from a strong source of entropy" and "MUST NOT make it simple for a server/proxy to predict the masking key for a subsequent frame", and masking exists to stop cache-poisoning of intermediaries (§10.3), so a predictable key defeats the one attack it was introduced for. .NET's two routes are different and are transcribed separately rather than harmonised: the nonce is Guid.NewGuid().TryWriteBytes base64-encoded (WebSocketHandle.Managed.cs:490-494) and the mask is RandomNumberGenerator.Fill (ManagedWebSocket.cs:762-763). The nonce cost no component edge at all, because #2228 already put the CSPRNG behind Guid::NewGuid and Core.Base was already public here; the mask took Security.Cryptography.Random as a private dependency, graph 41 / 93 -> 41 / 94, catalogue regenerated, and the selective-component consumer build re-run. Calling getentropy() directly from net-websockets was rejected on a recorded precedent -- it would be a third copy of the platform entropy call, the duplication #2354 spent a ticket removing. What cannot be observed here is stated rather than implied: on glibc std::random_device reads /dev/urandom, so the source change is not behaviourally visible on this platform, and the evidence is the reference, the RFC, this repository's own MinGW-w64 measurement, and symbol inspection, which is precisely discriminating -- after the repair random_device appears 0 times in that translation unit against 16 for either reversion, and the two reversions are told apart by which of Guid::NewGuid and RandomNumberGenerator::Fill drops to zero. Five mutations: the two source reversions are NOT caught by tests and are caught by the symbol table, reported as such rather than as passes, and the three RFC-property mutations are all caught. Those three are the plausible optimisations, not contrived defects -- a mask cached per connection still unmasks correctly at the server, because both ends agree on whatever key was sent, so it passes every other assertion in the file. One mutation was invalid as first written and was reformulated rather than counted: zeroing the mask left randomMaskingKey() unreferenced and -Werror=unused-function rejected it. What is newly pinned are the RFC properties themselves, which nothing pinned before: a 16-byte nonce differing between connections (§4.1) and a fresh mask per frame (§5.3), plus a separate assertion that no mask is all zeroes, which the freshness check alone would not catch for a single frame. It was 17,627 immediately before, measured on 2026-08-19 by ticket #2399. The current verified baseline is 17,627 tests across 38 executables with THE GATE GREEN -- 17,627 run, 17,627 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2399. +2 on the 17,625 below, all in SharpRuntimeIntegrationTests (944 -> 946; one vacuous case replaced by two real ones plus one shape pin). #2399 gives RNGCryptoServiceProvider the shape .NET declares -- public sealed class with [Obsolete], diagnostic id SYSLIB0023 (RNGCryptoServiceProvider.cs:8-10, message Obsoletions.cs:82) -- where this port had a non-final class with no deprecation and only the implicit default constructor. Two spellings are outlawed and they are outlawed for two different reasons, which is why each gets its own fixture site: deriving is rejected by the seal, and naming the type at all is rejected by the deprecation, since under -Wall -Wextra -Werror a deprecation is a hard error -- the boundary #2289 measured before this repository took its first [[deprecated]]. Site 1 suppresses the deprecation on purpose, so that it fails for the seal and only for the seal; without that the two reasons are indistinguishable and the site passes for the wrong one. Two of .NET's three missing constructors were added -- (string) and (byte[]), ignoring their argument exactly as .NET's do, and explicit, which is the faithful translation rather than a narrowing because a C# constructor never participates in an implicit conversion. (CspParameters?) cannot be transcribed and is not: CspParameters does not exist in this port, and inventing it to carry a type whose only behaviour is if (cspParams != null) throw new PlatformNotSupportedException() would be inventing public surface -- so what is pinned instead is the shape such an overload would introduce, static_assert(!is_constructible_v<Rng, std::nullptr_t>), which a later ticket adding the type must trip and justify. What is NOT a divergence is recorded too, so a later reader does not complete a complete set: .NET overrides four further members to forward to its inner generator, and this port overrides one and inherits the rest, whose base implementations call that same virtual. Six mutations, all caught -- and one by an instrument the test suite does not have: removing the [[deprecated]] is invisible to gtest, because a scoped suppression compiles perfectly well when there is no diagnostic to suppress, and it is caught by the negative fixture, sites 2 and 3, verified by running the checker with the mutation applied. Three more are caught at compile time by static_assert, the only way C++ reports a shape. One mutation was invalid as first written and was reformulated rather than counted: storing the byte[] argument in a member without reading it is a no-op, not a seed. The shipped case could not have caught any of it -- EXPECT_EQ(buffer.size(), 24u) on a buffer whose size was fixed before the call, which passes against a generator that writes nothing. Fixture set 48 / 245 -> 49 / 248; downstream zero sites in both consumers, recorded as #2400. It was 17,625 immediately before, measured on 2026-08-19 by ticket #2398. The current verified baseline is 17,625 tests across 38 executables with THE GATE GREEN -- 17,625 run, 17,625 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2398. +13 on the 17,612 below, all in SharpRuntimeIntegrationTests (931 -> 944). #2398 stops RandomNumberGenerator throwing PlatformNotSupportedException on Emscripten -- and the premise it threw on had already been measured false inside this repository. Its comment read "No secure random source wired up under Emscripten yet", while Guid.cpp:377-388 records #2228's measurement that Emscripten's libc declares getentropy() in <unistd.h> and implements it as __wasi_random_get(), backed by the host's crypto.getRandomValues. So Guid::NewGuid() has been drawing real entropy on Emscripten through exactly the call this file refused to make -- two answers to one question in one runtime, and the type whose entire purpose is cryptographic randomness was the one refusing. .NET does not refuse either: RandomNumberGeneratorImplementation.Browser.cs forwards to Interop.GetCryptographicallySecureRandomBytes, whose __EMSCRIPTEN__ arm is SystemJS_RandomBytes (src/native/minipal/random.c:83-93). Four platform arms became two, and that is what makes the repair verifiable rather than merely tidy: getrandom() is Linux-only, so the old file had one arm per platform and the Linux gate compiled exactly one of them; with a single non-Windows getentropy() loop, the code Emscripten takes is the code Linux takes, so the gate executes it on every run. The limitation was also undeclared -- AGENTS.md's platform-limitation table never listed RandomNumberGenerator, so a caller reading it would have concluded the type worked everywhere; after this change that conclusion is correct and no row is added. Failure still throws, and differs from Guid deliberately: NewGuid() retries because callers treat it as infallible, while .NET throws here too (Interop.GetRandomBytes.cs:22-26), and neither ever falls back to a weaker source. Two messages moved onto .NET's exact text -- GetInt32's empty range (RandomNumberGenerator.cs:105-106, SR.Argument_InvalidRandomRange) and verifyGetBytes's truncated SR.Argument_InvalidOffLen (:438-446), the latter also ending an inconsistency inside this port, since Console.hpp already spelled .NET's full sentence. The exception types were already right and did not move: .NET uses ArgumentException with no parameter name for the empty range, because the fault is the relationship between two arguments rather than either one's range, and a case asserts that shape beside the ArgumentOutOfRangeException guards so a later repair cannot conflate them. The shipped coverage could not have caught any of this: two cases asserting buffer.size() after filling a buffer sized before the call, both of which pass against a generator that writes nothing. Thirteen cases replace them, including #2228's fork() distinctness idiom (a userspace PRNG has state and fork() duplicates it) and the 256-byte chunk boundary, which the Linux gate could not reach before this change because getrandom() has no such limit. Eight mutations, six caught and two not caught with stated reasons: restoring the Emscripten throw cannot be caught here because that arm is not compiled and there is no Emscripten toolchain in this container, and ignoring a getentropy() failure is unobservable in isolation because with chunking intact the call does not fail. One mutation was invalid as first written and was reformulated rather than counted -- removing the chunk cap left maxChunk unused, so -Werror=unused-variable rejected it and the verdict said nothing about the tests. Downstream: zero sites in both consumers. RNGCryptoServiceProvider was split out as #2399 rather than bundled, because every part of it -- sealed, [Obsolete], and three missing constructors -- is a public source break or new public surface, where #2398 outlawed no spelling at all. It was 17,612 immediately before, measured on 2026-08-19 by ticket #2397. The current verified baseline is 17,612 tests across 38 executables with THE GATE GREEN -- 17,612 run, 17,612 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2397. +15 on the 17,597 below, all in SharpRuntimeIntegrationTests (916 -> 931; one shipped pin inverted in place, fifteen cases added). #2397 came from a queue-empty parity sweep: both work queues were empty, so a module with no dedicated test executable was measured against the reference -- System::Text::RegularExpressions, whose only coverage sat inside one integration file. Four divergences, two of them silent data loss through a public member. Regex::Split discarded every matched capture group's value -- Split("a1b2c", "(\\d)") returned three elements where .NET returns five (Regex.Split.cs:304-311, which appends each matched group after the segment preceding its match and skips one that did not participate) -- and it dropped a trailing empty segment, Split("abc", "c") returning {"ab"} where .NET returns {"ab", ""} (Regex.Split.cs:321). Both losses had one cause: the body was a single std::sregex_token_iterator(-1), which yields only the non-matching segments and suppresses a trailing empty token, so no care at the call site could recover the data. Regex::Escape used its own metacharacter set, differing from RegexParser.cs:2135-2136 in both directions -- six missing (TAB, LF, FF, CR, SPACE and #) and two extra (], }) -- and .NET additionally spells the four whitespace metacharacters as a backslash plus a letter (RegexParser.cs:180-199), a rule this port had no need of because it had none of those characters in its set. The direction that could have broken something was measured rather than reasoned: the newly emitted forms (\ , \#, \n, \r, \t, \f) had to be accepted by libstdc++'s ECMAScript grammar and the newly bare ]/} had to be accepted as literals -- probed over all 127 single ASCII bytes plus 18 composites, .NET's output round-trips as an exact literal with zero regex_error and zero wrong matches. One narrowing is real and is .NET's own: splicing Escape output into a character class -- "[" + Escape(x) + "]" -- is no longer protected by an escaped ], which is precisely why .NET specifies Escape for a literal in a pattern rather than in a class. Fourth, Match::getIndexProperty() answered -1 for an unsuccessful match, a sentinel .NET never produces: Match.cs:75 builds Match.Empty with capcount == 1, reaching Group.cs:27-28 and Capture.cs:27-32, so .NET reports 0. A shipped test pinned the -1 and was inverted rather than deleted, because it was asserting the divergence; the case that replaces the sentinel pins what follows from removing it -- a successful match at position 0 and a failed match now report the same index, so only getSuccessProperty() separates them, which is what Match.cs:72-74 says in terms. Landed under SA-5, with no layout, vtable, signature or noexcept change and no outlawed spelling, so no negative fixture was owed -- the set stays 48 / 245 and the graph 41 / 93. Nine mutations, eight caught and M7 an equivalence proven rather than asserted: dropping the no-match early return changes nothing, because every loop iteration appends at least the segment before its match, so an empty result set means prevat is still 0 and the fall-through substr(prevat) already yields {input} -- measured over 288 (pattern, input) pairs with both forms side by side, zero differences -- and the line is kept for being .NET's, with the site saying so. Downstream: zero sites in both consumers, the only Regex hits in cna being inside vendor/googletest. What #2397 did NOT close is recorded rather than left silent: Regex::Unescape is absent, the count/startat Split overloads are absent, and Match::Empty().Groups().Count is 0 here against .NET's 1 (GroupCollection.cs:67), because this port's Match does not derive from Group/Capture as .NET's does. It was 17,597 immediately before, measured on 2026-08-19 by ticket #2395. It was 17,597 tests across 38 executables with THE GATE GREEN -- 17,597 run, 17,597 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2395. +3 on the 17,594 below, in SharpRuntimeTests_Core_Base (6,089 -> 6,092; one gated pin replaced by four cases). #2395 gives a bit pattern and a number different spellings: the raw-bits constructor becomes the named static FromBits on both 16-bit floats, which frees the constructor signature for .NET's value conversions, and #2384 unit 3's to direction lands on both types in step (#2340). The collision was structural: Half(uint16_t) was the raw bit pattern and .NET spends exactly that signature on explicit operator Half(ushort), whose body is (Half)(float)value -- and it was worse than one overload, because an exact int match beats int -> uint16_t, so adding any value-taking integer constructor made Half(0x7BFF) silently mean the number 31743; #2384 measured it by building the constructors and watching 44 shipped tests turn red, Half::MaxValue among them. The dangerous half is that the compiler cannot find the sites: Half(0x3C00) is valid under both readings, so an unmigrated site keeps compiling and changes meaning silently. The work was therefore sequenced so that it could not: the rename landed first, with no value-taking constructor present, so every site had to be named by the compiler; then all sites were migrated; then the conversions were added. Two process lessons came out of that step and both cost real sites -- truncating the compiler's error list (| head) hid sites that then compiled silently, and the build stops at the first failing translation unit unless -k is passed. The migration is 67 sites, not the ticket's 66: a fourth site was found only by the full gate, in tests/integration/Task40Tests.cpp, a tree that modules/ and test/ do not cover -- the same gap #1958/SR-AUD-196 recorded when its own grep missed it. Three others were found only by an exhaustive re-grep, among them BitConverter::ToBFloat16 and UInt16BitsToBFloat16, both bit functions that would have begun returning a number. The conversion bodies are NOT all the same, and that is .NET's doing: Half gets (Half)(float)value for every integer, while BFloat16's int/uint/long/ulong go through RoundFromSigned/RoundFromUnsigned (BFloat16.cs:560-646), because 8 significand bits mean a 32- or 64-bit integer routed through a 24-bit float rounds twice -- probed over 400,000 random values of each width, the two differ on 4 int32 and 2 int64 inputs, the first being 1119879149, direct 0x4E85 against the float route's 0x4E86, verified by hand. byte/sbyte are explicit here where .NET makes them implicit, because C++ permits a standard conversion before a user-defined one and C# does not, so the implicit form makes every integer argument ambiguous; what is lost is the implicitness, never the conversion. nint, nuint, decimal, Int128 and UInt128 are absent in both directions and pinned, so a later unit completing one has to justify the asymmetry. Six mutations, all caught -- but three only after work that is recorded: M2 and M3 were first NOT CAUGHT because the 64-bit path is a separate template instantiation with no witness and no case exercised an exact tie, and both now have one (3485786034122340516 and 65792, which is 1.0000000_1 x 2^16 -- an exact midpoint above an even significand, so ties-to-even holds 0x4780 where ties-away gives 0x4781); and M6 is caught only under UBSan, because removing the zero short-circuit leaves abs << 32, which x86 masks in hardware so the answer still comes out 0x0000 -- undefined behaviour that happens to work, reported as "shift exponent 32 is too large for 32-bit type" with the guard removed and silent with it. Negative fixture set 47/240 -> 48/245, and its header states plainly what it cannot do: reject the old spelling. Downstream: zero sites in both consumers. It was 17,594 immediately before, measured on 2026-08-19 by ticket #1958 (SR-AUD-209). It was 17,594 tests across 38 executables with THE GATE GREEN -- 17,594 run, 17,594 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1958 (SR-AUD-209), which closes #1958. +4 on the 17,590 below, in SharpRuntimeTests_Threading (523 -> 527; one #1956 layout pin split in two and inverted, three cases added, one case deliberately removed). SR-AUD-209 makes AutoResetEvent and ManualResetEvent what .NET declares them to be: sealed class ... : EventWaitHandle whose entire body is one constructor (AutoResetEvent.cs:6-9, ManualResetEvent.cs:6-9) -- neither declares a member of its own, and Set, Reset, WaitOne, Close and Dispose are all inherited. This port had them with no base and no vtable, each carrying its own mutex, condition variable and signalled flag, so WaitHandle::WaitAll/WaitAny -- repaired by #1952 and documented ever since -- could not accept them at all: not a wrong answer, the code did not compile. That is what made SR-AUD-209 the one divergence in the namespace leaving a documented API unusable. Two things the finding did not name were required, and either omitted would have made the repair a regression. (1) EventWaitHandle had no closed state: #1956 gave Mutex, AutoResetEvent and ManualResetEvent a closed_ flag and this was its fourth case, missed, so Close() here reached WaitHandle's empty Dispose() and did nothing -- deriving without fixing it would have silently reverted #1956 for both events. (2) EventWaitHandle::Set() lost wakeups, storing and notifying without holding mtx_, so a waiter that had evaluated the predicate as false but not yet slept missed the notification; AutoResetEvent::Set() took the lock, so deriving would have introduced the race into a type that did not have it. Measured over 900 single-waiter rounds: EventWaitHandle lost 2, AutoResetEvent 0 -- and this is the type cna holds by value in six places, all for async completion, exactly the shape a lost wakeup hangs. Layout: both events 96 -> 112 and EventWaitHandle 104 -> 112, so every consumer rebuilds; 112 was measured after 104 was asserted and the build rejected it, #1956's flags having fitted existing padding on three other types and that expectation being carried over rather than measured. The pin asserts the relationship -- both events must be exactly sizeof(EventWaitHandle) -- as well as the figures, so it says these declare no members of their own rather than these are 112 bytes today. WaitOne() returns bool rather than void, a widening at every call site since ignoring a return value is legal, and the initialState parameter loses its default because .NET has none and it had zero call sites anywhere. Seven mutations, six caught and M6 honestly not caught: reverting Set() to the unlocked form is a race window, detectable at roughly 0.2% per round, so a bounded test would catch it about a third of the time -- and a first cut of the suite carried exactly such a 200-round case and it was removed rather than kept, on the reasoning #1957/SR-AUD-201 and #2031 recorded for their own window-closing mutations. A multi-waiter amplification was tried and is invalid, reporting 100% "loss" for the locked form too, because repeated Set() calls on an AutoReset event coalesce into one signal -- it measures AutoReset semantics, not the race. Downstream: zero AutoResetEvent/ManualResetEvent sites in both consumers; all 14 hits are EventWaitHandle, and measured against its usage (EventWaitHandle(true, ManualReset) returned as a WaitHandle&) cna needs a rebuild, not an edit -- and it builds against the sibling checkout on develop, so both repairs reach it at the merge rather than now. It was 17,590 immediately before, measured on 2026-08-19 by ticket #2208. It was 17,590 tests across 38 executables with THE GATE GREEN — 17,590 run, 17,590 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2208. +1 on the 17,589 below, in SharpRuntimeTests_IO_IsolatedStorage (62 -> 63; one residual pin removed, two cases in its place). #2208 confines IsolatedStorageFileStream: its constructor took a std::filesystem::path and checked nothing, opening whatever it was handed anywhere on the filesystem and creating that path's missing parents on the way. That is a wider hole than the TOCTOU #2207 declared and accepted -- that race needs an attacker who can already write inside the store root plus a window between check and use; this needed neither a race nor a privilege, only the call -- and it was the one door on a type that exists to confine file access which confined nothing. The reference corrected the ticket's proposed shape, and the correction removed the source break it was priced around: #2208 said remove the path constructor and take the owning store instead, while .NET publishes eight constructors all beginning (string path, FileMode mode, ...) with the store an optional trailing parameter (IsolatedStorageFileStream.cs:21-56), and its storeless form is not unconfined -- a null store means GetUserStoreForDomain(), resolved through isf.GetFullPath(path) exactly as the store-taking form is (:82-118). So the confinement lands without removing an overload .NET publishes and without inventing a leading-store parameter order; an earlier cut used (store, path, mode) and that order is now pinned as not compiling, because the wrong order compiles perfectly well as an addition and no behavioural test can see it, both orders confining correctly. On POSIX there is no source break at all -- std::filesystem::path converts to std::string implicitly there, so the old call still compiles and only its meaning moves, from this filesystem path to this path inside the store; on Windows value_type is wchar_t and the spelling breaks. An absolute path is contained, not refused, because fullPath() strips leading separators at every door -- the rule #2209 recorded -- and refusing it at this one door would make the type inconsistent with itself. Each door reports its own parameter name: routing every door through one resolver made it possible for one door's diagnostic to name another's parameter, which is #2323's rule, so the public constructors name path while OpenFile/CreateFile still name relativePath, through a private four-argument constructor. Two behaviours came with it -- the mode is validated with .NET's own text (Invalid mode, see System.IO.FileMode.) before the file is created, reachable only by a value cast in from outside the enumeration; and IsolatedStorageFile::GetUserStoreForDomain() was added purely additively with .NET's exact scope combination, because it is the store the storeless form defaults to. .NET's path == "\\" check is deliberately not reproduced, because on POSIX a backslash is an ordinary file-name character and this module's own isDirectorySeparator() says so -- the outcome is identical anyway, since fullPath() strips separators and then rejects what is left when it is empty. Eight mutations, seven caught, one a proven equivalence: swapping the default store's scope is unobservable, because both factories call literally the same GetIsolatedStorageRoot() and the stream never retains the store, so ForDomain is written for fidelity rather than for effect. Three mutations were invalid as first written (-Werror=unused-parameter, -Werror=unused-function, and a [[nodiscard]] on fullPath()) and were reformulated rather than counted. Seven of the eight are caught by pre-existing confinement tests, and that is the point -- OpenFile now routes through the constructor, so the whole shipped suite covers it; the mutation only the new cases catch is the half-repair, OpenFile pre-resolving while the constructor ignores its store. Negative fixture set 46/236 -> 47/240, measured by the checker rather than derived from this file (§22.4's running total was already stale). Downstream: zero constructions in both consumers (mobile-eggbert has one #include and no call). It was 17,589 immediately before, measured on 2026-08-19 by tickets #1888 and #1889. It was 17,589 tests across 38 executables with THE GATE GREEN — 17,589 run, 17,589 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1889. +4 on the 17,585 below, in SharpRuntimeTests_Text_Json (302 → 306). #1889 makes JsonArray/JsonObject enumeration fail-fast, closing two measured defects: an iterator held across a reallocating Add was an ASan-confirmed heap-use-after-free — a SIGSEGV without a sanitizer (J11) — and one held across Clear() silently returned a value from destroyed storage with no diagnostic in any build (J12), which is the worse of the two precisely because nothing traps. It is the repository's standard idiom, the one List<T> and BitArray already use, and AGENTS.md's counter invariant was followed exactly: detail::MutationCounter, never a bare integer, because ++ on a signed counter is undefined at INTCS_MAX and an implicit assignment would transplant the source's counter into the destination. JsonArray and JsonObject go 48 → 56; JsonNode and JsonValue are unchanged, which is what shows the counter went where the enumerators are and nowhere else. Zero consumer sites. The module edge was made explicit by the validator rather than by me. MutationCounter lives in modules/collections and the two headers are public, so Text.Json needed Collections.Core as a public dependency where it had been private — and the boundary validator rejected the private declaration outright. A local copy was never an option; AGENTS.md forbids it in terms. The graph is unchanged at 41/93: the edge already existed and only its kind moved, which is a smaller change than #1814's. Two of my own measurements were wrong and the compiler corrected both. "Zero first-party begin()/end() sites" missed JsonNodeParseDepthTests iterating with it->second, so the enumerator needs operator-> — guarded there too. And a shipped #1886 layout static_assert fired at compile time with a runtime one failing beside it; both are updated, the two figures that did not move are left as #1886 wrote them, and the growth is additionally asserted as a relationship so a later member cannot hide behind a hand-updated literal. Six mutations, all caught — but M6 only after the test was strengthened, and the first result is recorded rather than quietly fixed. A begin() that bumps the counter leaves a single range-for working perfectly, because the one enumerator snapshots the version begin() just produced; it is observable only with two enumerators over the same unmutated container. SetItem earns its own row for the mirror reason — it changes no element count, so only the counter can notice it at all. It was 17,585 immediately before, measured on 2026-08-19 by ticket #1888. ±0, and deliberately so: three shipped pins were replaced by two and one case was added. #1888 deletes JsonNode's four copy/move members and moves DetachParent to protected with JsonArray/JsonObject as friends, closing three measured defects — a copy gave a second container sharing the same children, each still reporting the original as its parent (J08); assignment sliced, rewriting parent_ on a node still stored in a container (J09); and public DetachParent let one node sit in two containers (J13). A .NET JsonNode is a reference type, so there was never an object copy to translate, and XObject already deleted all four — this ends an asymmetry inside the port rather than inventing a restriction. Measured impact: zero first-party copy/assign sites and zero in both consumers. The header's own note about DetachParent was wrong and the reference corrects it: it claimed to mirror "JsonNode.cs's internal DetachParent", and there is no DetachParent on JsonNode.cs at all — .NET puts it on the containers, private on each (JsonObject.cs:316, JsonArray.IList.cs:231), body item?.Parent = null, with Parent's setter internal. Protected-plus-friends is that reachability in C++. Four pins were inverted and the fourth was not where the measurement said to look: JsonNodeTeardownTests built its second container with make_shared<JsonArray>(realOwner), a copy commented "shares children" — a grep for X = *y missed it and the compiler found it; its real subject is #1886's == this guard, so it is rewritten to reach that guard without a copy. Three mutations; M1 and M3 caught at compile time, and M2 is NOT caught and is reported as such. Restoring the move members is a proven equivalence, measured with a probe for two independent reasons: JsonNode is abstract, so is_move_constructible_v is false whatever the declaration says, and JsonArray/JsonObject each have a user-declared destructor (#1895's iterative teardown) that suppresses their implicit moves, so move-constructibility falls back to the already-deleted copy. The deletion is kept for intent and becomes load-bearing the day a container drops that destructor; the header and the fixture's own site say so, so nobody is misled about what rejects the spelling today. And it finally wrote the fixture #1894 could not. test/consumer/text_json_node_lifetime_negative.cpp is named in #1894's acceptance criteria, and #1894 recorded — correctly — that it "cannot be started, not merely should not be", because no CCF-019 repair had outlawed any spelling. #1888 is the one that did. Fixture set 45 / 231 → 46 / 236. It was 17,585 immediately before, re-measured on 2026-08-19 by ticket #1894, which closes it. What that number does and does not depend on, audited 2026-08-19 and measured rather than reasoned. There are zero DISABLED_ tests in the repository — worth checking because a disabled test is invisible to both halves of this rule: gtest omits it from the run total and it never appears as skipped, so it could hide from the count and from the "0 skipped" claim at the same time. There are 56 GTEST_SKIP sites, none of which fires here, and all of which are environment-conditional — chiefly tzdata zone availability (~21), /proc descriptor counts (8), signal delivery (4), locale availability (3) and DNS (2). The run total is therefore environment-independent with respect to those skips, and only the pass/skip split moves. That was proved with a three-test probe rather than assumed: a conditional GTEST_SKIP leaves N tests … ran unchanged and drops PASSED by one. So a container without America/New_York tzdata still reads run=17,585, with ~21 skipped and ~17,564 passed — the floor this rule protects is intact there, which is exactly what a no-regression rule needs and what the existing qualification below (about failures differing by environment) does not by itself say. ±0, and deliberately so: #1894 repaired a test rather than adding one. It closed on a rule-14 sweep — its gate had gone stale that same day. Its own note set the condition, "close the negative-fixture half as not applicable if #1899 is declined/wontfix", and #1899 was declined on 2026-08-19 while #1888 remains declined, so both became determinate. The negative-fixture half is not applicable, and that is verified rather than trusted: a probe against the shipped headers confirms all four spellings #1888/#1899 would have outlawed are still legalJsonArray copy- and move-constructible, JsonNode::DetachParent() callable, and Extensions::Ancestors returning std::vector<XElement*>. A negative fixture asserts a spelling is rejected by the compiler; with nothing rejected, writing one would mean inventing an outlawed spelling to pin. This is not a claim that CCF-019 produced no fixtures at all — its async members did, in #1959 — only that the two owned-tree modules #1894 scoped outlawed nothing. The sanitizer half was re-measured because the recorded figures were stale, and the re-measurement found a real defect. The note cited a clean run over 218/218 Text_Json and 184/184 Xml_Linq from 2026-07-31; both suites have since grown to 302 and 349, so those figures describe a tree that no longer exists. Re-run: ASan+LSan clean on both, and UBSan aborted Text_Json on a misaligned reference binding in char_traits<char16_t>::length. It is a defect in a test, not in production code, and finding which construction caused it needed measurement rather than reading — the array copied verbatim into a standalone probe did not reproduce, nor did any literal alone. The manifestation is translation-unit-layout dependent: the empty u"" literal is merged into a 1-byte-aligned mergeable section beside narrow literals, so length() binds a const char16_t& to an odd address. One line — u"" becomes std::u16string(), the same empty string with no literal to misalign — and the mutation that restores it reproduces the report. The instrumentation was shown able to report before any of this was believed (the #1957/SR-AUD-204 lesson): deliberate defects built with the same flags gave one report each for ASan, LSan and UBSan, and the no-defect control gave none. The write-up also corrects that document's running total, stale since 2026-08-04 at 11 fixtures / 94 sites, to the measured 45 / 231. It was 17,585 immediately before, measured on 2026-08-19 by ticket #2384 (unit 3), which closes that ticket. +3 on the 17,582 below, in SharpRuntimeTests_Core_Base (6,084 → 6,087). Purely additive: nine from conversions on each 16-bit float, truncating toward zero — where truncate and floor part company for negatives — and all explicit, so a 16-bit float can never silently become an integer. Only the from direction landed, and the reason the other half did not is the unit's real finding. .NET declares 43 conversions on Half and 47 on BFloat16; four groups cannot be transcribed. The 13 operator checked variants have no C++ counterpart at all — C# selects them inside a checked context and there is simply nothing to write. nint/nuint are measured to be the same C++ type as longcs/ulongcs here (std::intptr_t is long), so a separate overload is a redefinition, not an addition — the conversions exist, through those. ushortHalf finds its signature already taken by the opposite meaning: Half(uint16_t) is the raw bit pattern where .NET's is (Half)(float)value. And the fourth group was found by building it rather than by predicting it. Adding explicit Half(intcs) makes C++ overload resolution prefer it for an int literal — an exact match beats an int → uint16_t conversion — so Half(0x7BFF) silently stops meaning "these bits" and starts meaning "the number 31743". This type's own constants are written that way, and landing the constructors turned 44 shipped tests red, Half::MaxValue and Half::NegativeInfinity among them; a minimal probe isolates it. Resolving it means renaming the raw-bits constructor — a public source break across 66 first-party sites with a silent meaning change for any site not migrated, which is the dangerous class — so it is #2395, a decision rather than a transcription. Two further measurements came from writing the test rather than reading the type: the raw-bits constructor already accepts any integer convertible to uint16_t, so Half(someSByte) compiles today and means bits — "is it constructible" therefore cannot be the pin, only the meaning can be; and the two types have different shapes of the same hazard, because BFloat16's extra float constructor makes an int literal ambiguous there where Half silently picks raw bits. One decision, two migrations. One divergence is already decidable and is recorded with #2395: .NET makes the byte/sbyte conversions implicit, and reproduced as implicit converting constructors they make every int argument ambiguous — measured, because C++ permits a standard conversion before a user-defined one and C# does not. Whatever #2395 decides, those two must be explicit; what is lost is the implicitness, never the conversion. Four mutations, all caught. It was 17,582 immediately before, measured on 2026-08-19 by ticket #2384 (unit 2b). +3 on the 17,579 below, in SharpRuntimeTests_Core_Base (6,081 → 6,084). Purely additive: 39 members on each 16-bit float, the transcendental, root, power and angular families. Every one is a float round-trip, and that is .NET's own shape — verified member by member against Half.cs and BFloat16.cs — so unlike units 1 and 2a there is no bit-level body to get wrong, which changes what is worth testing. The risk in a forwarding table is not arithmetic but mis-wiring: Sin calling Cos, or Atan2's arguments swapped, and every compile-only test passes against both. So each case asserts that a name reaches its own float function, comparing against FromSingle(expected(in)) rather than a literal — a test of the forwarding rather than of MathF's accuracy — plus explicit cross-checks that three members disagree on the same input, plus operands chosen so a swapped argument order gives a different answer. Seven mutations, all caught. Unit 2a's own estimate was wrong and the measurement corrects it. It put unit 2b at "~45 members per type, mostly one-line forwards"; only 39 could be forwarded, split 25 via MathF, 13 via Single, and 10 via neither. Those ten — Compound, ExpM1, Exp2M1, Exp10M1, LogP1, Log2P1, Log10P1, Lerp, MultiplyAddEstimate and ClampNative — have no counterpart in this port's System::MathF or System::Single, so adding them to the 16-bit floats would mean widening float's own public surface first: a different type's API, and a different question from the one this ticket asks. They are pinned absent on both types, so whichever ticket widens System::Single trips that pin and can complete the 16-bit types in the same change; MultiplyAddEstimate and ClampNative are doubly absent, since float lacks them here and .NET declares them on Half only. Ieee754Remainder is the one member whose .NET name and this port's MathF name differ (IEEERemainder), so its forward is spelled out and pinned — a mis-wiring there would be invisible to a name-based reading. Only unit 3 remains: 43 conversion operators on Half and 47 on BFloat16. It was 17,579 immediately before, measured on 2026-08-19 by ticket #2384 (unit 2a). +5 on the 17,574 below, in SharpRuntimeTests_Core_Base (6,076 → 6,081). Purely additive: rounding (Ceiling, Floor, Round×2, Truncate), Sign, and the IEEE 754:2019 *Number family on both 16-bit floats, plus MaxNative/MinNative on Half. The *Number family is not Max/Min, and two rules separate them — both pinned, because a forward to Max satisfies every ordinary row and fails exactly these: it does not propagate NaN (Max(NaN, 2) is NaN, MaxNumber(NaN, 2) is 2, from either side, and .NET says so in its own comment), and +0 is treated as larger than -0, which no comparison can see since +0.0 == -0.0, so the pin asserts the bits — a naive (x > y) ? x : y returns the wrong zero and passes everything else in the file. Sign has two transcribed edges: it throws ArithmeticException on NaN rather than returning a sentinel, and it tests IsZero before IsNegative, so Sign(-0.0) is 0, not -1. Round is ties-to-even through MathF::Round rather than std::round, and the swap is caught. The unit's most useful finding is a limit on #2340's own rule. Measured by diffing the two reference surfaces, MaxNative, MinNative, ClampNative and MultiplyAddEstimate are declared on Half only — so in step means each type gets what .NET gives it, not that the two surfaces are identical, a distinction that only becomes visible once the surface is large enough to differ. Their absence on BFloat16 is pinned, so a later unit that "completes the symmetry" has to justify inventing them. Six mutations, all caught. The remainder of #2384 is measured rather than estimated: unit 2b is ~45 members per type, mostly one-line forwards, and unit 3 is 43 conversion operators on Half and 47 on BFloat16; unit 2b's absence is pinned on both types via Sqrt. It was 17,574 immediately before, measured on 2026-08-19 by tickets #2387, #2207, #2364, #2384 (unit 1) and #2338. #2364 is the only one that changed a value a caller gets, and it changed eight of them: the POSIX GetFolderPath table is now .NET's. #2320's recorded reason for stopping was measured false — it said the rest "has no .NET mapping, so it would cross from parity into invention", and every remaining row maps onto ReadXdgDirectory (GetFolderPathCore.Unix.cs:220-249) or onto a static path, so these are alignments, not widenings. The sharpest row is Personal/MyDocuments, which returned the home directory itself — an application writing "the user's documents" wrote into $HOME — and now returns a Documents subdirectory; UserProfile is asserted not to have moved with it, because the two used to be the same answer and a repair that moved both would look correct without that row. Two rows are the reverse shape: ProgramFiles and System were invented here as /usr and /usr/lib where .NET maps them only under TARGET_OSX, so the alignment removes a mapping. ReadXdgDirectory is transcribed including every rejection in its line grammar — a value must be $HOME/-prefixed or absolute and anything else is skipped rather than accepted — and it reads user-dirs.dirs out of the XDG config directory, so it honours XDG_CONFIG_HOME rather than a hard-coded ~/.config. The eight rows were never covered, which is why the divergence survived: the full suite passed unchanged before and after. Every new case sets HOME and the XDG variables explicitly under DoNotVerify, so what is asserted is the mapping rather than what this machine happens to have — the #2320/SA-6 lesson turned into a rule. Six mutations, all caught. #2387, #2207, #2338 and #2384 unit 1 are the batch's other half. #2387 leaves ExternalException::ToString() absent and pins it: a virtual would be the #2374 shape, but where that slot bought an overridable lease policy nothing else could provide, this one would only relocate the naming problem into each of cna's three derived types — and a stored name grows sizeof and breaks all three. #2323's rule decides it: a message naming the wrong type is a lie, where an absence is merely an absence. #2207 declares the IsolatedStorage TOCTOU with its threat boundary, which is the part that makes it acceptable rather than tolerated: it does stop an absolute path, a .. climb and a pre-existing symlink — the case people assume the race defeats, and it does not — and it does not stop an attacker who can already write inside the store root, who already reads and writes every file there directly, so the race widens their reach beyond the store rather than granting access to it. Both halves are asserted rather than argued. #2338 keeps Unicode normalization invariant, and its premise was stale twice: the ticket says /rv is absent (it is present) and #2386 has since measured that .NET returns true and the argument unchanged in invariant globalization mode, saying so in its own comment — so this port was never diverging, and the question was never when do we fix this but do we add a capability .NET itself only has through ICU or NLS. Own UCD tables plus UAX #15 and an ICU dependency were both declined; measured zero call sites in both consumers. #2384 unit 1 gives both 16-bit floats nine members, and #2340's in-step rule worked as designed — adding them to BFloat16 alone broke the build, because #2382's pin asserted their absence with the message "it must move System::Half too". Four of the nine are bit operations, not float round-trips, and .NET says so itself: CopySign carries the comment "required to work for all inputs, including NaN, so we operate on the raw bits". Three edges are transcribed rather than derived — -Infinity increments to MinValue, +Infinity decrements to MaxValue, and -0.0 increments to Epsilon while +0.0 decrements to -Epsilon. Every body was derived per type, which is #2382's lesson applied rather than quoted; for these nine the two references agree, and that was checked. Six mutations, all caught — but M6 only after a measurement corrected the test: BFloat16::Abs via a float round-trip went uncaught because the case used 0x7FC1, a NaN that is already quiet; probed over all 65,536 patterns the two forms differ on exactly 126, every one a signalling NaN, and the case now uses 0x7F81 and asserts exact bits. Units 2 and 3 remain and their absence is pinned on both types. It was 17,561 immediately before, measured on 2026-08-19 by tickets #2374, #2392, #2058 and #2381. +2 on the 17,559 below: SharpRuntimeTests_Core_Base → 6068 and SharpRuntimeTests_Numerics → 344; SharpRuntimeTests_Buffers is unchanged at 630, because #2058 widened an existing case rather than adding one. #2374 gives MarshalByRefObject the virtual InitializeLifetimeService() .NET has (MarshalByRefObject.cs:22-26); its absence turned an observable runtime diagnostic into a compile error at an unrelated place. The class already has a vtable, so this inserts a slot here and in both derived classes, AppDomain and ContextBoundObject — a silent binary break, granted per action on the measurement that all three types have zero sites in both consumers. virtual is the point rather than a detail: a non-virtual member of the same name satisfies a presence check, compiles at every call site, and silently defeats the override the .NET member exists for — which is why #2297 left it absent rather than add it that way, and why the new case calls through a base reference and asserts dispatch, not presence. #2297's absence pin is inverted and renamed, and its detection idiom is kept rather than replaced by a direct call, because that is what makes presence and absence expressible in the same form. Three mutations, all caught; a first run of the set was invalid and is recorded rather than counted — the restore step copied a pre-change backup, so two mutations reported ANCHOR MISSING against a file that no longer had the member. #2392, #2058 and #2381 changed no production statement. #2392 declares that TotalOrderIeee754Comparer::GetHashCode hashes the bit pattern where .NET hashes the value (TotalOrderIeee754Comparer.cs:198-202, whose Double.GetHashCode collapses all NaNs and both zeros); adopting .NET's coarser hash was declined, and the contract holds either way — equality here is bit-pattern identity, so equal never hashes differently and the choice is about distribution, not correctness. The pin states the two rows as a difference from .NET rather than only as a property of this port, and additionally pins that Double::GetHashCode already matches .NET, so a future reader does not fix the wrong one. #2058 declares ReadOnlySequence<T> single-segment only: .NET has ReadOnlySequence(startSegment, startIndex, endSegment, endIndex) (ReadOnlySequence.cs:94) and computes IsSingleSegment as _startObject == _endObject (:41-45) where this port hard-codes true; its premise was partly stale — the false class comment had already been removed by an earlier ticket, so what remained was the ticket's own framing as blocked. The existing static_asserts become the declaration, and the single-segment case was widened from two constructors to all five, because an answer that is constant is only demonstrably constant if every door is tried. #2381 is the one instruction that was granted and could not be carried out, and it was measured rather than assumed. The user approved editing cna's XNB DateTimeReader to stop discarding the DateTimeKind, conditioned on the promise to check both sharp-runtime versions first. Measured: develop — which cna builds against — has explicit DateTime(longcs ticks) and no DateTimeKind at all, while next has DateTime(ticks, kind) and SpecifyKind. This is #2366's trap without #2366's escape: there, {} meant remove under both versions, so one spelling served both; here no expression means preserve the kind in a version that has no kind, and a requires-guarded form does not help because the unqualified name lookup is a hard error rather than a substitution failure. Editing anyway would leave cna unbuildable, which is worse than the documented deviation it carries, so the ticket records the exact merge-time diff and is blocked on the merge rather than on a decision. It also records what the repair will not buy: #1941 landed phase 1 only, so a preserved Kind is truthful and round-trippable but changes no conversion. It was 17,559 immediately before, measured on 2026-08-19 by ticket #2393. +1 on the 17,558 below, in SharpRuntimeTests_Uri (307 → 308; 51 constructions across 24 tests updated in place, two assertions inverted, one case renamed, one added). #2393 makes Uri(std::string) require an absolute URI. The port had TWO absolute-URI grammars in one type: the one-argument constructor called parse() and never checked isAbsoluteUri_, while the (string, UriKind) overload did — and TryCreate goes through that overload — so Uri("://example.com/") succeeded while TryCreate("://example.com/", UriKind::Absolute, u) returned false, letting a caller construct a Uri this port's own TryCreate says is not absolute. Reachable from ordinary code, since a UriBuilder with an empty scheme renders exactly that and getUriProperty() is Uri(ToString()). The direction was derived rather than chosen: .NET has one grammar by constructionnew Uri(s) is CreateThis(s, false, UriKind.Absolute) (Uri.cs:424-429), TryCreate(s, Absolute, out u) is CreateHelper(s, false, UriKind.Absolute) (UriExt.cs:223-227), and CreateThis throws exactly what CreateHelper returns null for. The repair is one line. The narrowing is wider than the ticket described and is stated plainly rather than discovered later: the reported symptom was one odd string, but the constructor no longer accepts any relative reference. Relative-URI support is untouched — every accessor, the query/fragment split and base resolution are unchanged; only the default kind moved, onto .NET's, and the migration is to name it. Measured: zero real consumer sites (cna's single System::Uri match is inside a comment) and zero in mobile-eggbert; first-party, 51 constructions across 24 tests, each keeping its coverage through the two-argument constructor because they were testing relative-URI behaviour, not the constructor's default. Two shipped assertions were inverted because they asserted the defect, one of them ending with EXPECT_NO_THROW(Uri("/relative/path")) and calling it "what makes the distinction observable rather than theoretical" — it was observable, and it was the defect. One consequence is worth following, because it moved three times in one day: #2391's mutation M1 was recorded as an unobservable equivalence, then measured wrong — because of this very defect — and is now an equivalence again, since self can no longer be a Uri the strict comparand parse would refuse. The original reasoning was right and was defeated only by a bug one layer down; the case is renamed Decl2391_…IsNowAnEquivalence and records all three steps, so the line stays RelativeOrAbsolute because .NET writes it, not because it is load-bearing. The new pin asserts the equivalence over an 11-row corpus rather than the one reported string, because a repair that fixed only the reported example would pass a single-row test. Four mutations, all caught. It was 17,558 immediately before, measured on 2026-08-19 by tickets #2155, #2199, #1896, #2366 and #2377. +15 on the 17,543 below: SharpRuntimeTests_Timers 36 → 38, SharpRuntimeTests_Text_Json 301 → 302, SharpRuntimeTests_Xml_Linq 337 → 349. #2155 gives Timers::Timer the System::Object base so Elapsed reports the raising timer, as .NET does (Timer.cs:313); it had reported nullptr for a structural reason — EventHandler<T>::Raise types its sender as Object* and Timer had no such base, so nullptr was the only value that compiled. The obvious alternative does not exist here: .NET derives from Component (Timer.cs:15) and this port has no ComponentModel Component at all, so the divergence is in the base, not the sender. sizeof 104 → 112 and a new vtable; two shipped pins were inverted and both failed the build, each having said in terms "#2155 landed without updating its pin". Four mutations, all caught — M2 passes some other Object*, which is why the pin asserts identity rather than non-nullness. #2199 implements XObject's Changed/Changing, inert since it was ported. Both gates opened the same day. Layout: sizeof(XObject) 16 → 24 and every derived type by exactly one pointer — a unique_ptr allocated only on first registration, so an unobserved tree pays a null pointer and no allocation, the closest analogue of .NET's annotation slot; all ten figures match the approval exactly and three shipped layout pins failed the build. Removal is a registration token, because std::function has no operator== and a handler therefore cannot name its own registration — not a cost question but an impossibility, so add_* returns a token and remove_* takes it; removing all registrations and throwing were both offered and declined. Ids are process-wide, so a token from one object can never match another's. The semantics are transcribed: bubbling innermost-first, the sender being the object changed (the child for Add/Remove, .NET's own asymmetry at XLinq.cs:156,177), Changing before and Changed after, and — the subtle one — notify means "any object on the chain carries registrations", not "a changing handler ran", which is what keeps Changed-only subscriptions alive. XElement::setValueProperty deliberately raises nothing of its own, because .NET's setter is RemoveNodes(); Add(value); and a Value event there would be invented. Nine mutations, all caught, four only after test repairs, and three share one root cause: the recorder captured only the Changed half's kinds and senders, so a mutation corrupting the Changing half alone passed — half a pair is still a wrong notification. The fourth was a vacuous assertion: the foreign-token case removed A's registration before trying B's token, so it returned false for the wrong reason and a per-object counter went undetected. #1896 takes deep tree construction from quadratic to linear — 100,000 levels go 42.583s → 0.045s for JSON and 120.028s → 0.051s for XML, measured on the same binary and flags. Its approved layout growth was NOT needed and was NOT taken: the node being attached is parentless (already checked), so it can only be an ancestor of parent if it has a subtree — a childless node cannot contain anything — so parent == this is checked directly and the walk is skipped otherwise. sizeof is unchanged everywhere and no virtual was added. The trap is that an empty container short-circuits, so the self-check must sit OUTSIDE the emptiness guard; moving it inside makes self-attachment legal again, and both mutations that do so are caught. There were TWO quadratic sources and only one was in the ticket: fixing the guard left XML at 89.3s, still quadratic, because #2199's own ancestor walk — added earlier the same day, and .NET has exactly that shape (XObject.cs:424-427) — visits every ancestor even with nothing subscribed. A process-wide count of live registrations fixes it exactly rather than approximately: zero means no walk can find anything. Eight mutations, all caught, but M7 and M8 were first reported NOT CAUGHT and that was the honest result — a leaked count corrupts nothing, every assertion still passes, and the suite merely got slower until one run hit the 240-second mutation timeout; a mutation caught only as a timeout is not caught by name. The two cases now time the build: bound 2 s, linear ~40 ms (50× headroom), M7 13,870 ms and M8 14,098 ms (7× over). #2366 and #2377 are the two cna repairs, landed in that repository's working tree under a per-action instruction with no commit authorised. Both carry a premise correction. #2366's is that cna builds against the sibling checkout, which is on develop and does not contain #2313 — so cna is not broken today and both the defect and its repair arrive with the merge; that made std::nullopt the wrong spelling, since it does not compile against develop, and the repair uses {}, which means remove under both versions. #2377's is that the ticket named five types and there are seven: NetworkNotAvailableException and NetworkSessionJoinException chain to a base default constructor and would have inherited a message naming their base, which is #2323's own rule one level down. Both repairs are version-independent and CnaTests passes 68/68. Module graph unchanged at 41/93; negative fixture set 44/226 → 45/231. It was 17,543 immediately before, measured on 2026-08-19 by tickets #2170, #2390, #2391, #2118, #2185 and #1899. +10 on the 17,533 below: SharpRuntimeTests_Numerics 341 → 343, SharpRuntimeTests_Threading 522 → 523, SharpRuntimeTests_Uri 305 → 307, SharpRuntimeTests_Text_Json 300 → 301, SharpRuntimeTests_TimeZone 188 → 190, SharpRuntimeTests_Xml_Linq 335 → 337. All six were needs_user and all six were answered on the same day, which is what this checkpoint really records: the queue's remaining blockers were decisions, not evidence. They are written up as SA-12 and SA-13 in docs/StandingApprovals.md, and two of the six were decided against the recommendation on the record — that is the user's call and the reasoning is preserved so the trade stays visible rather than relitigated. #2170 gives TotalOrderIeee754Comparer the IEqualityComparer<T> base it has advertised in its own doc-comment since #2169, buying polymorphic binding and nothing else — before it, passing one where the interface is required was a compile error, so no call site can have depended on the old behaviour. sizeof 8 → 16, the subobject at offset 8, pinned as an offset rather than only a size because a single base that merely grew would give the same 16. The growth has no .NET counterpart and that is stated rather than implied: .NET's comparer is a readonly struct and a C# struct pays nothing to implement an interface, so the second vptr is a C++ artifact. IEquatable<TotalOrderIeee754Comparer<T>> is deliberately not reproduced — .NET's body is => true, which a defaulted operator== on an empty type already says, and a third base would cost a third vptr to express nothing. A divergence the ticket did not name was found and deliberately NOT bundled: .NET's GetHashCode is obj.GetHashCode() — the value's hash, which Double.GetHashCode normalizes so "all NaNs and both zeros have the same hash code" — while this port hashes the bit pattern, so -0.0 and +0.0 hash differently. Both satisfy the contract and .NET's is merely coarser, but the difference is directly observable through a public member, and closing it inverts five shipped pins, so it is #2392. Mutation M4 (adopt .NET's hash) is caught by Float_SignedZerosAreDistinct, which is the evidence those pins are load-bearing. Module graph unchanged at 41/93IEqualityComparer<T> is in Core.Base, already a dependency. Five mutations, all caught. #2390 is a general rule rather than a decision about one type, recorded as SA-12: where this port has a real creator, internal members become private with that creator a friend (the #2298 shape); where it has none, they stay public and the accessibility divergence is recorded in the header. Both mechanical alternatives were declined for stated reasonsprivate with no friend leaves the type impossible to instantiate, costing the tests that verify its fixed message and HResult, which is the only observable content such a type has; private plus a friend that never constructs it is a dead friend declaration that looks faithful while granting access to a class that will never use it. ThreadStartException is the second case, because std::thread either constructs or throws and .NET's window does not exist here; the divergence is in accessibility alone and the pin asserts the shape has not widened with it. #2391 adopts .NET's UriBuilder delegation and WITHDRAWS the non-throwing guarantee #2004 measured and chose — both members now go through the built Uri (UriBuilder.cs:277-279), so identity becomes canonical (host case folded, an explicit default port resolved away) and a builder whose rendering does not parse throws from both, b.Equals(b) included. It is a convergence rather than a reversal, and #2004's own justification had already become false: #2004 argued the string hash was value-identical because Uri::GetHashCode hashed absoluteUri_ verbatim, and #1995 made it hash a canonical identity key earlier the same session — keeping #2004 would have meant a builder and the Uri it builds hashing differently, which is the defect #2004 existed to prevent, one level up. The asymmetry is .NET's: self goes through the Uri property and throws, the comparand is handed over as a string and merely returns false. Four pins inverted, each having been written anticipating exactly this. Five mutations, all caught — but M1 only after the test meant to catch it was rewritten, and the first record of it was wrong: it was filed as an unobservable equivalence on the reasoning that getUriProperty() is always absolute, and probing the premise instead of trusting it showed this port's Uri(std::string) accepts "://example.com/" while its own TryCreate(s, Absolute) rejects it — two absolute-URI grammars in one type, filed as #2393. #2118, #2185 and #1899 changed no production statement: they are decisions and their evidence, the shape of #2202, #2015 and #2324. #2118 declares that JsonElement::GetRawText re-renders — .NET slices the original document bytes (JsonElement.cs:1196-1201JsonDocument.cs:700-704) and nlohmann's DOM retains no source spans at all; retaining them was declined because it is paid at parse time and in memory by every caller whether or not the member is called, and #2117 had already grown JsonElement 48 → 56 the same session. What is lost is the representation, never the value, and re-rendering is idempotent, which is asserted rather than asserted-about. Its gated pin was renamed and re-roled as a declaration, each row now also recording what .NET would have returned. #2185 makes SR-AUD-228 a permanent deviation in the reflection deviations' shape, on two measurements: it is not closable by sampling libc at any granularity — two zones can agree on every sampled instant and still differ in rule, so no finer sampling turns offsets into rules, and closing it needs tzdata's own TZif structures, which are out of scope — and the failure is one-directional, this port can only ever be too permissive, so a caller using HasSameRules as a necessary condition is correct and one using it as sufficient is not. The layout cost an earlier design measured (160 → 184) is real, is not what blocks it, and is not paid. #1899 closes carrying its own impossibility proof, recorded beside the contract it justifies rather than only in the ticket: an owning handle to an object no shared_ptr owns cannot be manufactured (the topmost ancestor has no parent), and XElement/XDocument/XContainer are routinely automatic-storage — 51 declarations in this repository's own tests — so enable_shared_from_this would throw bad_weak_ptr at a correct call site rather than rescue anyone. Its two premises are pinned, because a proof whose premises silently stop being true is not a proof. It was 17,533 immediately before, measured on the same date by ticket #1996 (groups G-3 and G-4), which closes that ticket. +5 on the 17,528 below, in SharpRuntimeTests_Uri (300 → 305; two pins inverted in place). G-3 makes setSchemeProperty reject text that is not a scheme — a narrowing, which is precisely what SA-5 grants — and the truncate-at-colon retry is the half the ticket's summary omitted: §14.2 says only "throw for an invalid one", which would reject "http:", while .NET accepts it and stores http, retrying after cutting at the first : (UriBuilder.cs:108-134); an empty scheme is accepted too, the whole block being guarded on value.Length != 0. One pre-existing row's subject moved and the guarantee got stronger: G-2's pin asserted a non-ASCII scheme is folded invariantly, and G-3 makes such a scheme unstorable, closing the Turkish-locale hazard by construction rather than by the fold's implementation. G-4 promotes a relative constructor string, which used to render the unparseable :///www.example.com/path. The summary is wrong about the host: it says "and localhost host", while .NET prefixes http:// and reparses, so the host comes from the string — a mutation using http://localhost/ is caught. And one of my own expectations was wrong, corrected by the reference: I asserted the promoted render was http://www.example.com/path, it is …:80/path, and .NET renders the default port too (_port = _uri.Port, appended whenever != -1) — the test now asserts the stronger property that the promoted and explicit routes are identical. Six mutations, all caught. Downstream: zero sites. One boundary stays deliberately untaken and pinned — .NET's Host setter refuses "contoso.com/path" and this port stores it, which belongs to G-1's block. It was 17,528 immediately before, measured on the same date by ticket #1995 — 17,528 run, 17,528 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1995. +6 on the 17,522 below, in SharpRuntimeTests_Uri (294 → 300; two pins inverted in place, one obsolete pin removed). Uri's identity was the raw input string, so HTTP://EXAMPLE.COM:80/Path and http://example.com/Path were unequal with different hashes. Both members now feed from one canonical key, which is how .NET keeps them consistent — it renders both from UriComponents.HttpRequestUrl = Scheme | Host | Port | Path | Query. The design record proposed only folding and the default port; the reference also excludes the fragment and the user-info, saying so in a comment — "Fragment AND UserInfo (for non-mailto URIs) are ignored" — so http://a/p#one == http://a/p#two and http://u@a/p == http://a/p, which §14.1's wording would never have predicted. A trap measurement found, not the record: defaultPortForScheme matches lower-case names only, so this port parses HTTP://example.com/ with port −1 and the lower-case form with 80 — comparing stored numbers would have left them unequal even after folding the scheme, defeating the repair on its own motivating input; the key resolves the default from the folded scheme, for comparison only, and the parse is untouched. Not reproduced and stated: .NET compares UNC/DOS paths case-insensitively and hashes file: URIs likewise; this port models neither, so equality is narrower than .NET's — never equal where .NET is unequal. UriBuilder is deliberately not delegated, though §14.1 and .NET both say it should: delegating reintroduces the throw #2004 measured and removed, so the two landed decisions conflict and the reference cannot settle it for this port — filed as #2391. Six mutations, all caught; M2/M3/M6 were invalid as first written (anchors spelling \x01 as an escape rather than literal text) and were re-run rather than counted. Downstream: zero sites — the one grep hit is a comment saying cna deliberately avoids System::Uri. It was 17,522 immediately before, measured on the same date by ticket #1997 (group A-3) — 17,522 run, 17,522 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1997 (group A-3). +5 on the 17,517 below, in SharpRuntimeTests_Uri (289 → 294). A-3 gives Uri the two overloads that let a UriCreationOptions reach a Uri operation at all — before this, a value could be constructed and read and then had nowhere to go, which is SR-AUD-149's consumer half. Purely additive, under SA-5. The plan's wording implied the option type had to be written too, and the compiler corrected that: it already existed, and a first cut declaring it alongside the overloads failed with "redefinition of class System::UriCreationOptions" — only the overloads were missing, and the existing type is left untouched, including its public data member, because .NET's is an unvalidated settable auto-property that a public field is observationally identical to (#1969's reasoning for ChannelOptions). Both overloads resolve against Absolute, and that is the reference's choice (Uri.cs:476-480, UriExt.cs:236-240), so a relative string throws through the constructor and fails through TryCreate even though the sibling one-argument constructor accepts one — all three asserted together so the asymmetry is observable rather than theoretical. The option itself stays inert and that is disclosed rather than implied: .NET's flag disables path/query canonicalisation and this port performs none, so a Uri built with it set and one built with it clear are byte-for-byte identical — asserted across three inputs chosen for dot segments, percent sequences and a default port, and if that test ever fails the port has grown canonicalisation and the disclosure must be revisited. The header's obsolete half-warning is replaced accordingly; leaving it would be the SR-AUD-168 defect. Three mutations, all caught, two run once per overload because they are independent bodies. Downstream: zero sites. A-2 and A-4 remain — A-2 on a measured module-boundary cost, A-4 on a vtable and access-level change. It was 17,517 immediately before, measured on the same date by ticket #1999 — 17,517 run, 17,517 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1999. +2 on the 17,515 below, in SharpRuntimeTests_Uri (287 → 289; one pin inverted in place). UriTypeConverter::ConvertFrom returned a by-value Uri, which cannot express .NET's null, so an empty string was forwarded straight to the constructor and threw where .NET returns null (UriTypeConverter.cs:40-51). It now returns std::optional<Uri>. The widening is exactly one input wide, and that is .NET's own boundary: its comment — "Let the Uri constructor throw any informative exceptions" — says the empty case is the only short-circuit, so a malformed string still throws; that has its own pin, because "return the empty state on any failure" is the plausible over-correction. Landed under SA-10 rather than as an approval, and the recorded cost was wrong: §14.5 called it "a vtable-slot signature change plus mandatory migration for every override", but SA-10 names return type explicitly and routes it through SA-2 — SA-3's exclusion is a change to the vtable's shape, not a signature within an existing slot — and there are zero overrides and zero derivations anywhere, in-tree or downstream, so the migration was four test call sites. Four mutations, all caught; M4 only after a case was added — every test converted an absolute URI, so requiring UriKind::Absolute never failed, and a relative input is the only one the kind discriminates, which is exactly why .NET passes RelativeOrAbsolute. M2 was invalid as first written (-Werror=unused-parameter) and was reformulated rather than counted. Fixture set 44 fixtures / 226 sites; site 3's diagnostic is "invalid covariant return type", more precise than the "does not override" predicted — gcc reads the old signature as an attempted covariant override. Downstream: zero sites. It was 17,515 immediately before, measured on the same date by ticket #1980 (group G-5) — 17,515 run, 17,515 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1980 (group G-5). +9 on the 17,506 below, in SharpRuntimeTests_Runtime (189 → 198; one pin migrated in place). G-5 gives MarshalAsAttribute .NET's field typesValue get-only (SA-8), ArraySubType an UnmanagedType rather than a loose intcs, SizeParamIndex a short — adds the two absent fields SafeArraySubType/IidParameterIndex, seals the class, and adds VarEnum, ComInterfaceType and ClassInterfaceType. Field types are the contract for the same reason G-2's values were: a loose integer where .NET has an enum lets any number be stored where only a marshalling kind is meaningful. One member stays absent deliberately: .NET types SafeArrayUserDefinedSubType as Type?, which is reflection, and inventing a second string would look like parity while storing something else — MarshalTypeRef survives as a name-carrying string only because it already did. Six mutations, all caught, four at compile time. M4 is the one worth keeping: .NET's VarEnum jumps from VT_DECIMAL = 14 to VT_I1 = 16, and asserting those neighbours catches a renumbering but not an insertion — inserting VT_UNUSED15 = 15 went uncaught, because C++ cannot enumerate an enum's members. The fix is an exhaustive switch with no default:: the build runs -Wall -Wextra -Werror, so -Wswitch turns any unhandled enumerator into error: enumeration value 'VT_UNUSED15' not handled in switch, pinning the enum's membership — and it fails the other way too, since removing an enumerator breaks the census's own reference. Fixture set 43 fixtures / 223 sites, site 2 being the assignment to Value that let a caller retarget an attribute after construction. Downstream: zero sites. #1980 now has only G-3 left (a vtable and layout change SA-3 excludes). It was 17,506 immediately before, measured on the same date by ticket #1980 (group G-4) — 17,506 run, 17,506 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1980 (group G-4). +3 on the 17,503 below, in SharpRuntimeTests_Runtime (186 → 189; two pins migrated in place). G-4's point is that the port was wrong in BOTH directions, which is why one rule could not fix it: CompilerFeatureRequiredAttribute published a full IsOptional setter where .NET's is { get; init; }too permissive — while ObsoletedOSPlatformAttribute and RequiresPreviewFeaturesAttribute took a url constructor parameter .NET does not declare and fed it into a read-only accessor where .NET's is { get; set; } — too inventive on the constructor and too restrictive on the property. init is two facts, not one: the value can be supplied at construction and cannot be assigned afterwards, so removing the setter alone would have been a narrowing rather than a translation — the two-argument constructor is what makes the removal faithful, and one test asserts both halves together. Landed under SA-8 (the mutability half) and SA-5 (the Url half). Five mutations, all caught, two at compile time; M4 and M5 are run once per type deliberately, because they are two separate declarations and fixing one is the easy half-repair, and M4 was invalid as first written (its anchor spanned doc-comments that differ between the types) and was reformulated rather than counted. The absence pin uses a dependent parameter (the #2299 gcc trap). Fixture set 42 fixtures / 219 sites, site 4 being the trait query that breaks a consumer silently. First-party migration was two sites, both tests, both found by the compiler; downstream zero sites in all three types. G-1 and G-2 landed the same day; G-3 (vtable) and G-5 remain. It was 17,503 immediately before, measured on the same date by ticket #1980 (group G-2) — 17,503 run, 17,503 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1980 (group G-2). +5 on the 17,498 below, in SharpRuntimeTests_Runtime (181 → 186; one pin inverted in place). G-2 corrects the interop metadata values, and getting the numbers right is the whole contract of these types: P/Invoke is a declared permanent deviation, so they exist to preserve managed metadata rather than to produce an effect. The LPStruct finding was understated: the plan recorded "48 → 43", a wrong number, and 48 is LPUTF8Str's value — so the two enumerators were indistinguishable, LPStruct == LPUTF8Str was true, and a switch over UnmanagedType could not carry both arms. Two more divergences the plan never named were found by measuring the reference alongside the four it did: both CharSet fields default to a named value here while .NET's are plain fields defaulting to 0, which is not a declared enumerator (None is 1) — reproducing an unnamed default is deliberate and follows from the types' purpose. Currency/IDispatch were absent, Pack was 8 where .NET's plain public int Pack; gives 0, and PreserveSig/BestFitMapping were true where .NET's plain public bool gives false — the port had already got the other three booleans right, which is what makes those two a divergence rather than a policy. Every other UnmanagedType value already matched, asserted across a spread so three repairs cannot be mistaken for a renumbering. Five mutations, all caught, M2 at compile time — removing an enumerator breaks the test naming it, the only way C++ reports that. The plan predicted the one pin that had to be inverted (DefaultPack_IsEight) and it was the only pre-existing test the group touched. Downstream: zero sites in both consumers. G-1 landed the same day; G-3 (vtable), G-4 (mandatory migration) and G-5 remain. It was 17,498 immediately before, measured on the same date by ticket #1958 (SR-AUD-196) — 17,498 run, 17,498 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1958 (SR-AUD-196). +1 on the 17,497 below, in SharpRuntimeTests_Threading (521 → 522; two message-taking cases replaced by three, and the integration case rewritten in place). ThreadStartException published three constructors where .NET has two, and .NET has no message-taking one at all — both of its own pass the fixed SR.Arg_ThreadStartException (ThreadStartException.cs:11-24), so ThreadStartException("anything") produced an exception .NET can never produce while still claiming COR_E_THREADSTART. The port's own doc-comment already said the constructors are internal in .NET, so the documentation and the code disagreed. The surviving overload takes the reason alone, which is why it replaces the old (message, inner) pair rather than joining it; the class is now final, matching sealed. Landed under SA-8 with SA-2's five conditions. Five mutations, all caught, two at compile time. Fixture set 41 fixtures / 215 sites; site 3 is the string-literal spelling, the one most likely to survive a careless migration because a literal is not a std::string. The first-party count was wrong and the compiler corrected it: four sites, not two — a grep over modules/ and test/ missed tests/integration/, a separate tree. Downstream: zero sites in both consumers. The accessibility half is filed, not guessed — .NET's internal has no C++ equivalent, and the two mechanical translations differ from each other and from .NET (a friendless private makes the type uninstantiable; a friend Thread would be dead, because this port has no window in which to throw it), so #2390 asks it as a general rule. This closes #1958's seventh finding; only SR-AUD-209 remains. It was 17,497 immediately before, measured on the same date by ticket #1958 (SR-AUD-194) — 17,497 run, 17,497 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1958 (SR-AUD-194). +7 on the 17,490 below, in SharpRuntimeTests_Threading (514 → 521). Thread::Start(void*) captured its argument and then discarded it with a literal (void)parameter; while its own doc-comment said the value was "forwarded to the thread function" — there was no way to receive it and no diagnostic, because the only accepted callback shape had no parameter slot. The recorded cost estimate was wrong and is corrected by measurement: #1958 listed this as "a signature change", routing it through SA-10 and SA-2, and no existing signature changes — the repair is an additive second constructor (.NET's Thread(ParameterizedThreadStart), Thread.cs:152) plus an SA-5 behaviour change, so it landed as ordinary work with a pinned layout. The one spelling that would become ambiguous, Thread(nullptr), exists in zero places across modules/, test/ and both consumers. No shape flag was needed: exactly one of the two callables is ever set, and which one is .NET's startHelper._start is ThreadStart test. sizeof(Thread) 104 → 136; consumers rebuild. Two asymmetries are .NET's and both are pinnedStart() does not reject a parameterized thread (it passes null, Thread.cs:239-253), and the shape check applies only before the first start, because .NET wraps it in if (startHelper != null) and says so in a comment. That second pin was written asserting the opposite and failed, and the reference showed the TEST was wrong rather than the code — this port gets the same rule from fn_ being moved from on the first start, exactly as startHelper is nulled. Five mutations, all caught; two only after repair — M1 was a SEGV because the test dereferenced a null the mutation supplies, and M3 was a SIGABRT because my mutation threw from a thread body, which is not a realistic regression. M2 is caught only as a crash and that is inherent: removing the guard hands an empty std::function to a new thread, reaching std::terminate with no handler — the #2215 limit. Downstream: zero sites in both consumers. #1958 now has two findings left, SR-AUD-209 and SR-AUD-196. It was 17,490 immediately before, measured on the same date by ticket #1958 (SR-AUD-220) — 17,490 run, 17,490 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1958 (SR-AUD-220). +8 on the 17,482 below, in SharpRuntimeTests_Threading (506 → 514). ThreadLocal's trackAllValues was accepted and never read, and the type had no Values property at all — two halves of one finding, inseparable because the flag is only observable through the property it gates, so a caller who asked for tracking got a silent no-op with no way to notice. Values is transcribed from ThreadLocal.cs:421-434, message included, and the tracking check precedes the disposed check, so a disposed untracked instance reports InvalidOperationException. The lifetime question decided the design: .NET's GetValuesAsList walks the ThreadLocal's own LinkedSlot list, so a value survives its thread exiting — the registry therefore holds strong references and per-thread storage moved unique_ptrshared_ptr, since a weak_ptr registry would silently drop a dead thread's value. Co-ownership rather than copying is also what makes an update reflected rather than duplicated. sizeof(ThreadLocal<int>) 56 → 128; consumers rebuild. Six mutations, all caught — two only after the tests meant to catch them were found vacuous, and both are recorded: the check-order mutation cannot be asserted with EXPECT_THROW, because ObjectDisposedException derives from InvalidOperationException (the #2152 trap), so the derived type must be caught first; and Dispose's registry release is unreachable through the public surface — after Dispose, Values throws either way — so its only observable is when values are destroyed, tested by letting the owning thread exit and counting destructor calls. Downstream: zero sites in both consumers. #1958 stays open for SR-AUD-209 (vtable), SR-AUD-194 (signature) and SR-AUD-196. It was 17,482 immediately before, measured on the same date by ticket #2389 — 17,482 run, 17,482 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2389. +6 on the 17,476 below, in SharpRuntimeTests_Threading (500 → 506). #2389 completes what #1956 half-landed: .NET's ReaderWriterLockSlim.Dispose performs two checks (ReaderWriterLockSlim.cs:1250-1258) and #1956's design record named only the held-mode one, so Dispose accepted a lock other threads were waiting for. waitingReaders_ and waitingUpgraders_ join SR-AUD-204's waitingWriters_, and the two new counters feed no admission predicate — only writer-waiting does — so their guards take notifyOnLast = false and cannot perturb wake-up ordering. sizeof 120 → 128: SR-AUD-204's single counter fit existing padding and these two did not, so consumers rebuild. Four mutations, all caught. Three process notes, and they are the substance of this ticket. (1) The first mutation run reported all four "caught" — by a pre-existing layout gate asserting 120, which was failing on the unmutated build too, so no verdict meant anything; a mutation verdict is only evidence against a baseline that passes. (2) Re-run green, M1 and M2 came back NOT CAUGHT — a real defect in my tests, which disposed from a thread that also held a mode, so the held-mode check fired and the waiter check was never exercised; the disposer must hold nothing. (3) The corrected tests then flaked inside the gate: a fixed 150 ms settle does not guarantee the waiter has been counted. They now rebuild the scenario over up to six attempts and pass the moment Dispose refuses — sound rather than tolerant, since only a genuinely firing check can pass it, and every mutation still fails all six. That layout gate doing its job is also the evidence 128 is a real change rather than drift. Downstream: zero sites in both consumers. It was 17,476 immediately before, measured on the same date by ticket #1956 — 17,476 run, 17,476 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1956. +9 on the 17,467 below, in SharpRuntimeTests_Threading (491 → 500). #1956 makes disposal a real state across System::Threading. Its recorded gate was "the repair turns previously-succeeding public calls into throws … needs approval question 1", and SA-5 grants exactly that, verbatim"including where a call that succeeds today starts throwing" — granted 2026-08-17, two weeks after the design record was written. Four findings: the three wait handles' Close() were empty bodies, so Close() then WaitOne(0) returned success while the headers already claimed Close "closes the handle" — documentation and behaviour disagreed; ThreadLocal::IsValueCreated answered false when disposed, indistinguishable from alive, no value yet; ReaderWriterLockSlim::Dispose() succeeded with a lock held. One member is deliberately excluded and that is pinned, not glossed: ITimer::Change must return false, not throw — .NET's Timer.Change opens if (_canceled) { return false; } (Timer.cs:539-542), so making it throw for symmetry would contradict the interface; mutation M6 is the one that pin exists to stop, since "make disposal consistent" would otherwise look like an improvement. Mutex overrides Dispose() rather than shadowing Close(), which is .NET's own arrangement (Close() => Dispose()), so all three routes reach the guard. Every affected sizeof is unchanged — the flags land in existing padding. Six mutations, all caught; M6 was invalid as first written (a missing include) and was reformulated rather than counted. One deliberate narrowing is recorded: .NET's Dispose performs two checks and this port implements the second — the waiters half needs per-mode waiter counts this port lacks — so the behaviour is a strict subset of .NET's, never refusing a disposal .NET would accept; filed as #2389, and the design record's own gap rather than an implementation shortcut. Downstream: zero sites in all six affected types, measured separately. It was 17,467 immediately before, measured on the same date by ticket #1957 (SR-AUD-204) — 17,467 run, 17,467 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1957 (SR-AUD-204). +5 on the 17,462 below, in SharpRuntimeTests_Threading (486 → 491), and it closes #1957's fourth and last member. SR-AUD-204 gives ReaderWriterLockSlim writer preference: the read predicate was !writerActive_ alone — it asked whether a writer held the lock, never whether one was waiting — so a steady arrival of readers starved a blocked writer indefinitely. .NET packs the same signal into its _owners word, and its own comment is the derivation: "Setting these bits will prevent new readers from getting in" (ReaderWriterLockSlim.cs:1005-1010) — WAITING_WRITERS and WAITING_UPGRADER both sit above MAX_READER, so the single test _owners < MAX_READER refuses a reader when a writer holds or awaits the lock. Both kinds count, so an upgrade-to-write blocks readers too — the easy half to miss, pinned separately — and a timed-out writer stops blocking, via an RAII guard mirroring .NET's finally, because that failure mode would be permanent rather than transient. sizeof is 120 before and after. Writer preference cannot deadlock a recursive reader: every thread already holding a read, write or upgrade lock returns before the predicate. Two mutations, both caught. Three honest results on the sanitizer, two of them limits: the full target cannot build under TSan — Thread::MemoryBarrier() is std::atomic_thread_fence, which gcc rejects under -Werror=tsan, a pre-existing incompatibility (#2298's) reached from an unrelated file — so the evidence is a focused probe, clean over 1,020,489 read acquisitions; the probe was shown able to report (a deliberate unsynchronised counter yields 2 race warnings), per the plan's rule that a silent sanitizer is evidence about the probe; and the probe does not reproduce the starvation, reporting writes=6089 even pre-repair, because its readers do not overlap continuously — the deterministic gtest does that instead. Downstream: zero sites in both consumers. It was 17,462 immediately before, measured on the same date by ticket #1957 (SR-AUD-210) — 17,462 run, 17,462 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1957 (SR-AUD-210). +6 on the 17,456 below, in SharpRuntimeTests_Threading (480 → 486). #1957/SR-AUD-210 lets a Barrier's post-phase action read the phase number: FinishPhase() runs that action while holding mutex_, and getCurrentPhaseNumberProperty() took the same non-recursive mutex, so a legal call deadlocked. This is not a new discovery — #1955 fixed the sibling property exactly this way and its comment names this one as the remaining case. .NET's CurrentPhaseNumber is Volatile.Read(ref _currentPhase) (Barrier.cs:184-188), a lock-free read, so the reference settles the design and phaseCount_ becomes a std::atomic<longcs>layout-neutral, sizeof(Barrier) 160 before and after. The design record named only the deadlock, and the reference exposes a second half: .NET increments the phase in SetResetEvents, called from FinishPhase's finally after the action and on the throwing path too, while this port incremented first — unobservable only because the property that would have seen it hung. Fixing the deadlock alone would have shipped a newly reachable wrong answer in place of a hang, so both land together and the action now reads the phase that is ending, as .NET does; nothing outside the action moves, since mutex_ is held throughout. Three mutations, all caught. M2 was invalid as first written — adding the increment without removing the later one is a double increment, not a move — and was reformulated rather than counted. M3 was first reported "not caught" and that was a defect in my mutation harness, found by checking rather than believing it: M3 hangs a pre-existing multi-participant case, so the whole-suite run hit its timeout and emitted no FAILED lines at all, which the harness read as a pass — run alone, the new throwing-action pin fails in 0 ms. SR-AUD-204 remains and #1957 stays open for it (SR-AUD-202 landed as #2341, SR-AUD-201 earlier the same day). Downstream: zero sites in both consumers. It was 17,456 immediately before, measured on the same date by ticket #1957 (SR-AUD-201) — 17,456 run, 17,456 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1957 (SR-AUD-201). +5 on the 17,451 below, in SharpRuntimeTests_Threading (475 → 480). #1957/SR-AUD-201 makes PeriodicTimer::WaitForNextTick single-consumer: two concurrent waiters used to both return true for one tick (the audit measured concurrent=1,1), so a caller that accidentally shared a timer got twice the intended work rate with no diagnostic — the worst shape of concurrency bug, where the wrong answer is a plausible one. #1957's design predates SA-3 (2026-08-17), which answers its whole approval question, and the reference resolves its one recorded [unverified] flag: .NET carries private bool _activeWait and throws (PeriodicTimer.cs:192,199-203), commenting "Failing to do so is an error." The guard runs first and that is transcribed: .NET tests it before the cancellation short-circuit and the signalled fast path, so a second consumer is refused even on a disposed timer — it is the concurrent use that is the error. The flag clears on every exit via RAII, which is the mistake in the other direction: a flag never cleared makes the first wait lock the timer out for ever. sizeof is 128 before and after — the bool fits existing padding — so SA-3's pin records no growth at all. Three mutations, two caught; M3 is uncaught and cannot be caught deterministically, because moving the guard below the disposed check differs only in the race window between Dispose() releasing the mutex and the parked consumer reacquiring it — a test for it would be flaky, and this session has twice repaired flaky tests rather than written one; the ordering is kept because it is .NET's and the site says so. SR-AUD-204 and SR-AUD-210 are untouched and #1957 stays open for them (SR-AUD-202 landed earlier as #2341). Downstream: zero sites in both consumers. It was 17,451 immediately before, measured on the same date by ticket #1981 — 17,451 run, 17,451 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1981. +7 on the 17,444 below, in SharpRuntimeTests_Runtime (174 → 181). #1981 stops ConditionalWeakTable's enumerator keeping every snapshotted value alive — for a type whose whole purpose is not to retain values, that is a defect of the primary contract. Measured before and after: table.Remove(key) left the value alive until the enumerator was deleted, and now releases it. The snapshot demotes each value to a weak_ptr and MoveNext locks both halves, so only Current is retained and an entry released after the snapshot is skipped rather than yielded stale. Reset() becomes empty, transcribed from ConditionalWeakTable.cs:492 — so a caller can no longer re-enumerate, and because the body really is empty it does not clear Current either, which is the easy mistake in the other direction and is pinned separately. .NET's own design is deliberately not reproduced and the reason is recorded: its enumerator holds no snapshot at all but a borrowed reference to the table plus an index range, and GetEnumerator() here returns a raw pointer whose lifetime the table does not control — reproducing it would reintroduce CCF-019. The recorded cost was overstated: Enumerator is a private nested class the table heap-allocates, so no consumer can name, size or hold one, and the table's own sizeof is 72 before and after — SA-3's pin is on the type that can actually be sized. Three mutations, all caught; M3 only after a case was added, because no test enumerated after a Remove, which is the sole situation where the key is alive and the value is not — testing the key alone yields a pair with a null value and counts it as live. M1 was invalid as first written and was reformulated rather than counted. No existing test relied on the old Reset(), all 174 passing unchanged. Downstream: zero sites in both consumers. It was 17,444 immediately before, measured on the same date by ticket #2150 — 17,444 run, 17,444 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2150. +10 on the 17,434 below, in SharpRuntimeTests_IO_Compression (103 → 113; one absence pin inverted, ten cases added). #2150 gives DeflateStream, GZipStream and ZLibStream .NET's (Stream, ZLibCompressionOptions, bool) constructor. Its recorded gate was a classification, and this repository's own design record already contradicted it: the ticket said "public surface addition — approval required", while docs/SystemIOCompressionNamespaceReviewPlan.md §7 lists #2150 as signature new ctors, vtable —, layout —, noexcept —, result additive — the #1980-G-1 and #1997-A-1 shape, which is ordinary SA-5 work. The one substantive claim is false and is now asserted rather than argued: a new overload can only change an existing call if some argument binds to both, and CompressionMode is a scoped enum while ZLibCompressionOptions has no converting constructor, so nothing converts to both — pinned, so the claim must be re-made if either fact changes. The format arithmetic went from three copies to one, not four: each encoder carried its own in an anonymous namespace and the streams had none, so Detail::ResolveWindowBits is shared and the encoders now delegate to it — a net removal, proven behaviour-preserving by all 103 pre-existing cases passing unchanged. Two asymmetries are transcribed rather than smoothed: Deflate and GZip clamp the window log to 9 and ZLib deliberately does not (.NET's comment explains that classic zlib silently upgrades 8 to 9 while zlib-ng rejects it), and the sign and offset are the container. Six mutations, all caught — M3 only after a pin was added, because every end-to-end case used a window log of 9 or 15 and never exercised the one input that separates clamping from not clamping; asserting the resolver directly was necessary rather than merely convenient, since classic zlib upgrades 8 to 9 internally and no end-to-end test could discriminate it. M6 was invalid as first written (-Werror on an orphaned constexpr) and was reformulated rather than counted. Downstream: zero sites in both consumers. It was 17,434 immediately before, measured on the same date by ticket #2388 — 17,434 run, 17,434 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2388. +4 on the 17,430 below, in SharpRuntimeTests_Threading_Tasks (222 → 226; one pin rewritten in place, four cases added). #2388 is #1969's other half, filed and closed the same day: ParallelOptions::MaxDegreeOfParallelism is private behind the rule-5 pair and validated in its setter, where .NET validates it (Parallel.cs:85-90). #1966 had already landed the same rule at the entry of every Parallel method, because a public data member has nowhere to put a check — and the difference is observable, not cosmetic: an invalid degree used to be stored and survive until a loop ran, so a caller that assigned and never looped got no diagnostic at all, and one that read the value back read a number .NET can never hold. The parameter name stays "MaxDegreeOfParallelism" and that is confirmed rather than assumed: .NET writes nameof(MaxDegreeOfParallelism) here and nameof(value) in BoundedChannelOptionsthe reference is inconsistent between its two option types, both are transcribed as they are, and harmonising this onto #1969's "value" would be inventing a reference. One #1966 test moved rather than being left to pass for the wrong reason: it asserted the degree error beats the empty-body error, and with the guard in the setter there is no ordering left to assert — the throw now comes from the assignment, outside the EXPECT_THROW, which is the #2359 trap; it is replaced by the stronger property that an invalid degree cannot reach Parallel::For at all. Five mutations caught — M4 by the new pin and four pre-existing #1966 cases — and one equivalence recorded rather than counted: removing the now-unreachable use-site guard changes nothing, because the field is private, its only mutator validates, and the private member makes ParallelOptions a non-aggregate, so brace initialisation cannot reach it either; it is kept as defence in depth and the site says so. Fixture set 40 fixtures / 211 sites, site 4 being the aggregate-ness a consumer loses silently. Downstream zero sites in both consumers, measured separately from #1969's rather than assumed to match. It was 17,430 immediately before, measured on the same date by ticket #1969 — 17,430 run, 17,430 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1969. +6 on the 17,424 below, in SharpRuntimeTests_Threading_Channels (64 → 70). #1969's recorded blocker was an approval request, and SA-8 grants it verbatim"making a public data member private and adding the rule-5 accessor pair" is the question this ticket asked on 2026-08-03, and SA-8 adds that the decision "is not to be re-litigated ticket by ticket". BoundedChannelOptions::FullMode was a bare public data member, and the shape was the whole obstacle: a data member has nowhere to put a check, so static_cast<BoundedChannelFullMode>(99) could be stored — and then the writer took the drop path (mode ≠ Wait) and matched no arm of the drop switch, so nothing was dropped and Count reached 2 on a channel bounded at 1. The reference corrects the ticket twice: .NET validates two members, not one — and this port already had Capacity right, private with ThrowIfNegative(…, "value") at both doors, so nothing there needed changing; and the capacity bound is < 0, not < 1, so a zero-capacity channel is legal in both and the intuitive "at least one slot" repair would be a divergence. The three base flags are deliberately left public and the boundary is pinned: .NET's SingleWriter/SingleReader/AllowSynchronousContinuations are auto-properties with no validation, so a public field is observationally identical and SA-8 does not reach them — converting them would be a source break buying no behaviour, the exact inverse of FullMode. Five mutations, all caught, and M3 only after a vacuous assertion was repaired: it searched what() for "value", and the default message is "…out of the range of valid values.", which contains the substring whatever the parameter is named — it now asserts getParamNameProperty(). M5 additionally hangs a pre-existing case, and a mutation caught only as a hang is not caught by name. Fixture set 39 fixtures / 207 sites; six first-party sites migrated; downstream zero sites in both consumers. #2388 is filed rather than bundled for the identical shape one module over, with the correction that ParallelOptions's parameter name is already right — .NET writes nameof(MaxDegreeOfParallelism) there and nameof(value) in Channels, and both are transcribed as they are. It was 17,424 immediately before, measured on the same date by ticket #2202 — 17,424 run, 17,424 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2202. +3 on the 17,421 below, in SharpRuntimeTests_Xml (515 → 518). #2202 changed no production statement: it is a declaration and its evidence, the shape of #2015 and #2324. This runtime writes a processing instruction correctly in any position and can read one back only before every other node — an XML declaration may precede it and nothing else may, not even a comment — so SR-AUD-349's closure property does not hold for this one node kind. The ticket's own acceptance criteria prescribe this outcome ("if the substrate cannot express it, the correct outcome is an explicit documented limitation … plus a pin, not silence"); the substrate had already been confirmed unable, and the documentation and the second pin were simply never written. The cause is measured, not asserted: called directly with no port code involved, vendor/tinyxml2 produces exactly the port's five verdicts, and the leading <?p d?> comes back as a Declaration node valued p d — tinyxml2 has no processing-instruction node type at all, so every <? inherits the rule that a declaration may appear only before anything else. .NET differs by exactly that model distinction: XmlLoader.cs:203-209 switches on XmlDeclaration and ProcessingInstruction as separate cases in the same general node loop, which runs for element content too. Two routes were refused — patching vendor/ (third-party, never edited) and pre-rewriting non-leading <?…?> around the substrate, which forks its semantics for every document; mutation M3 shows what that costs, a naive shim breaking the shipped XPathSelectTests.ProcessingInstructionNodeTest_SelectsPI. Three mutations, all caught, two of them also by pre-existing tests. Pinned at both layers as the criteria require, and the asymmetry pin is written to fail the moment the limitation lifts. It was 17,421 immediately before, measured on the same date by ticket #2059 — 17,421 run, 17,421 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #2059. +1 on the 17,420 below, in SharpRuntimeTests_Buffers (629 → 630). #2059's premise does not survive the reference, and the ticket is resolved by declining its own proposed repair. SR-AUD-088 is titled "MemoryHandle documents RAII cleanup but never unpins at scope exit" and #2059 existed to add ~MemoryHandle(){ Dispose(); } — but .NET's is public unsafe struct MemoryHandle : IDisposable (MemoryHandle.cs:12), a struct with no finalizer, so scope exit does not unpin there either; using var h = memory.Pin() is a language construct, not something the type does. What the finding actually found was a doc-comment promising what the type never did, and that promise had already been removed — so adding the destructor would be a divergence, not a repair, and the ticket's own copy hazard (one unpin per copy for a single pin) is the second, independent reason. Dispose() needed no change either: it already matches MemoryHandle.cs:41-53 statement for statement, idempotence included. What did land is the divergence the ticket never named: both data members were public here and are private in .NET, which publishes exactly one of them — Pointer, as a getter — so the port let a caller retarget a live handle, or detach its IPinnable and make the subsequent Dispose() a silent no-op that leaks the pin. Landed under SA-8 with SA-2's five conditions discharged; zero first-party sites needed migrating, measured across all of modules/ and test/. It is an access change and not a layout changesizeof is 24 before and after, so no consumer rebuilds — the same shape as #2332's SequencePosition. .NET's third field, GCHandle _handle, stays deliberately absent: no moving collector, no handle to free. Five mutations, all caught, two of them at compile time through absence pins that use a dependent parameter (the #2299 gcc trap). Fixture set grows to 38 fixtures / 204 sites, its third site being the detach that breaks silently rather than loudly. Downstream: zero sites in both consumers. It was 17,420 immediately before, measured on the same date by ticket #1958 (SR-AUD-193) — 17,420 run, 17,420 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1958 (SR-AUD-193). +4 on the 17,416 below, in SharpRuntimeTests_Threading (471 → 475). #1958 gives every thread a distinct ManagedThreadId: Thread::CurrentThread().getManagedThreadIdProperty() returned 1 from every thread not created through a System::Threading::Thread, so the main thread, every raw std::thread and every pool worker all reported the same number — erasing the uniqueness .NET's contract states unconditionally, and silently breaking anything keyed on it. The naive repair is wrong and intermittently so: handing 1 to whoever asks first gives it to a worker whenever one asks before main does, so the main thread keeps 1 by identity, from an OS id captured during static initialisation — which runs on it. One counter, not two, and that is the load-bearing choice: uniqueness is across all threads, not within each kind, so external threads draw from the same counter the wrapper's constructor uses. The obvious test for that does not work and the honest record is the point: collecting four ids of each kind and asserting eight distinct values passes against a second-counter mutation, because two counters only collide where their ranges overlap and a fresh one sits far below the shared one — the assertion that discriminates is ordering, an external id taken after a Thread object's id must exceed it. Four mutations, all caught, the fourth only after that rewrite. The id is assigned on first use, so a thread that never asks costs nothing. No signature, layout, vtable or noexcept change — the id lives in a thread_local, so no consumer rebuilds. #1958's own description is stale and is corrected rather than copied: SR-AUD-214 and SR-AUD-189 were landed by #1971 on 2026-08-03, and the ticket stays blocked only for members needing approvals this one did not (SR-AUD-209 a vtable change, SR-AUD-194 a signature change, SR-AUD-196 removing public constructors, SR-AUD-220 storage on a public template). Downstream: zero sites in both consumers. It was 17,416 immediately before, measured on the same date by ticket #1996 — 17,416 run, 17,416 passed, 0 failed, 0 skipped, measured on 2026-08-19 by ticket #1996 (groups G-1 and G-2 only). +4 on the 17,412 below, in SharpRuntimeTests_Uri (283 → 287; two pins updated, four cases added). #1996 names G-1 + G-2 as the recommended minimum and both are alignments to the reference, so SA-5 covers them: setHostProperty("::1") now renders http://[::1]/ instead of the unparseable http://::1/, and setSchemeProperty("HTTP") lower-cases. G-3 — which #1996 calls “the only narrowing” — and G-4 are not taken and are pinned absent. One deliberate divergence is forced by that split and is recorded at the site: .NET’s host setter wraps first and then throws, so "[::1" becomes "[[::1]" and is refused; without the rejection the wrap would store that nonsense and turn a value #1991 already refuses into one it might not — so a value already carrying a bracket is left exactly as given. h:abc[h:abc] is not a divergence but .NET’s own “probable ipv6 address” rule, and it made two existing pins’ unparseable examples parseable, so their subject moved to "[::1" and the empty host. Five mutations, four caught; the uncaught one is an equivalence in the “C” localestd::tolower agrees with the explicit ASCII fold on every byte there, and distinguishing them needs the global locale changed inside a shared binary, which #2174 considered and declined. Downstream: zero sites. It was 17,412 immediately before, measured on the same date by ticket #1997. +4 on the 17,408 below, in SharpRuntimeTests_Uri (279 → 283). #1997’s acceptance criterion calls A-1 “strictly additive and touches no existing declaration”, so Uri::GetLeftPart(UriPartial) lands under SA-5 — System::UriPartial had documented it since it was ported and the member did not exist. Two details are only visible in the reference: the scheme delimiter is a substring of the source, not a rule — GetParts(Scheme | KeepDelimiter) is _string.Substring(Offset.Scheme, Offset.User − Offset.Scheme) — so mailto: keeps its bare colon; and Authority is the empty string when there is none, where .NET’s comment three lines above return string.Empty; says the opposite of it, and the code is what runs. A-2’s cost estimate was wrong and is corrected rather than paid: the same criterion calls CheckHostName “strictly additive”, and it classifies through IPv6AddressHelper/IPv4AddressHelper, which modules/uri has not — it depends on Core.Base alone and IPv6 content validation is a declared out-of-scope boundary there. A first cut wrote it against IPAddress and the module graph rejected it, so the cost is measured rather than guessed; A-2 stays with #1997 and the reason sits in Uri.hpp. Six mutations, all caught. Module graph unchanged at 41/93. Downstream: zero sites. It was 17,408 immediately before, measured on the same date by ticket #1980. +4 on the 17,404 below, in SharpRuntimeTests_Runtime (170 → 174). #1980’s own acceptance criterion calls G-1 “purely ADDITIVEcannot break a consumer, which makes it ordinary SA-5 work rather than an approval; G-2..G-5 are untouched and the ticket stays blocked for them. OSPlatform gains the default .NET’s readonly struct has always had, RuntimeInformation gains RuntimeIdentifier, and ExternalException gains (message, errorCode) and ErrorCode. Two premise corrections: ErrorCode needs no data member — .NET’s is ErrorCode => HResult, an alias, so sizeof is unchanged; and the header’s blanket note that RuntimeIdentifier/FrameworkDescription cannot be described is right for one of them — FrameworkDescription is a build-generated .NET product version, while RuntimeIdentifier is AppContext.GetData(...) as string ?? "unknown", reproduced exactly, as string being a type test rather than a coercion. ToString() was implemented and then removed by the downstream measurement: cna derives from ExternalException in three types, so the statically resolved name #2323 used would have misnamed every one — #2323’s own rule, that naming the wrong type is a lie where an absence is not. Split out as #2387 (needs_user), because a virtual is a vtable change and a stored name breaks those three. Five mutations, all caught; two were invalid as first written, one of them caught by compilation, which is the only way C++ reports a missing constructor. It was 17,404 immediately before, measured on the same date by ticket #1983. +2 on the 17,402 below, in SharpRuntimeTests_Runtime (168 → 170). #1983 gives Windows OSArchitecture .NET’s two-step probe — IsWow64Process2 resolved at run time, else GetNativeSystemInfo — so a WOW64 process stops reporting X86 as the operating system’s architecture, and makes an unrecognised compilation target a build error. Two of the ticket’s three “absences, ALL of which must be resolved” are gone: a MinGW-w64 cross-compiler is installed and /rv is present; only the mixed-bitness Windows host remains, and that gates observation rather than implementation — #2378’s position exactly. The fabricated X64 fallback is replaced by #error, which is all .NET offers, because the property states the compilation target and an unknown target means the build is wrong. Two mapping tables, not one: IMAGE_FILE_MACHINE_* and PROCESSOR_ARCHITECTURE_* are different enumerations with different defaults, and both asymmetries are transcribed. Four mutation attempts, two valid and both caught — symbol inspection shows the Windows imports drop 2 → 0, and a lifted preprocessor chain shows the baseline rejected where the mutation compiles. One mutation is uncaught and cannot be caught here: changing a mapping arm alters no symbol, so its behaviour is unverifiable without a Windows host — which is the third absence, restated rather than waved away. Downstream: zero code sites. It was 17,402 immediately before, measured on the same date by ticket #2194. +4 on the 17,398 below, in SharpRuntimeTests_Net_NetworkInformation (63 → 67). #2194 makes Ping correlate its reply to its request and report a refused socket option instead of discarding the result. Its blocker was environmental and is goneping_group_range was 1 0 and is now 0 2147483647, so a real round trip runs. The acceptance criterion is wrong on two of its three fields, both measured: the identifier cannot be matched, because a Linux ping socket has the kernel rewrite it (probed, 0x1234 out and 0x94d4 back) — .NET checks it only because its raw path writes an id the kernel leaves alone; and the source must not be matched, because an ICMP error legitimately comes from an intermediate router, which is what a low Ttl asks for, and .NET does not check it either. The sequence survives and is what correlates. Four mutations, two caught — and the two that were not are the informative ones. Computing the correlation and ignoring it changes nothing here, which exposed a vacuous test of mine: a ping socket is demultiplexed by the kernel-assigned id, so a foreign datagram never reaches our queue. The check is therefore defensive on this platform and is kept because #1962 would add a raw socket, which receives every ICMP datagram on the host. The restarted-deadline mutation would hang rather than fail, so no non-flaky test can catch it. Ttl = 256 is the reachable option door: PingOptions rejects only ttl <= 0, which is .NET’s own bound. Downstream: zero code sites. It was 17,398 immediately before, measured on the same date by ticket #2042. +7 on the 17,391 below, in SharpRuntimeTests_Net (333 → 340; two pins inverted, nine cases in their place). #2042 bounds CookieContainer, which was unbounded in every direction — 10,000 cookies from one origin were all retained, and an expired cookie was kept for ever and merely hidden from emission. Its gate was “every bound is a number somebody must choose” AND “.NET’s exact default capacities cannot be established here”, which are the same claim and it is false: CookieContainer.cs:69-71 defines 300, 20 and 4096. The per-domain limit binds at 19, not 20, and that is .NET’s arithmetic — aging must free a slot for the cookie being added. MaxCookieSize is the one limit that reports rather than evicting, and it bounds the Value alone; a capacity that cannot be satisfied silently rejects, as .NET does. One structural difference is stated: .NET evicts from the least-recently-used path collection, and this flat list has no collections to time-stamp, so it drops the oldest stored cookie — within a collection .NET does the same thing. No extra state was needed: insertion order is vector order. Six mutations, all caught, three only after the case meant to catch them was fixed — the domain must be at its limit for the ordering to be observable, and the expired cookie must not also be the oldest or plain eviction removes it anyway. A bug in one new test (iterators into two different temporaries) hung the suite once and is named in the note. Downstream: zero code sites, and all six cross-module consumer suites green. It was 17,391 immediately before, measured on the same date by ticket #2031. +3 on the 17,388 below, in SharpRuntimeTests_Diagnostics (229 → 232; one pin inverted, four cases in its place). #2031 makes Kill(entireProcessTree) walk the tree instead of killpg-ing one process group, which a setsid() descendant had left. The reference corrects the ticket’s own proposed design three times: SIGSTOP comes before the children are enumerated — .NET’s comment says why, “so it won’t start additional children” — where option A enumerated then killed and so reintroduced the defect one level down; there is a self-guard refusing a tree that contains the caller; and failures are collected while ESRCH is ignored. The guard’s placement is load-bearing: it runs before the current-process no-op because that is where .NET has it, and a first cut that put it after left it unreachable through any ordinary Process object. Six mutations, five caught. The “no recursion” mutation went uncaught at first for an instructive reasonsetsid() changes the session, not the parent, so the original pin’s grandchild is an immediate child and a one-level walk kills it; a three-deep case was added. The SIGSTOP mutation is uncaught and cannot be caught deterministically: it removes a race window, and the only test that would see it is a flaky one, which this session has twice repaired rather than written. Kill(false) is deliberately untouched. Downstream: zero code sites. It was 17,388 immediately before, measured on the same date by ticket #2117. +2 on the 17,386 below, in SharpRuntimeTests_Text_Json (298 → 300; two pins inverted, four cases in their place). #2117 makes a JsonElement captured before JsonDocument::Dispose() raise ObjectDisposedException instead of answering. Its gate was SA-3 all along — the ticket said it is “gated exactly as modules/io’s #2098 is”, and #2098 landed under SA-3 the day before. The design is .NET’s rather than a workaround: .NET’s JsonElement holds _parent plus _idx and every accessor delegates through CheckNotDisposed(), so the flag lives with the document; this port’s element now points at a shared JsonDocumentState and carries the node as a raw pointer into it. sizeof(JsonElement) 48 → 56, pinned by shadow structs asserting a difference of exactly one pointer; consumers rebuild. The separate disposed_ bool is deleted rather than mirrored — two flags for one fact is what let a document and its elements disagree — and deriving a child no longer builds an aliasing shared_ptr at all. Two boundaries are .NET’s and both are pinned: a default element stays Undefined (CheckValidInstance raises InvalidOperationException, a different exception from CheckNotDisposed), and a Clone() taken beforehand survives, because .NET’s Clone roots the copy in a new document. ValueKind throws too, which is easy to omit. Five mutations, all caught. Downstream: zero code sites. It was 17,386 immediately before, measured on the same date by ticket #2003. +1 on the 17,385 below, in SharpRuntimeTests_Uri (278 → 279). #2003 changed no production statement: it asked for approval to make Uri reject an embedded NUL, “accepting that the .NET behaviour it matches could not be re-measured in this environment” — and with the reference present it measures the other way, so the approval must not be sought. UriHelper.s_notSafeForUnescapeChars lists U+0000–U+001F explicitly, Uri.TryCreateThis has no whole-string character precheck at all, and a control character in the path is percent-escaped rather than refused. This port already matches, and the one difference — raw rather than %00 — is the declared no-percent-encoding boundary, not a second divergence. Two premise corrections: the title’s “every component” stopped being true when #2359 gave the host .NET’s DNS character set four days later; and “no truncation” had been asserted of the string but never of the accessors, which a first probe appeared to contradict only because %s stops at a NUL — every row now asserts a length. The decision is enforced rather than written down: truncating the input is caught by four tests and the repair the ticket proposed is caught by three, both built and run before being reverted. It was 17,385 immediately before, measured on the same date by ticket #2086. +3 on the 17,382 below, in SharpRuntimeTests_Xml (512 → 515; one pin inverted, four cases in its place). #2086 makes RemoveAll dispatch the node-change pair per child and leaves those children alive. The ticket recorded two candidates with neither selected and (b) marked “NOT compatible without approval”; the reference selects (b)XmlNode.RemoveAll is literally a loop over RemoveChild — so it is derived, not chosen. The lifetime hazard #2079 refused to introduce does not arise on that route: this port’s RemoveChild already detaches through DetachNode, so the wrapper a handler receives names a live object. The sibling is captured before the removal, as .NET does, because detaching re-parents the child and its NextSibling() then walks the holder’s list. ASan discharges the acceptance criterion and is shown to discriminate: clean across all 29 cases on the repair, and heap-use-after-free at the handler’s own dereference on the destroy-then-raise shape. Four mutations, all caught; a fifth was a no-op rather than a mutation — guarding the loop on child == FirstChild() is always true, since removing the head promotes the next — and was reformulated rather than counted. The cost is stated: detached children live until the document does, which is not a new policy but is newly reached by the InnerText/InnerXml setters. Downstream: zero code sites. It was 17,382 immediately before, measured on the same date by ticket #2032. +2 on the 17,380 below, in SharpRuntimeTests_Diagnostics (227 → 229; three pins inverted, two cases added). #2032 makes WaitForExit(milliseconds) honour its own bound — it returned true after 29,951 ms against a declared 5,000, because reaping the child joined the pipe readers and a reader cannot return until every holder of the write end closes it, an inherited grandchild included. Its blocker was #2029, and the choice #2029 gated was a false trichotomy: the ticket said the repair must join, detach or abandon the reader, and the reference says reaping the CHILD and waiting for its OUTPUT are two different things — WaitForExitCore waits for _output.EOF only when milliseconds == Timeout.Infinite, commented “if we have a hard timeout, we cannot wait for the streams”. So the join moves rather than going away, and four doors are repaired by deleting one statement because all of them reached it through reapIfNeeded. The restart Start() keeps its join and stays pinned, because assigning to a joinable std::thread calls std::terminate — required by the language, not chosen as policy. Four mutations, all caught, and two of them only after work that is recorded: joining after an expired deadline sat on a statement no case reached, since WaitForExit(ms) returns early when the child has already exited; and the case written for the unbounded overload does not discriminate its mutation — seven pre-existing tests do, which is the evidence that join is load-bearing. Downstream: zero code sites. It was 17,380 immediately before, measured on the same date by ticket #2046. +1 on the 17,379 below, in SharpRuntimeTests_Net (332 → 333; five tests rewritten, one deleted as superseded, four added). #2046 makes Dns apply the requested AddressFamily to a resolved name, which it never did — GetHostAddresses("localhost", Unix) returned 127.0.0.1 while the same request against the literal was refused. The ticket asks “which way should the two agree?” and the reference says they do not: Dns.cs:213 returns an empty array for a mismatched literal, the name path returns EAI_FAMILYAddressFamilyNotSupported, and GetHostEntry does not short-circuit a literal at all. Three answers, all cited, so no decision was needed. It reverses #2039 deliberately, and #2039 recorded the reason it might be wrong: it chose HostNotFound over an empty vector because an empty vector is “indistinguishable from ‘checked and found nothing’”, and noted the plan had predicted empty — that prediction was right and its “correction” was the error, made with the reference absent, which is the condition SA-5 exists to end. GetHostEntry keeps throwing because it returns one entry, not a list, so the empty answer is not available to it. One residual difference is pinned rather than glossed: a mismatched IP family on a literal keeps HostNotFound, because .NET would reverse- then forward-resolve and could find an address this port never looks for. EAI_FAMILY was measured directly here — AF_UNIX, AF_PACKET and 99 all return −6. Five mutations, all caught. Downstream: zero code sites. It was 17,379 immediately before, measured on the same date by ticket #2045. ±0 on the #2385 reading, and deliberately so: #2045 rewrote its pin in place, one case replacing one. #2045 makes IPEndPoint::TryParse reject a trailing : with no port — "1.2.3.4:" used to parse as 1.2.3.4:0, a port the caller never wrote. Its recorded gate was “the .NET reference tree is absent here so the intended behaviour cannot be established”, and the reference is present: IPEndPoint.cs:99-152 locates the port field structurally and runs uint.TryParse — not int.TryParse, the ticket’s one wrong detail — over the remainder under NumberStyles.None, which rejects an empty span. The guard is a flag rather than !portPart.empty() and that is the whole design: “no colon at all” and “a colon with nothing after it” both leave the port text empty and are not the same input, so a mutation reverting it is caught. Five mutations, four caught; the uncaught one is proven equivalentstd::stoul("") throws and the existing catch (...) already rejects — and the line is kept because .NET’s uint.TryParse returns false rather than throwing. One mutation was invalid as first written (-Werror on an unused variable) and was reformulated rather than counted. Downstream: zero code sites. It was 17,379 immediately before, measured on the same date by ticket #2385. +3 on the 17,376 below, in SharpRuntimeTests_Core_Base (6,064 → 6,067; one pin’s two rows inverted, three cases added). #2385 makes the four (string, index) overloads read a code point, combining a surrogate pair as CharUnicodeInfo.GetCodePoint does. It was exposed by #2315 and #2336 rather than introduced by them: while every non-ASCII code point answered OtherNotAssigned and every one answered −1, a supplementary character and a lone surrogate were indistinguishable, so the divergence could not be observed — with the tables in, 80 supplementary code points carry a decimal digit value and 1,177 a numeric value. Three conditions, and the easy mistake is the third: a low surrogate at the index is never combined, including the low half of a valid pair, because .NET only ever looks forward. Five mutations, four caught and one equivalence. The fourth was caught only after a row was added, and the reason is recorded: with a low surrogate accepted at the index the only successor that would combine is another low one, a shape no existing case had. The equivalence is proven rather than assumedCheckIndex gives i + 1 <= size() and operator[](size()) is specified to return a null character, so the dropped bound is defined behaviour and the check is kept for being .NET’s rather than for being load-bearing. Downstream: zero sites. It was 17,376 immediately before, measured on the same date by ticket #2386. +1 on the 17,375 below, in SharpRuntimeTests_Core_Base (6,063 → 6,064; one #2337 pin inverted, one case added). #2386 makes an undefined NormalizationForm raise ArgumentException with .NET’s verbatim text and nameof(normalizationForm), because CheckNormalizationForm runs before the invariant shortcut and so holds in every mode. The four values are enumerated rather than range-checked, and that is load-bearing: 3 and 4 are holes (FormC=1, FormD=2, FormKC=5, FormKD=6), so a bounds check accepts two undefined values. The larger half is a premise correction that closes a question rather than opening one: #2337 pinned three behaviours as “statements about the CURRENT stub” that “must be inverted by the repair”, and Normalization.cs:11-40 returns true and the argument unchanged in invariant globalization mode, saying so in a comment — so this port was never diverging, it was matching .NET under its own conditions, and the header now declares that with the citation. SA-4 does not reach #2338 and the measurement says why: CharUnicodeInfoData.cs contains zero normalization data, because .NET has no such tables and dispatches to ICU or NLS — so #2338 is needs_user with three recorded options rather than blocked on an approval that was already granted. Five mutations, all caught; the identity mutation is caught by three pre-existing pins. It was 17,375 immediately before, measured on the same date by ticket #2018. ±0 on the #2336 reading, and deliberately so: #2018 rewrote the two gated Rune pins rather than adding to them, two replacing two. #2018 is SA-4’s third unlock — Rune’s six classification and casing members answer from the UCD 16.0 tables, ending the self-contradiction SR-AUD-294 rests on (IsWhiteSpace was Unicode-aware while its siblings were ASCII-only). The case tables share the category trie — .NET indexes all three with one offset function — and are 16-bit signed deltas, so the plane is preserved by masking rather than added to, a delta being unable to cross one. The round trip is not a bijection, and the exact set is asserted rather than a count — five uppercase BMP scalars do not return to themselves, and the count would have hidden that four of the five are Unicode’s own duplicate letters (U+03F4, U+2126, U+212A, U+212B), which lowercase into the canonical letter whose uppercase is the canonical capital; the fifth is U+1E9E SHARP S. A repair that “fixed” it would be disagreeing with the UCD. Five mutations, four caught; the uncaught one is a proven equivalence — the ASCII fast path agrees with the table on all 128 ASCII code points for all six members, and is kept because .NET has two statements there and collapsing them would simplify the reference rather than port it. Downstream: zero sites. It was 17,375 immediately before, measured on the same date by ticket #2336. +4 on the 17,371 below, in SharpRuntimeTests_Core_Base (6,059 → 6,063). #2336 is SA-4’s second unlock: GetDecimalDigitValue, GetDigitValue and GetNumericValue read a generated UCD 16.0 numeric table, so the sixteen-code-point reduction is gone and the BMP figures go 10/13/16 → 370/465/742. Decimal and Digit are two properties, not one built on the other — .NET reads a different nibble of the same byte, and the old “decimal value, else three hard-coded superscripts” agreed only over the thirteen it covered. What did NOT change is the load-bearing half: a CJK ideograph still answers −1, because Python answers 5.0 from Unihan, which UnicodeData.txt field 8 does not carry and .NET therefore does not either — measured, all 83 such code points are CJK, and there is no code point where the table and Python both claim a value and disagree. The widening creates two sentinel traps and both are asserted: U+0F33 really is −0.5 (the BMP’s only negative), so value < 0 is now wrong, and U+2189 is exactly 0.0, so value != 0 is wrong the other way. Five mutations, all caught — one by five pre-existing Char tests, which is what shows the table is load-bearing for the shipped API. A gap was exposed and deliberately not bundled: the four (string, index) overloads index a code unit where .NET reads a code point, invisible until now because every non-ASCII answer used to be −1 — filed as #2385. Downstream: zero sites. It was 17,371 immediately before, measured on the same date by ticket #2315. +5 on the 17,366 below, in SharpRuntimeTests_Core_Base (6,054 → 6,059; six #2316 pins inverted, five cases added). #2315 gives CharUnicodeInfo::GetUnicodeCategory a generated UCD 16.0 table, so the declared reduction is gone and 292,420 of the 1,114,112 code points changed answer. The gate was already lifted and the ticket did not know it: its recorded blocker was “Approval F”, and SA-4 is Approval F, granted two days earlier — so #2336, #2018 and #2338 were unblocked with it, in SA-4’s stated order. The ASCII ladder is deleted, not kept as a fast path: two sources of truth for 128 code points, and it was wrong on 17 of them — exactly the punctuation/symbol split #2316 had documented as part of the reduction. The census is decomposed so it is external evidence rather than the table agreeing with itself: subtracting private use, surrogates and controls leaves 154,998, which is Unicode 16.0’s own published character count. SA-4’s cross-check is discharged and the answer is clean — against Python 15.1.0 and Perl 15.0.0, exactly one genuine reclassification, the same one in both (U+1171E, MnMc); every other difference is a later assignment, and the 627-code-point gap between the two counts is Unicode 15.1’s own additions, so the corpora corroborate each other. A first Perl parse read inversion lists as ranges and reported 0 disagreements — what a silently wrong oracle looks like. Five mutations on the trie arithmetic, all caught. One inverted pin was kept: U+D7FF really is unassigned, so #2316 was right for a reason it could not have known, and the first cut’s “update” was the error. Generator and table both committed with a --check mode. Downstream: zero sites. It was 17,366 immediately before, measured on the same date by ticket #2382. +7 on the 17,359 below, in SharpRuntimeTests_Core_Base (6,047 → 6,054). #2382 brings BFloat16 to the line this port already decided for System::Half — sixteen members — and gives it Half’s @note Status block, since the omissions being undeclared is what let SR-AUD-176 be raised as a defect. “Half’s line” is not “Half’s bodies”, and for one member that distinction is load-bearing: .NET’s BFloat16 delegates its identity trio to float, and Half::GetHashCode masks to 16 bits, so copying it would have compiled, satisfied the hash contract and returned the wrong number. A defect the ticket did not name was found by a mutation: ToString() was a bare std::to_chars, a second formatter beside Single::ToString, and exhaustively over all 65,536 patterns 256 disagreed — the two infinities and 254 NaNs, C’s "inf"/"nan" against .NET’s "Infinity"/"NaN" — so the type was inconsistent with itself, ToString("F2") having been right all along. It surfaced because an uncaught mutation was investigated instead of excused: dropping the empty-format branch is only uncatchable if the two formatters agree. Eleven mutations, nine caught, two equivalences deleted rather than defended. The declined surface is pinned static_assert-absent with a dependent parameter, the #2299 gcc trap having been hit again. Downstream: zero sites. It was 17,359 immediately before, measured on the same date by ticket #2363. +5 on the 17,354 below, in SharpRuntimeTests_Net_Sockets (127 → 132; three #2138 pins inverted, eight cases in their place). #2363 gives TcpClient, TcpListener and UdpClient IPv6, so #2138’s refusal is removed rather than left unreachable. The ticket’s premise is corrected three times. .NET does not merely resolve with AF_UNSPECSocket.Connect(string, int) calls IPAddress.TryParse first (Socket.cs:919-923), which settles the question #2138 deferred and settles it away from #2359: a URI authority has bracket syntax and a socket hostname parameter does not, and .NET answers the socket question in the socket code. And not every AF_INET constant should have goneUdpClient() and UdpClient(port) are InterNetwork in .NET (UDPClient.cs:24,47), unlike TcpClient(), so a mutation that “finishes the job” by making them dual-stack is caught. #2138’s own measurement had one gap and this ticket found it: it concluded nothing was ever silently narrowed, which held for every path it probed and not for UdpClient::Receive, which read an IPv6 sender through a sockaddr_in and handed back a fabricated IPv4 address. The layout claim was wrong in the first cut and the pin caught it: an AddressFamily member grows TcpClient 24 → 32, so the family is stored as the bool this port’s own IPAddress uses — every sizeof unchanged, no consumer rebuild. Nine mutations, eight caught; the uncaught one (dropping the IPv6 scope id) is unobservable without a link-local interface, which SA-6 makes a test defect rather than evidence. Three cases were restructured mid-pass because a thread parked in AcceptTcpClient() turns a failed connect into a hang, and a mutation caught only as a timeout is not caught by name. Downstream: zero code sites in either consumer. It was 17,354 immediately before, measured on the same date by ticket #1939. +6 on the 17,348 below, in SharpRuntimeTests_Core_Base (6,041 → 6,047). #1939 lands #1929 row 4A: DateOnly and TimeOnly gain invariant single-format ParseExact/TryParseExact, purely additively — no existing result, message or failure moved. The digit-width rule is .NET’s, not the specifier count: ParseDigits(str, 1) reads one or two digits while ParseDigits(str, n>1) reads exactly n, so yyyy-M-d accepts both paddings and yyyy-MM-dd accepts only one — a scanner that counts specifiers is wrong in both directions, and that mutation is caught. The approval’s own worked example is stale and is corrected rather than copied: it asserts the unpadded text still fails general Parse, which #1929 made false the day before, so the test asserts the separation with text the general parser really does reject. : and / are matched as literals, because rejecting the characters would fail the approval’s own example on the wrong token — what is out of scope is a provider’s power to change them, which is 4B. One mutation is not caught and is an equivalence: no invariant month or day name is a prefix of another, so longest-first name matching is defensive rather than load-bearing, and the comment at the site says so. It was 17,348 immediately before, measured on the same date by ticket #1941. +5 on the 17,343 below, in SharpRuntimeTests_Core_Base (6,036 → 6,041). #1941 lands phase 1 only of #1929 row 4D: DateTime stores and reports a DateTimeKind, and nothing converts by it — ToLocalTime, ToUniversalTime and offset/Z parse conversion stay absent and are pinned absent, so phase 2 is a deliberate act rather than a drift. The layout does not move: MaxTicks needs 62 bits, so the kind packs into the two that are free, exactly as .NET does — sizeof(DateTime) stays 16 and DateTimeOffset 48, so no consumer rebuilds. The member is renamed, and that is the safety property: ticks_ became dateData_ behind a masking ticks(), because a bare ticks_ would silently have changed meaning and all thirty reads inside the type would have had to be remembered — renaming makes the compiler visit each one. Nothing that existed changed its answer: .NET's operator == shifts the flag bits away (DateTime.cs:1862) and GetHashCode uses Ticks, so comparison, equality, hashing, arithmetic and formatting are unmoved, each asserted. The reserved fourth encoding is transcribed and its mutation is honestly uncaught: LocalAmbiguousDst is unreachable in phase 1 because the constructor refuses kind 3, and the fold is kept anyway so phase 2 need not change a shipped accessor. Downstream is an opportunity, not a break: cna's XNB DateTimeReader records a documented deviation — it masks the kind out and discards it, explicitly because this gap existed — and that gap is now closed (#2381). Five mutations, four caught, one uncaught for a stated reason; the range-check mutation was caught only after a row was added for it. It was 17,343 immediately before, measured on the same date by ticket #2348. +4 on the 17,339 below: SharpRuntimeTests_Xml 509 → 512 and SharpRuntimeTests_Xml_Linq 334 → 335. #2348 rejects a DOCTYPE internal subset that closes its own declaration — a subset of ]><evil/><!-- emitted <!DOCTYPE r []><evil/><!--]>, which is malformed and injects an element. The ticket's premise was corrected twice, in opposite directions: #2084 had already corrected the finding's claim that ] is the terminator (it is >), and the reference now corrects the ticket's own implication that .NET does better — XmlWellFormedWriter.WriteDocType validates the subset with XmlCharType.IsOnlyCharData alone, a character check this port also performs since #2349, and then writes it with RawText, so .NET emits the same malformed document. This is therefore a deliberate narrowing past the reference, and it is the rule #2084 already applied in this same function to the ExternalID literals, where .NET likewise checks only characters. The rule is not "reject any >" — #2084 considered and rejected that, because <!ENTITY a "b"> is legitimate and only this runtime's >-terminated DOCTYPE node mis-reads it, a reader limitation a writer must not be narrowed to fit. It is a ] followed by >, and it is quote-aware, so <!ENTITY a "]>"> still passes. Four mutations caught, the most useful being "make it any >", which is caught by pre-existing tests — the evidence that the narrowing stopped where #2084 said it must. It was 17,339 immediately before, measured on the same date by ticket #2355. +3 on the 17,336 below, in SharpRuntimeTests_Text (314 → 317). #2355 widens the encoder fallback from a char to a char32_t, so a custom fallback can see which scalar was unencodable — the narrowing was static_cast<char>(scalar & 0x7F), so U+1F600 arrived as the byte 0x00 and U+00E9 as the letter i. The repair is not "add .NET's second overload": .NET's parameter is a char with a second overload taking a surrogate pair, and it reassembles that pair into one integer before formatting its message (EncoderExceptionFallback.cs:53-59) — which is itself the evidence that the scalar is the value a caller wants. This port has no pair to reassemble, so reproducing the overload would reproduce a limitation it does not have, the same argument #2299 made about Func<void>. A behaviour repair came with it: ASCII()->GetBytes("\U0001F600") produced two ? and now produces one, because .NET's replacement fallback runs once for a pair (EncoderReplacementFallback.cs:117-138) — the doubling existed only to mimic the pair through a narrow parameter, and two pins asserted the old answer with the wrong inference, "matching the two UTF-16 code units .NET would encode it from": the premise is right and the conclusion does not follow. A public source break under SA-2, all five conditions discharged — the negative fixture's third site is the override-less spelling that stays silently abstract and is reported only at instantiation (37 fixtures / 200 sites), downstream ticket #2380, and zero sites in both consumers. Three mutations, all caught. It was 17,336 immediately before, measured on the same date by ticket #2378. +2 on the 17,334 below, in SharpRuntimeTests_Core_Base (6,034 → 6,036). #2378 makes an undefined SpecialFolder throw on Windows, where Environment.Windows.cs:768-770 ends its switch in a throw above a Debug.Assert(!Enum.IsDefined(folder)), while POSIX keeps returning "" because GetFolderPathCore.Unix.cs:22 says outright "No need to validate if 'folder' is defined". The table is transcribed rather than reflected: 47 enumerators over 46 distinct values (Personal and MyDocuments share 0x05), 14 holes inside 0x000x3B, and those counts are asserted rather than trusted. Its placement is the point: it lives in a detail header rather than inside the #ifdef _WIN32 arm, because the behaviour is Windows-only but the data is not, and behind the #ifdef it would have been unreachable from the only platform this gate runs on. The Windows arm is verified rather than asserted: a MinGW cross-compiler is present, so Environment.cpp compiles for Windows, nm shows the guard and its table in that object and neither in the POSIX one — evidence that the rejection is confined by construction — and removing the guard drops the symbol, so the check discriminates. What is still not verified is runtime behaviour on a real Windows host; the CI is Ubuntu-only and that limit is stated. Four mutations caught, one reformulated after -Werror rejected the first spelling. It was 17,334 immediately before, measured on 2026-08-18 by ticket #2376. ±0 on the #2359 reading, and deliberately so: #2376 inverted its pin in place, one case replacing one. #2376 makes the three strict HTTP-date arms match the format strings they transcribe — sscanf's %d and %[A-Za-z] bound no field, so Sun, 06 Nov 199 08:49:37 GMT was accepted and read as the year 199 AD, a wrong instant off by more than eighteen centuries rather than a rejected format. .NET's yyyy/yy are ParseDigits at an exact width and its ddd/dddd are name tables, not widths. The ticket named two sites and there are three: asctime carries the same unbounded year and the same unvalidated weekday, and repairing two thirds of one rule is not repairing it. And the lenient arm needed the same rule — the first cut left Xyz, 06 Nov 1994 … parsing, because #2360's arm checked the weekday's length rather than its name, so a value the strict arm had just refused was accepted one arm later; that is the hazard of two parsers for one grammar. Six mutations caught, two of them reformulated after the first spelling was invalid — removing an entry from a fixed-size std::array leaves a null the comparison dereferences. It was 17,334 immediately before, measured on the same date by ticket #2359. +1 on the 17,333 below, in SharpRuntimeTests_Uri (277 → 278; two pins inverted, three cases replacing two). #2359 makes a Uri host obey .NET's DNS character set — Uri("http://exa mple.com/") was accepted, reporting the host "exa mple.com". #2005 stopped here deliberately, because this half "needs a trace through .NET's host parser, not a line to quote". The trace is four steps and it corrects the framing: which of the six named BadHostName sites a space reaches does not matter, because DomainNameHelper.IsValid tests IndexOfAnyExcept over exactly -0-9A-Z_a-z., the IRI path lists the space explicitly, and — the step that makes it uniform — no built-in scheme sets AllowAnyOtherHost, measured across all fourteen UriSyntaxFlags constants. Non-ASCII stays accepted because .NET's IRI path takes it, except U+0080–U+009F; decoding the scalar rather than waving bytes through is what makes that reachable, and it costs nothing because #2354 put the decoder in Core.Base earlier the same day (graph unchanged at 41/93). The path, query and fragment are untouched. One tightening arrives with it: a WebSocket test asserted that CR/LF in a host is refused at ConnectAsync, and that URI can no longer be built, so the assertion moved to the constructor — leaving it where it was would have passed for the wrong reason, since the constructor now throws outside the EXPECT_THROW. Four mutations, all caught. It was 17,333 immediately before, measured on the same date by ticket #2349. ±0 on the #2309 reading, and deliberately so: #2349 inverted its two scope pins in place, two cases replacing two. #2349 makes both writer door families reject a code point outside XML 1.0's Char production — 28 of the 29 non-Char bytes in 0x000x1F used to be emitted raw, so the document was not well-formed XML. The ticket recorded five priced options and called it a user decision; the reference collapses them to one: XmlWriterSettings.CheckCharacters defaults to true (XmlWriterSettings.cs:513) and is enforced — InvalidXmlChar throws when it is set and entitizes when it is not — so .NET rejects by default and the flag is what turns that off. Both of the ticket's pricing complications are dissolved rather than accepted. It priced enforcement on XmlConvert::VerifyXmlChars, which iterates char and so checks bytes, accepting U+FFFE, U+FFFF and a lone surrogate — but #2354 put a code-point decoder in Core.Base earlier the same day, and both modules already depend on it, so the correct check costs one call and no new edge (graph unchanged at 41/93). And it priced option B as "not a same-shaped change on both sides" because the Xml.Linq doors take no settings — .NET's do not either: XNode.GetXmlWriterSettings builds a default XmlWriterSettings (XNode.cs:681-687) and inherits CheckCharacters = true, so the two door families agree by construction rather than by coordination. Two exception types, deliberately: NUL keeps XmlException because it is this port's own truncation guard, every other non-Char code point raises .NET's ArgumentException. What is still open is stated rather than left implicit: this port's reader accepts these characters, so writing and reading now disagree — .NET has no such asymmetry because its reader enforces too, and closing it here means driving tinyxml2 character by character. Four mutations, all caught. It was 17,333 immediately before, measured on the same date by ticket #2309 and confirmed by two consecutive full runs. +4 on the 17,329 below, in SharpRuntimeTests_Core_Base (6,030 → 6,034; three pins inverted, four cases added). #2309 closes SR-AUD-098's C2/C3/C4: AggregateException("outer", {a, b}) reported "outer" and discarded the leaves, and .NET composes unconditionally whenever there are inner exceptions (AggregateException.cs:339-360). The ticket's two blocking grounds answer each other: composition and preservation cannot both hold under one stored string — measured, closing both yields "custom outer (a) (b) (a) (b)" and grows without bound under repeated Flatten() — and the escape is a second std::string, which is exactly SA-3's case. sizeof(AggregateException) 192 → 224 and sizeof(UnobservedTaskExceptionEventArgs) 208 → 240, pinned; consumers rebuild. Flatten and Handle differ and that is .NET's doing: Flatten passes the raw message for a plain aggregate and the composed one for a derived type (:335), which is precisely what stops the accretion, while Handle passes the composed one (:281) — so a rethrown aggregate legitimately lists its leaves twice. The composition runs in the constructor rather than the getter, which is observationally identical because innerExceptions_ is fixed at construction, and avoids a public signature change. Five mutations, all caught. The gate run also caught a pre-existing flake from #2326: a resolution test that took 200 back-to-back timestamp pairs measured the machine, not the code — eight passes in isolation, one failure under gate load — and now compares GetTimestamp() against steady_clock read directly, a comparison of values immune to scheduling that still catches the mutation restoring the division. It was 17,329 immediately before, measured on the same date by ticket #2186. +1 on the 17,328 below, in SharpRuntimeTests_TimeZone (187 → 188; two gated pins inverted, three cases replacing two). #2186 asked five parity questions, each "requiring the .NET reference or a managed runtime that this container does not have", and /rv answers all five: three repairs, two already correct. The conversions clamp rather than throw (TimeZoneInfo.Cache.cs:340-342), and ConvertTime clamps once, at the end, because .NET computes from raw ticks "to avoid precision loss from double-clamping" — a mutation that clamps twice is caught. The reference corrects the ticket's own statement of two of the three validations: the daylightDelta range is not ±14 hours but -23.0 .. 14.0, and .NET explains why in a comment — Samoa moved across the International Date Line — while the message still says ±14, an inconsistency that is .NET's and is transcribed rather than tidied; and the seconds check is not sub-minute but not a whole number of minutes. Question 5 was already right, and the reference contains a trap: reading only CalculateUtcOffset's window test puts an ambiguous 01:30 inside daylight, and the line after it overrides that from a flag only a prior UTC→local conversion sets — so a freshly built DateTime gets the standard reading, which is what #2182 chose. Non-zone-data now raises InvalidTimeZoneException where an absent id still raises TimeZoneNotFoundException: different answers to different questions, and a caller catching only the first used to swallow the second. Five mutations, all caught. It was 17,328 immediately before, measured on the same date by ticket #2174. +5 on the 17,323 below, in SharpRuntimeTests_Numerics (336 → 341). #2174 asked four parity questions that were blocked on evidence, not approval, and /rv answers all four: two were already matches, one is a match whose explanation was wrong, and one was a real divergence in a place the ticket's own probe could not see. Complex::ToString was right all along — the audit's two citations disagreed and the source wins, since the (26.1, 18.06) doc example is stale prose about a constructor. BigInteger::Parse threw on " 1" and "1 ", where NumberStyles::Integer — the default style — is AllowLeadingWhite | AllowTrailingWhite | AllowLeadingSign, so the port was stricter than the style it claims to implement. The vector half is the substantive one: the ticket measured NaN propagating from Min/Max with the NaN in the first operand; with it in the second the port discarded it, because std::max(a, b) is a < b ? b : a and is asymmetric under NaN. .NET's expression propagates from either side — and nothing in it mentions IsNaN(y), which is exactly why that row is easy to miss. A signed-zero rule came along with it, because .NET's third disjunct is not symmetric (Max tests IsNegative(y), Min tests IsNegative(x)). Two of my own expectations were wrong and the port was right: BigInteger(-1) << INTCS_MIN is −1, not 0, since >> is arithmetic, and << INTCS_MAX's unbounded work is what .NET does too. One mutation is an equivalence in the "C" locale and is recorded as one rather than counted — a test could only distinguish it by changing the global locale inside a shared binary. It was 17,323 immediately before, measured on the same date by ticket #2166. ±0 on the #2105 reading, and deliberately so: #2166 inverted three pins in place, three cases replacing three. #2166 makes seven Console doors reject arguments they accepted silently. The cursor bound is not a belief about a platform layer, which is how #2165 recorded it: .NET validates in Console.cs itself, before dispatch (Console.cs:550-559), so it applies on every platform — and the comparison is >=, so 32766 is the last accepted column and 32767 the first rejected one, an off-by-one pinned on both sides. The other six are a different case and the difference is stated rather than glossed: .NET's Unix pal throws PlatformNotSupportedException for every one of them, so on this port's runtime platform .NET states no range at all; the ranges adopted are its Windows pal's, because they are the only ones .NET defines, and copying the Unix answer would remove a feature this port offers rather than repair one. The buffer-relative halves of those checks are deliberately not reproduced — this port has no buffer geometry — so the rejection is a subset of .NET's and never a superset. This also lands the exact message texts #2163 and #2164 could not: their types were probe-verified and their texts were not, and the ticket recorded that the texts rode along on this one. The previous checkpoint's #2105 rename test flaked in this run and was repaired first: it assumed two events share one inotify batch, passed three times in isolation and failed inside the full gate, so it now counts only deliveries that arrive after the stop — a test that is intermittently green is not evidence (#2352). Six mutations caught; two were invalid as first written and one was masked by its sibling check, all reformulated rather than counted. It was 17,323 immediately before, measured on the same date by ticket #2105. +3 on the 17,320 below, in SharpRuntimeTests_IO (693 → 696). #2105 was a deferred verification whose review said the measurement "needs TSan plus a deterministic harness". It needs no TSan, and the batch is what makes the harness deterministic: inotify delivers many events in one read() and the watch loop dispatched the whole batch before it next reached poll(), so creating 24 files and stopping the watcher from the first handler answers the question with no timing race. Thirteen further handlers ran after the setter returned. Only the self-stop path was wrong — an external stop joins the watch thread, a stronger guarantee than .NET's, and that half is now pinned too. The repair is .NET's: gate per event, at the point of dispatch, and drop (FileSystemWatcher.Linux.cs:1071-1093, 1211-1217). The gate has two sites and the second is easy to miss — an unpaired IN_MOVED_FROM is reported as a Deleted from a loop that runs after the batch — found by mutation, since removing it passed every other test in the repository. And its test is easy to write vacuously: the move-out must come first in the batch, or the per-event gate drops it before it is ever recorded and the second gate is unreachable, which is exactly what the first cut measured. One mutation is an equivalence and the code says so at the site rather than claiming a rationale it does not have. It was 17,320 immediately before, measured on the same date by ticket #2070. +1 on the 17,319 below, in SharpRuntimeTests_Net_Http (200 → 201; one gated pin inverted, two cases added). #2070 makes StringContent take an Encoding rather than a charset string, and serialise through it. The old signature let the label and the bytes contradict each other: StringContent("é", "utf-16") announced charset=utf-16 and emitted the two UTF-8 bytes c3 a9, which a conforming server reads as one UTF-16 code unit, U+A9C3 — a different character, silently. .NET does not validate against that; it makes it unrepresentable, and that is why the repair is a signature change rather than a check: one Encoding is both the serialiser and the label (StringContent.cs:48-73,90-98), so there is no second source of truth to disagree with. A check would still let a caller name a charset the body was not encoded in. A public source break under SA-2, with all five conditions discharged — including a negative fixture whose third site is the two-argument StringContent(body, "utf-8") most likely to survive a careless migration (36 fixtures / 197 sites), downstream ticket #2379, and the measurement that closes it: zero StringContent sites in both consumers. One check became unnecessary and is kept anyway — #2063's CR/LF rejection on the charset, which can no longer carry one. The module graph goes 41/92 → 41/93 (Net.HttpText) with the catalogue regenerated; a private copy of one encoder would have been the duplication #2354 just removed six of. Three mutations, all caught. It was 17,319 immediately before, measured on the same date by ticket #2321. +1 on the 17,318 below, in SharpRuntimeTests_Core_Base (6,029 → 6,030). #2321's blocker was the exception identity, and /rv settles it: #2320's transcription had the type, the parameter name and the format string right and used the two-argument ArgumentOutOfRangeException constructor where .NET uses the three-argument one, so the Actual value was N. clause was being dropped. That is the whole repair. The folder half asked for a table the reference says is not needed: C4 wanted a definedness table separating a defined-but-unmapped SpecialFolder from an undefined one, and GetFolderPathCore.Unix.cs:22 answers in a comment of its own — "No need to validate if 'folder' is defined" — because GetSpecialFolder returns null for anything unhandled and null becomes "". So on POSIX the two categories are deliberately indistinguishable, which is what this port already did; the new pin probes both, since one that probed only undefined values would pass against a table that does not exist. The answer is platform-dependent, which the ticket did not anticipate: .NET's Windows core ends its switch in a throw, and matching it needs the table after all — but only on Windows, where this Ubuntu-only gate could never run it, so that is #2378 rather than an untestable table smuggled in beside verified work. Three mutations, all caught. It was 17,318 immediately before, measured on the same date by ticket #2323. +7 on the 17,311 below: SharpRuntimeTests_Core_Base 6,026 → 6,029, SharpRuntimeTests_Net_Http 198 → 200, SharpRuntimeTests_Text_Json 296 → 298; three pins inverted in place. #2323 gives a default-constructed exception .NET's fallback message, "Exception of type '{0}' was thrown." (Exception.cs:61, Strings.resx:2333), where this port had an empty string. Both recorded blockers are resolved, and the second dissolves rather than being worked around: {0} is GetType(), which is reflection this port permanently lacks — but .NET computes the fallback lazily only so _message can stay null for serialization, and the observable is identical if the constructor stores it, which a hundred subclasses here already do. So {0} is resolved statically, at each site, by the type that knows the answer: no reflection, no new virtual, no layout change, no signature change. Inheriting the base string into subclasses was rejected for the review's own reason — a message naming the wrong type is a lie, where an empty one is merely an absence. The review's premise measurement was wrong: it said exactly one type reaches the base fallback; re-measured across all 103 subclasses, three do, because = default on a derived exception default-constructs its base. All three now name themselves, and a guard test catches a future = default subclass rather than letting it be discovered downstream. Downstream is not empty, which is the point of SA-2 condition 5: cna has five types chaining to System::Exception() and six tests asserting an empty what(), all named to the line in #2377 rather than edited. It was 17,311 immediately before, measured on the same date by ticket #2360. +3 on the 17,308 below, in SharpRuntimeTests_Net_Http_Headers (431 → 434; two pins inverted, three cases added). #2360 adopts the sixteen lenient HTTP-date formats #2130 deliberately left, so this port accepts what .NET accepts at every date-carrying header. Twenty-one format strings are not twenty-one grammars — they are three shapes crossed with three axes, and transcribing the cross is what made the gaps visible: two cells of it are missing from .NET's list (a two-digit year with a numeric offset, with and without a day-of-week), so they are rejected explicitly and pinned, because the obvious completion of the pattern is exactly the widening with no reference behind it. The zone token is zzz transcribed — sign, one or two hour digits, an optional colon, two minute digits, rejected at 60 — so -0500 and -05:00 both work. The widening is safe by construction: the lenient arm runs after the three strict ones, so nothing they accept reaches it; a mutation reordering them changes no result, recorded as an equivalence rather than a catch. A correction the first tests needed: Retry-After dispatches on the first character, so a digit-leading date cannot reach its date branch — .NET does the same and says so — which makes If-Range the door that sees the whole grammar. Two pre-existing leniencies surfaced and were split out as #2376 rather than bundled: the strict sscanf arms accept an abbreviated weekday on the RFC 850 shape and a three-digit year on IMF-fixdate, and both repairs are narrowings inside a widening ticket. Eight mutations caught, two only after routing the probe around a strict arm that answered first. It was 17,308 immediately before, measured on the same date by ticket #2361. +2 on the 17,306 below, in SharpRuntimeTests_Xml (507 → 509). #2361 closes the two-door asymmetry #2082 left open: XmlDocument::Load handed the path straight to LoadFile and never held the bytes, so a document loaded from a file accepted an undeclared entity and then silently rewrote it on save — accepting the reference was recoverable, reinterpreting it as literal text and re-escaping it meant loading and saving changed the document. Load now reads the file itself, which is what LoadFile does internally anyway. The failure path deliberately still goes through LoadFile, because that is what produces XML_ERROR_FILE_NOT_FOUND and the ErrorStr() the message has always carried; reproducing those from an ifstream failure would be inventing diagnostics. Two mutations are not mutations and are recorded as such: text-vs-binary mode is a no-op on Linux by construction, and Parse(c_str()) without the length is semantically equivalent — verified by probe across three NUL placements through both doors, all six agreeing, rather than assumed. Three real mutations caught. It was 17,306 immediately before, measured on the same date by ticket #2209. +2 on the 17,304 below, in SharpRuntimeTests_IO_IsolatedStorage (58 → 60). #2209 makes GetFileNames/GetDirectoryNames honour a directory-qualified pattern — GetFileNames("sub/" "*") used to return nothing — and the ticket's blocker is simply gone: /rv states the contract in .NET's own comments above each method, and FileSystemEnumerableFactory.NormalizeInputs:45-56 splits at the last separator, joins the directory half onto the root and matches only the final segment. The result stays a bare name, via Path.GetFileName, because the store exists to hide its own root. One deliberate narrowing, on a security boundary: .NET does not confine the pattern — these two doors are the only ones on the type that bypass GetFullPath, so .NET's GetFileNames("../" "*") escapes the store and lists its parent. This port resolves the directory half through the same fullPath() every other door uses, because reproducing .NET would mean opening a confinement hole to match a reference that has one; SA-8 does not reach it, and a test pins the asymmetry. A second, older divergence is recorded rather than introduced: .NET rejects a rooted pattern outright, while this port strips leading separators at every door, so "/x" has always meant x relative to the store — rejecting it only here would make the type inconsistent with itself. Five mutations, all caught. It was 17,304 immediately before, measured on the same date by ticket #2152. +2 on the 17,302 below, in SharpRuntimeTests_IO_Compression (101 → 103; two pins inverted, four cases added). #2152 makes DeflateStream, GZipStream and ZLibStream enforce their own mode: a Read on a Compress-mode stream used to return 0, indistinguishable from end-of-stream, so a read loop terminated normally having produced nothing. The order is transcribed, not chosenValidateBufferArgumentsEnsureDecompressionModeEnsureNotDisposed (DeflateStream.cs:284-309) — so a stream that is both disposed and in the wrong mode reports the mode, and one existing test needed updating for it. A mutation proved the exception type alone cannot express that ordering: ObjectDisposedException derives from InvalidOperationException, here as in .NET, so the swapped-order mutation went uncaught until the test asserted the message. Flush() deliberately gets no guard, because .NET's has none, and a test pins the absence rather than leaving a plausible symmetry to be added later. A correction on the way past: #2148's doc-comment recorded that /rv was absent to narrow the invalid-CompressionMode exception, and /rv now confirms that choice exactly (DeflateStream.cs:99), so the caveat is replaced by the measurement. It was 17,302 immediately before, measured on the same date by ticket #2362. +1 on the 17,301 below, in SharpRuntimeTests_Core_Base (6,025 → 6,026; three pins inverted, one case added, two stale comments corrected). #2362 ends the unsigned integer parsers' one deliberate deviation, which had stood for about a year: a negative-indicating token is now grammar and the rejection is range, so UInt32::Parse("-1") is an OverflowException (Number.Parsing.cs:157) rather than a FormatException. The old argument was wrong on its own terms, and that is the whole ticket: it held that the only practical effect was which exception type a clearly-invalid input throws, but "-0" is not invalidNumber.Parsing.Common.cs:259-268 clears the sign when no nonzero digit and no decimal separator were seen, so .NET returns 0 and this port was rejecting an input .NET accepts. The two rows could not be separated: repairing "-0" alone would have left "-1" diverging, which is worse than the old consistent rule, so grammar, normalisation and rejection landed together. The asymmetry is .NET's — "-0" is 0 and "-0.0" overflows — and it makes #2356's mutation M2 observable for the first time, since that ticket had recorded honestly that the same guard was unobservable in the signed core. Six mutations caught; a seventh is a no-op and is recorded as one — reinstating the old grammar rejection after the leading-token loop changes nothing, because the loop has already consumed the token, the same shape #2138 recorded. TryParse callers see no change at all. It was 17,301 immediately before, measured on the same date by ticket #2354. +5 on the 17,296 below: SharpRuntimeTests_Text 311 → 314 and SharpRuntimeTests_Globalization 681 → 683. #2354 leaves one definition of this runtime's UTF-8 scalar decode. The ticket named three copies and there were sixUtf8JsonWriter.cpp (byte-for-byte identical), IdnMapping.cpp, and the UTF8Encoding.cpp variant #2014 recorded as unmovable because it reads a (pointer, end) range rather than a std::string; that range is now a parameter of the shared decode rather than a reason to hold a sixth copy. Zero remain, measured. The three doors really do want three different things, and they differ on exactly one input class — a structurally valid encoding of a non-scalar — so the shared header has two entry points, TryDecodeUtf8Scalar (reports, consuming the sequence's own length) and DecodeUtf8Scalar (substitutes U+FFFD over one byte, as .NET's replacement fallback does); collapsing them would have been a silent behaviour change in whichever door lost. Rune::TryGetRuneAt had no test anywhere in the repository, and that collapsing mutation passed all 17,296 tests then present — two of five mutations were caught only after a new assertion, and both are recorded rather than quietly fixed. The five new cases live in two files because modules/text does not depend on Globalization, and a refactor is not a reason to add a public edge: the graph is unchanged at 41/92. −179 lines, no behaviour change at any door. It was 17,296 immediately before, measured on the same date by tickets #1929 and #2375, and confirmed by two consecutive full runs. +6 on the 17,290 below: SharpRuntimeTests_Core_Base 6,019 → 6,025 (#1929 added six cases and inverted ten pins in place); SharpRuntimeTests_Net is unchanged at 332, because #2375 rewrote two tests rather than adding any. #1929 widens the date/time parse grammar to .NET's in the two respects the user decided on 2026-08-18: a one- or two-digit month and day, and .NET's full ParseTimeZone offset grammar, so "2024-6-15", "+8", "+2:5", "+800" and "+0800" all parse. It is a pure widening — no string that parsed before parses differently or fails now — so it needed no source break, no negative fixture and no migration of callers. Both halves are transcribed: .NET's lexer types a run of one or two digits as a NumberToken and three or more as a YearNumberToken (DateTimeParse.cs:5593-5605), and ParseTimeZone splits a three- or four-digit run as value / 100 and value % 100 (DateTimeParse.cs:552-556) — which is why "+800" and "+0800" agree. Two stops are deliberate and both are pinned: the year is not widened, because .NET reads a short one through Calendar.ToFourDigitYear's culture-dependent century window; and the ±14 h bound stays on DateTimeOffset rather than moving into the shared grammar, because that is where .NET applies it — DateTime parses an offset and discards it, so DateTime::TryParse("…+99") succeeds where DateTimeOffset::TryParse("…+99") fails. The widening forced a structural repair the ticket did not name: DateTimeOffset::TryParse located its offset by scanning for a sign from character 10, which is correct only while the date part is exactly ten characters wide — "2024-6-5" is eight — so the three doors now share one grammar instead of three copies that had already drifted. Nine mutations, all caught. #2375 is a test defect the gate caught and it is the reason this reading is trustworthy: two modules/net tests asserted the resolver's opinion rather than the port's. "1.2.3." takes a 13 ms DNS round trip on this container and comes back as 1.2.0.3 (where "1.2.3" is answered by libc in 0.03 ms), and a reverse lookup of 192.0.2.1 raised EAI_AGAIN in one gate run while passing five isolated runs. Neither was a regression — both reproduce with the in-flight change stashed — and neither was disabled, weakened or skipped: the first now asks getaddrinfo itself and requires the port to agree with that independent oracle (the pattern #2351 established for tzdata), and the second asserts what the finding was actually about, termination, since an unbounded mutual recursion can neither return nor throw. The file header's claim that these tests need no network was measured false and is corrected in place. It was 17,290 immediately before, measured on the same date by ticket #2115. +2 on the 17,288 recorded for #2269, in SharpRuntimeTests_Text_Json (three gated pins inverted, three cases added). #2115 makes both inert JsonDocumentOptions flags real: trailing commas by a scanner that respects string literals and comments and removes a comma only before a closer, duplicates through nlohmann's parser callback — the one place a key is visible before its silent overwrite. The cheaper option was declined on purpose, and a test that looked sufficient was not: the obvious scope test passes even when the key scope never closes, so one mutation went uncaught until a root key reappearing after a nested object was added. Earlier the same date, measured on 2026-08-18 by ticket #2269. +4 on the 17,284 recorded for #2250, #2255 and #2289, in SharpRuntimeTests_Core_Base. #2269 makes all eight integer wrappers validate their NumberStyles, where they validated nothingParse("2A", NumberStyles::HexFloat) used to return hexadecimal 42, a style .NET rejects outright. The noexcept problem was not in the ticket and is the real cost: four of the eight TryParse(style) overloads were noexcept, TryParse must throw for an invalid style, and validating only the four that could already throw would have left the port inconsistent with itself. No existing test needed changing, so nothing relied on an invalid style. Earlier the same date: ±0 on the #2250 reading: #2255 and #2289 rewrote existing pins rather than adding. #2255 types the AppContext data store with std::any, making both of SR-AUD-102's .NET behaviours reachable — the APP_CONTEXT_BASE_DIRECTORY override and TryGetSwitch's string fallback — where a void* carried no type and no ownership. Two reference details decide the implementation: BaseDirectory uses as string, so a non-string entry falls through silently, and the switch parse is bool.TryParse, which rejects "1" and "yes". A process note worth keeping: an intermediate gate run reported a green 17,284 while the build was actually broken, because stale objects were reused; the reading here was taken after a forced full rebuild. #2289 adds the repository's first [[deprecated]], at all five prose-only sites. +1 on the 17,283 below, in SharpRuntimeTests_Core_Base (two gated pins inverted, one case added). #2250 makes AppDomain::IsCompatibilitySwitchSet consult the registry, where it returned false unconditionally — a switch explicitly set to true still reported as unset. Neither of its two changes could land without the other: a bool cannot distinguish explicitly-false from unset, which is why .NET's is bool?, and keeping noexcept while forwarding to a throwing, mutex-taking call would have meant std::terminate. It was 17,283 immediately before, measured on the same date by ticket #2299. +2 on the 17,281 below, in SharpRuntimeTests_Core_Base. #2299 makes Func<void> and Converter<T, void> ill-formedFunc<void> used to compile and was the same type as Action, not merely convertible to it, because an alias template introduces no new type. The finding's second prescription is structurally impossible and is now a declaration: with aliases there is one type, so constraining removes the spelling but cannot create a category. One measured detail is kept: testing for a constrained alias's absence must use a dependent parameter, since gcc evaluates one eagerly in a non-dependent requires and errors instead of yielding false. It was 17,281 immediately before, measured on the same date by ticket #2172. +1 on the 17,280 below, in SharpRuntimeTests_Numerics. #2172 makes Complex::Abs return double as .NET's does, and removes the invented AbsD that existed only to work around the wrong return type. The break is loud and that is measured — no implicit conversion exists either way, which matters because both spellings computed the same magnitude, so only a static_assert on the return type could catch it. A checker change had to land first: this is the repository's first Numerics negative fixture, and -Wpedantic rejecting __int128 made its baseline broken, leaving SA-2's condition 2 unsatisfiable for the whole area; check_negative_consumer_fixtures.py now takes a named relaxation from a closed set, and -isystem was tried first and rejected because it silently disarmed two live sites elsewhere. It was 17,280 immediately before, measured on the same date by tickets #2297 and #2298. ±0 on the #2297 reading: #2298 moved its suite from modules/core/tests to modules/threading/tests and rewrote it, six cases replacing six. #2298 gives Thread .NET's six data-slot doors and makes LocalDataStoreSlot's constructor private — the type held one std::any shared by every thread, so a write from any thread replaced what every other thread read. The three named doors have three different contracts and are transcribed rather than invented. The slot holds an opaque id rather than the storage, because it lives in Core.Base and Thread in modules/threading, which depends on it — the graph is unchanged at 41/92. ThreadSanitizer was attempted and could not run, on a pre-existing incompatibility, so isolation is evidenced by a live two-thread test instead — stated rather than implied. It was 17,279 immediately before, measured on the same date by tickets #2291 and #2295. +1 on the 17,279 below. #2297 makes MarshalByRefObject's constructor protected and adds GetLifetimeService(), whose absence turned an observable runtime diagnostic into a compile error at an unrelated place. The third part is split out as #2374 because it is a vtable change: .NET's InitializeLifetimeService() is virtual, and this class already has a vtable, so adding it inserts a slot here and in both derived classes — SA-3 excludes that. It must not be added non-virtually as a workaround, which would silently defeat the one thing the .NET member exists for while looking correct at every call site; its absence is pinned through a detection idiom. It was 17,279 immediately before, measured on the same date by tickets #2291 and #2295. ±0 on the #2295 reading, and deliberately so: #2291 rewrote ApplicationId's suite rather than adding to it — thirteen narrow cases became six broader ones plus five new contract cases. #2291 takes all four of its review's decisions toward .NET at once, because the review said the fourth cannot be decided first: the name is validated, the public key token is a byte container cloned in and out (a const& return would have defeated the constructor's own copy), Culture/ProcessorArchitecture are std::optional, and ToString() is .NET's grammar — including two quirks transcribed rather than tidied, a space before processorArchitecture's = and a token printed even when empty. #2292 closed on the way past: GetHashCode was noexcept while allocating. It was 17,278 immediately before, measured on the same date by ticket #2276. +1 on the 17,278 below, in SharpRuntimeTests_Core_Base. #2295 makes ObsoleteAttribute's Message, DiagnosticId and UrlFormat std::optional<std::string>, matching .NET's string? — an absent and an empty value used to be the same state, and a default attribute compared equal to one built from std::string{}. A getter change alone could not have closed it: the boundary was on the way in too. The ticket's own objection to this route is discharged by measurement, not overruled — it said downstream consumers "were not inspected", and they have been: mobile-eggbert zero, cna once inside a comment. sizeof 112 → 136 under SA-3. It was 17,278 immediately before, measured on the same date by ticket #2276. −5 on the 17,283 below, a documented decrease and the chain's fourth: nine narrow ArgIterator cases replaced by four that assert more. #2276 answers the ticket's question — the members stay instance members, because .NET's are — but the reference settled a larger half it had not asked: every member of .NET's ArgIterator throws, and three here returned quietly (End() a no-op, Equals() false, GetHashCode() 0), handing a caller a plausible answer where .NET reports an unsupported platform. Three further divergences the ticket did not name are fixed: the exception was the base type, GetNextArgType()'s return type, and a missing overload. It was 17,283 immediately before, measured on the same date by ticket #2281. −15 on the 17,298 below, a documented decrease and the chain's third: #2281 removed System::UnitySerializationHolder under SA-9, taking two suites totalling 15 cases with it. The ticket's own premise is corrected: .NET's type is not internal — it is public sealed, with the comment "Needs to be public to support binary serialization compatibility". The real reason is better: it exists only for .NET Framework BinaryFormatter compatibility, is [Obsolete], and every public member takes SerializationInfo/StreamingContext — infrastructure this project permanently does not implement. It was 17,298 immediately before, measured on the same date by ticket #2334. −8 on the 17,306 below, and it is a documented decrease, the chain's second after #2284's −5: #2334 removed System::RuntimeType under SA-9, taking its 8-case test file with it. The removal is exactly that — verified by counting TEST( in the file at its last committed revision — and no other executable's count moved. .NET's RuntimeType is internal sealed class RuntimeType : TypeInfo, not public API at all and not an enumeration, and the port's six values were its own invention. Removal beat renaming because there was nothing to migrate and, since reflection is a permanent deviation, nothing would ever force the .NET name free again. RuntimeTypeHandle is a real .NET type and is untouched, which the negative fixture asserts. It was 17,306 immediately before, measured on the same date by ticket #2326. +2 on the 17,304 below, in SharpRuntimeTests_Core_Base (6,042 → 6,044). #2326 makes Stopwatch::Frequency the clock's own 1,000,000,000 rather than the TimeSpan tick rate, and stops GetTimestamp() dividing by 100. The old pair was self-consistent and still a defect: every elapsed time was correct, but nothing finer than 100 ns could be resolved. The value is derived from Clock::period, not transcribed — reporting QueryPerformanceFrequency while sampling steady_clock would be a lie about a different timer — and stays constexpr, a stronger guarantee than .NET's runtime static readonly. The review's warning was correct: twelve tests across two suites needed rescaling, none a regression, and two of them turned out to be about double rounding at unit scale where the system provider only happened to qualify. It was 17,304 immediately before, measured on the same date by ticket #2324. +2 on the 17,302 below, in SharpRuntimeTests_Core_Base (6,040 → 6,042). #2324 changed no production statement: it declares that event args stay const to subscribers, and the declaration is a measurement — the repair was fully built and compiled before being reverted. SA-8 does not reach it, because SA-8 covers the port being more permissive than .NET and here it is more restrictive. Dropping the const costs 29 first-party sites, forces EventArgs::Empty to stop being const (a process-wide mutable global), and trips a deliberate tripwire belonging to #2199 — while buying nothing today, since the only two EventArgs types with a settable property are delivered through no handler alias at all. The trigger for revisiting is recorded: the first event that delivers a settable args object. It was 17,302 immediately before, measured on the same date by ticket #2325. +2 on the 17,300 below, in SharpRuntimeTests_Core_Base (6,038 → 6,040). #2325 makes ResolveEventHandler return std::optional<std::string>, so a handler can decline as .NET's Assembly? does. The empty string could not be borrowed for it — empty already means absent requesting assembly in ResolveEventArgs, so a sentinel would have been unenforceable by the type, which is the defect itself. The review's "decide SR-AUD-103 first" is deliberately not taken, and its own reasoning is why: a delegate nothing calls is the cheapest possible moment to fix a signature. The break is asymmetric — a widening for handlers, since std::string converts implicitly. It was 17,300 immediately before, measured on the same date by ticket #2328. +1 on the 17,299 below, in SharpRuntimeTests_Core_Base (6,037 → 6,038; one pin inverted, one case added). #2328 makes ArraySegment::CopyTo(std::vector&, index) reject a short destination instead of resizing it — .NET's body is Array.Copy and .NET arrays cannot grow, so a caller who passed the wrong buffer got a silently enlarged one instead of a diagnostic. It was also the one place in this repository that broke its own convention: twenty of twenty-six CopyTo overloads already rejected. The review found four tests pinning the resize and there were five — the fifth was caught by the full gate after a filtered run had passed, which is why the gate covers the whole repository. It was 17,299 immediately before, measured on the same date by ticket #2322. +2 on the 17,297 below, in SharpRuntimeTests_Core_Base (6,035 → 6,037). #2322 makes System::ValueType's constructor protected, matching .NET's public abstract class. Protected rather than abstract, deliberately: a C++ class is abstract only by having a pure virtual, and .NET's ValueType.ToString() has a real body, so making one pure would invent surface the reference lacks. The review's premise was wrong and is corrected — it said the only derivations were two test types, and missed five direct instantiations in a different file, which the compiler found at once. The identity Equals, address hash and literal ToString stay: all three are reflection, and a test pins them as declarations. It was 17,297 immediately before, measured on the same date by ticket #2330. +3 on the 17,294 below, in SharpRuntimeTests_Core_Base (6,032 → 6,035). #2330 makes all eight TupleN arities getter-only, as .NET's are — Tuple::Create(1, 2).Item1 = 99 used to compile and stick. The user decided it knowing the cost, which was stated first: t.Item1 becomes t.getItem1Property() permanently under rule 5. Getter-only means a const reference — a T& return would satisfy the naming rule while leaving the finding in place. ValueTuple is deliberately untouched, because .NET's two tuple families differ on purpose, and a test pins that boundary. The ticket's own count was wrong: 61 real sites, not 75, since the 16 in SystemTypesRemainingTests.cpp are all ValueTuple — which is why the migration was driven off the compiler's private-access errors rather than a grep. It was 17,294 immediately before, measured on the same date by ticket #2332. +1 on the 17,293 below, in SharpRuntimeTests_Buffers (628 → 629). #2332 makes SequencePosition's two components private, matching .NET's private readonly fields — the port could only state in a doc-comment the rule .NET enforces in the language. It is an access change, not a layout change: sizeof, alignof and trivial copyability are unchanged, so consumers need no rebuild, and brace initialisation with two arguments still compiles through the constructor. No first-party migration was needed — every other use already went through the accessors. It was 17,293 immediately before, measured on the same date by tickets #2327 and #2339. +2 on the 17,291 below, in SharpRuntimeTests_Core_Base (6,030 → 6,032). #2327 gives Array::MaxLength .NET's 0x7FFFFFC7 instead of int.MaxValue56 less, and the gap is the GC's allocation ceiling rather than this port's to choose; the assertion that let the divergence survive was EXPECT_GT(…, 0), which passes for both values. #2339 makes System::Attribute's constructor protected, and the copy and move members with it, so the base cannot be reached by a slice — a route the review did not enumerate. The identity Equals is not repaired and cannot be: .NET compares every instance field, which is reflection, so the deviation is now pinned by a test showing that all forty-six subclasses inherit it. It was 17,291 immediately before, measured on the same date by ticket #2271. +4 on the 17,287 below, in SharpRuntimeTests_Core_Base (6,026 → 6,030). #2271 makes Delegate::Combine/Remove refuse operands of different concrete types, as .NET does, and makes a composed delegate carry the type. The finding's two halves really were inseparable — a guard alone breaks the chained form — but no data member was needed: a multicast's type is the type of its entries, which Combine keeps uniform, so it is read from the list and sizeof(Delegate) is unchanged. The ticket expected two fixtures to need rewriting and none did; what the removing mutation breaks is three pre-existing MulticastDelegateTests, which is what shows the derivation is load-bearing. It was 17,287 immediately before, measured on the same date by ticket #2313. +1 on the 17,286 below, in SharpRuntimeTests_Core_Base (6,025 → 6,026; two pins inverted, three cases added). #2313 makes Environment able to express a present-but-empty variable: the getter returns std::optional<std::string> and std::nullopt, not "", is what removes. The deferral's two readings implied opposite work and the reference settles it — only a null value removes, and there is no empty-to-null conversion on the read side, so the port's own doc-comment stated its contract rather than .NET's. The measured downstream split is what makes this safe: cna has zero Get sites, so the return-type change breaks nothing, and 96 of its 98 Set sites are unaffected — but two change meaning silently, one carrying the comment "empty value deletes it", and both are named to the line in #2366 rather than edited. Fixture set grows to 19 fixtures / 139 sites. It was 17,286 immediately before, measured on the same date by ticket #2246. +3 on the 17,283 below: SharpRuntimeTests_Core_Base 6,022 → 6,025 and SharpRuntimeIntegrationTests unchanged (one pin inverted in place). #2246 removes Property<T>'s vestigial cachedValue, so sizeof(Property<int>) falls 72 → 64 and sizeof(Property<std::string>) 96 → 64 — the size no longer depends on T at all. The member was wrong, not merely unused: a cache in the wrapper can only ever disagree with the storage the getter and setter close over. The half a sizeof pin cannot express is the one worth having — every constructor default-initialised it, so T had to be default-constructible, a requirement a getter/setter wrapper never used. #2244 wrote the integration pin for exactly this moment and it is inverted, not deleted. It was 17,283 immediately before, measured on the same date by ticket #2215. +1 on the 17,282 below, in SharpRuntimeTests_Core_Base (6,021 → 6,022; one gated pin replaced by two cases). #2215 guards ArraySegment<T>'s four enumeration doors, so a default segment throws where a range-for used to perform zero iterations silently. It is a public signature change — the noexcept drop — and the first landing under SA-10, with all five SA-2 conditions discharged; the negative consumer fixture set grows to 18 fixtures / 135 sites, its fourth new site being the shape that breaks silently (a helper whose own noexcept is computed from the door). The guard asks whether the array is present, never whether the count is zero. One mutation is caught as a crash rather than a failure, inherently — it reintroduces the very SEGV the finding recorded, and no test can assert on undefined behaviour. It was 17,282 immediately before, measured on the same date by ticket #2357. +2 on the 17,280 below, in SharpRuntimeTests_Net_WebSockets (103 → 105; one pin inverted, three cases added). #2357 gives ClientWebSocket .NET's outer gateObjectDisposedException when disposed, InvalidOperationException when never connected. The ticket's framing was too simple and the reference corrected it: .NET has two layers, and the inner per-operation check raises WebSocketException(InvalidState) exactly as this port already did, so rewriting it would have replaced a correct exception with a wrong one. No new data memberAbort() calls Dispose() in .NET, so InternalState maps onto state this class already holds and sizeof stays 424. It was 17,280 immediately before, measured on the same date by ticket #2238. +2 on the 17,278 below, in SharpRuntimeTests_Core_Base (6,019 → 6,021; three pins replaced by five). #2238 makes Lazy<T>'s PublicationOnly match .NET: the factory runs with no lock held, the first publication wins and every loser discards its value. This reverses a guarantee the class advertised for its whole life, and the user decided it knowing the price, stated first — a PublicationOnly factory may now run concurrently and more than once, and a recursive one exhausts the stack instead of raising. The old pin asserted the old contract and hung against the new code, which is the correct signal. One mutation is not caught and the code says so at the site: restoring the reentrancy guard changes nothing, because creatingThreadId_ is no longer written on that path. It was 17,278 immediately before, measured on the same date by ticket #2320. +5 on the 17,273 below, in SharpRuntimeTests_Core_Base (6,014 → 6,019). #2320 makes POSIX GetFolderPath honour XDG_CONFIG_HOME/XDG_DATA_HOME when absolute, and honour SpecialFolderOption, which it used to accept and ignore. The default option now verifiesGetFolderPath(Desktop) is "" where no such directory exists — which is GetFolderPathCore.Unix.cs:26-47's contract and a behaviour change. The second clause was not a user question: the reference answers it, so it was derived. A test defect had to be fixed on the way: five rows passed only because this machine has ~/Desktop, which SA-6 calls a defect in the test, and one asserted the opposite of the new contract. The premise for declining full XDG was wrong and is corrected rather than left standingReadXdgDirectory does exist, so eight further diverging rows are #2364 rather than a silent widening. It was 17,273 immediately before, measured on the same date by ticket #2138. +2 on the 17,271 below, in SharpRuntimeTests_Net_Sockets (124 → 126; two gated pins replaced by four cases). #2138 makes the six TcpClient/TcpListener/UdpClient endpoint doors refuse an IPv6 address deliberately, at the door, with .NET's own ArgumentException text. The finding's wording did not reproduce: SR-AUD-266 called it silently misrepresented and, measured twice, nothing was ever silently narrowed — this was a diagnostic defect. Three refusals existed, none naming the operation the caller attempted, and one of them let a TcpListener be fully constructed and only fail at Start(). The two hostname doors are deliberately unchanged because "::1" at a hostname parameter is #2359's question. One mutation is not a mutationIPAddress carries its family in a single bool, so an allow-list and a deny-list are equivalent, and the dead name table the first cut wrote was deleted rather than defended. It was 17,271 immediately before, measured on the same date by ticket #2356. +1 on the 17,270 below, in SharpRuntimeTests_Core_Base (6,013 → 6,014; one gated pin replaced by two cases). #2356 makes an all-zero magnitude parse to 0 at any exponent — "0E30" was an OverflowException. The recorded answer was wrong, and so was the first correction: the deferral asked whether a non-positive scale overflows and the old pin asserted that a large positive one does, while Number.Parsing.Common.cs:259-268 discards the scale entirely when no nonzero digit was seen. A middle answer — that the count of zeros matters — is also wrong and is recorded as a trap, because a leading zero skips the same block and so never advances the scale. Two of four mutations are not caught and both are explained rather than papered over: one is unobservable until #2362 lands, the other is semantically identical code. It was 17,270 immediately before, measured on 2026-08-17 by ticket #2043. +1 on the 17,269 below, in SharpRuntimeTests_Net (331 → 332). #2043 makes Dns::GetHostAddresses reject 0.0.0.0 and :: with ArgumentException, as .NET does at three separate sites. This removes a result that worked, which is why the ticket was split out of #2039 and needed evidence rather than judgement: an unspecified address names every local interface to bind and nothing at all to connect, so resolving one to itself hands a caller a target it cannot use. The rejection runs before the address-family check, matching the reference's own ordering, and that ordering is asserted. It was 17,269 immediately before, measured on the same date by ticket #2044. +3 on the 17,266 below, all in SharpRuntimeTests_Net (328 → 331). #2044 makes WebUtility::HtmlEncode encode U+00A0..U+00FF and supplementary scalars as decimal references, closing the asymmetry where the decoder understood more than the encoder could emit. The premise the deferral rested on was wrong: it held that "two HTML encoders in one repository must not be given two different escape sets", and .NET has exactly two, with exactly two different sets, deliberately — HtmlEncoder's allow-list in uppercase hex (#2019) and this narrower decimal one. Each matches its own counterpart; a mutation that "makes them consistent" is caught. It was 17,266 immediately before, measured on the same date by ticket #2106. +1 on the 17,265 below, in SharpRuntimeTests_IO (691 → 692). #2106 gives BinaryData::ToString() .NET's UTF-8 decoding, so an ill-formed byte becomes U+FFFD instead of being handed to the caller inside a std::string that is not valid UTF-8. The finding's two halves are answered differently on purpose: the aliasing half will not be repaired, because reproducing .NET's wrapping BinaryData(byte[]) means holding a borrowed reference in a language with no GC — the CCF-019 defect this programme has spent the session removing. The shared UTF-8 scalar decode moved from modules/text to Core.Base so modules/io could reach it without a sixth copy or a new public component edge; the bodies are byte-identical, System::Text::detail re-exports both names, and the module graph is unchanged at 41/92. It was 17,265 immediately before, measured on the same date by ticket #2080. +3 on the 17,262 below, all in SharpRuntimeTests_Xml (504 → 507). #2080 gives XmlConvert's TimeSpan pair the XML Schema duration form — P1D, not 1.00:00:00.0000000 — and the deferral's three unanswerable sub-questions are all answered: the year/month factors are .NET's own estimate (365 and 30, and the source says so), the native colon form is no longer accepted, and the exception is always FormatException because .NET remaps even its own OverflowException. Two things surprised the first cut and are recorded: PT.5S is valid (the test was wrong, not the parser), and one line of XsdDuration.TryParse is dead in the reference itself. It was 17,262 immediately before, measured on the same date by tickets #2082 and #2083. +6 on the 17,256 below: SharpRuntimeTests_Xml 496 → 504 and SharpRuntimeTests_Xml_Linq 334 → 332 (two pins replaced by two); no other executable's count moved. XmlDocument::LoadXml now rejects an undeclared entity reference and an undeclared namespace prefix, as .NET does. The entity case was worse than acceptance: &nope; was reinterpreted as literal text and re-escaped, so loading and saving a document silently rewrote it. The entity check must run on the raw text — tinyxml2 decodes the predefined five, so &amp;nope; and &nope; are indistinguishable afterwards — and "undeclared" is not "not predefined": the first cut rejected the repository's own billion-laughs pin, which declares two entities and references one. Load(filename) cannot run the entity check and that asymmetry is #2361. It was 17,256 immediately before, measured on the same date by ticket #2072. +5 on the 17,251 below, all in SharpRuntimeTests_Net_Http (193 → 198). #2072 gives HttpClient::parseUrl RFC 3986's userinfo rule — http://user@host/p returned the host "user@host", which went to getaddrinfo as a DNS name and into the Host: header — and stops http://[::1]x/p silently discarding the x, so the URL a caller writes and the URL the client connects to can no longer differ without a diagnostic. The last @ is the delimiter, per RFC 3986 §3.2.1. Note one mutation was not caught and the redundant check it exposed was deleted rather than defended. It was 17,251 immediately before, measured on the same date by ticket #2130. +4 on the 17,247 below, all in SharpRuntimeTests_Net_Http_Headers (427 → 431). #2130 accepts the two obsolete HTTP-date forms RFC 9110 §5.6.7 requires a recipient to accept, which .NET accepts too — it tries twenty-one formats. The widening uncovered a latent defect: Sun, 06 Nov 94 … was not rejected, it was accepted as the year 94 AD, a silently wrong instant off by nineteen centuries, and no test had noticed. That row is a correction. The two-digit-year window is .NET's (TwoDigitYearMax == 2029), not the naive 1900 + yy. The remaining sixteen lenient .NET formats are deliberately not adopted and are pinned as such — #2360. It was 17,247 immediately before, measured on the same date by ticket #2060. +1 on the 17,246 below, in SharpRuntimeTests_Buffers (627 → 628). #2060's two claims came apart: Utf8Parser's signed D/G grammar now accepts a leading + because .NET's does (Utf8Parser.Integer.Signed.D.cs:16-31), while unsigned D still rejects it because .NET's has no sign handling at all — so the "internal inconsistency" with unsigned N is .NET's own and is reproduced deliberately, with a mutation that tidies it away being caught. It was 17,246 immediately before, measured on the same date by ticket #2119. +1 on the 17,245 below, in SharpRuntimeTests_Text_Json (293 → 294). #2119 is a measurement, not a repair, and the answer is partial: the like-for-like re-run of OwnedTreeLifetimeContractPlan.md's deep-nesting rows shows the parse half gone outright — X28c went from SIGSEGV to 0.032s and X28d is linear — and J19c fixed, while J19d survives: 100,000-deep programmatic construction takes 9.63s against 20,000's 0.365s, which is 26× for 5× the depth, i.e. still quadratic. Its cause is AssignParent's ancestor walk, already owned by #1896. The old pin tested only the parse path and would have gone on passing while J19d stayed broken. It was 17,245 immediately before, measured on the same date by ticket #2005. +2 on the 17,243 below, all in SharpRuntimeTests_Uri (275 → 277). #2005 makes System::Uri trim surrounding whitespace before parsing, removing an asymmetry the deferral had recorded rather than tidied: leading whitespace used to make the whole reference relative while trailing whitespace was accepted into the path. The trimmed set is exactly .NET's UriHelper.IsLWS — space, LF, CR, TAB — deliberately not std::isspace, and a vertical tab is asserted to still fail. The other half of #2005, a space inside the host, is deliberately left to #2359: it is a narrowing question needing a trace through .NET's host parser, and bundling it with a verified widening is what the original deferral was avoiding. It was 17,243 immediately before, measured on the same date by ticket #1998. +3 on the 17,240 below, all in SharpRuntimeTests_Uri (272 → 275). #1998 makes UriParser::IsKnownScheme reject a malformed scheme with ArgumentOutOfRangeException instead of reporting it as merely unknown — a caller could not previously tell "I do not recognise this scheme" from "that is not a scheme". The deferral was correct and the evidence has arrived: the ticket was blocked on "the same line #1963 sits on", and the reference now confirms SR-AUD-147 exactly. It also adds Uri::CheckSchemeName, which is public in .NET and which .NET's own IsKnownScheme is defined in terms of. It was 17,240 immediately before, measured on the same date by tickets #2056 and #2057. +2 on the 17,238 below, all in SharpRuntimeTests_Buffers (625 → 627). #2056(a) makes a disposed pool owner throw ObjectDisposedException instead of returning a zero-length Memory indistinguishable from a live Rent(0); half (b) stays pinned as unfixed, and the .NET-shaped null discriminator was deliberately rejected because it would make half (b)'s latent use-after-free deterministic. #2057 makes a default ReadOnlySequence<T> enumerate no segments where getEmpty() enumerates one — .NET's distinction, which a std::vector cannot express. Both grow by 8 bytes under SA-3; modules/buffers is header-only and nothing outside it includes either type. It was 17,238 immediately before, measured on the same date by tickets #2067 and #2069. +1 on the 17,237 below, in SharpRuntimeTests_Net_Http (192 → 193; two gated pins inverted, three cases added). #2067 makes an HttpRequestMessage sendable once, as .NET has always required — the second send reuses content the first may have consumed — with an atomic claim, and sizeof(HttpRequestMessage) grows 192 → 200 under SA-3 (consumers must rebuild). #2069 bounds the status code to 0..999, which is .NET's domain, not 100..599. This closes the modules/net-http review: #2067, #2068, #2069 and #2071 all landed today. It was 17,237 immediately before, measured on the same date by ticket #2071. +5 on the 17,232 below, all in SharpRuntimeTests_Net_Http (187 → 192). #2071 bounds all three response-reading paths with MaxResponseContentBufferSize, defaulting to .NET's HttpContent.MaxBufferSize (int.MaxValue) rather than a number this port invented — pinned to the exact value. A declared Content-Length is checked before any body byte is read, because a Content-Length is a claim; and a chunk size of FFFFFFFFFFFFFFF used to be a request to accumulate eighteen exabytes. The knob lives on HttpClientHandler rather than HttpClient because this port's handler reads eagerly where .NET's streams, with HttpClient forwarding so it keeps .NET's name, default and validation. It was 17,232 immediately before, measured on the same date by ticket #2068. +1 on the 17,231 below, in SharpRuntimeTests_Net_Http (184 → 187, two gated pins inverted and three cases added). #2068 makes HTTP field names case-insensitive, and the premise that blocked it was avoidable: the review said the repair "cannot make the lookup case-insensitive without changing" the public map type, which is true of one implementation and not of the rule — setHeader erases case variants on the way in, so getHeadersProperty()'s type is untouched and the pinning static_asserts are kept. The second half is the security-relevant one: the handler wrote Host, User-Agent, Accept and Connection unconditionally before the caller's map, so a caller-set Host went on the wire twice. It was 17,231 immediately before, measured on the same date by ticket #2095. +2 on the 17,229 below, in SharpRuntimeTests_Net_WebSockets (101 → 103). #2095 enforces fragmentation ordering: a continuation with no message in progress used to be typed from a C++ member initialiser, and a fresh data frame mid-fragment used to make message boundaries server-controllable. The deferral was correct and is now resolvable — .NET rejects both (ManagedWebSocket.cs:1385-1410) and this port uses its messages. A second defect found while fixing it is fixed with it because it is the same state: the tail of a non-final frame reported endOfMessage = true from the buffer's exhaustion. Two bool members fit in padding, so sizeof(ClientWebSocket) stays 424. modules/net-websockets now has no open implementation work: #2092, #2093, #2094, #2095 and #2096 all landed today, leaving only the needs_user parity question #2357. It was 17,229 immediately before, measured on the same date by ticket #2092. +1 on the 17,228 below, in SharpRuntimeTests_Net_WebSockets (100 → 101). #2092 makes WebSocketException keep the inner exception it used to accept and discard, and the analysis that blocked it was half wrong: the review called the repair a change to a widely derived base "and possibly an object-layout change on every exception type in the repository", but System::Exception has carried innerException_ all along — measured, sizeof(System::Exception), sizeof(Win32Exception) and sizeof(WebSocketException) are byte-identical before and after — and .NET's own Win32Exception has the very constructor the port was missing. This closes the modules/net-websockets review: #2092, #2093, #2094 and #2096 all landed in this session. It was 17,228 immediately before, measured on the same date by tickets #2094 and #2358. +8 on the 17,220 below: SharpRuntimeTests_Net_WebSockets 93 → 100 and SharpRuntimeTests_Net_Sockets +1; no other executable's count moved. #2094 makes KeepAliveInterval/KeepAliveTimeout real, with .NET's two strategies — the default (a non-positive timeout) is an unsolicited Pong that cannot fault, and only an explicit timeout selects Ping/Pong. sizeof(ClientWebSocket) grows 408 → 424 under SA-3. #2358 is a P1 defect #2094 uncovered and had to fix first: Socket::Send called ::send() without MSG_NOSIGNAL, so writing to a closed peer raised SIGPIPE and terminated the process — no exception, no return value, exit 141 — reproduced in thirty lines with no WebSocket in them. It was 17,220 immediately before, measured on the same date by ticket #2093. +3 on the 17,217 below, all in SharpRuntimeTests_Net_WebSockets (90 → 93; one gated pin inverted, four cases added). #2093 makes all five ClientWebSocket *Async members honour their CancellationToken, and the transport-level redesign the ticket was blocked on turned out to be nine lines: .NET registers Abort() on the token (ManagedWebSocket.cs:608,789), so cancelling any WebSocket operation aborts the whole WebSocket and no poll loop is needed. modules/net-sockets is untouched. Cancellation therefore destroys the connection — that is .NET's contract and the migration note says so. It also repairs a test-harness defect that took the whole suite down with terminate called without an active exception and no failing test name. It was 17,217 immediately before, measured on the same date by ticket #2096. +5 on the 17,212 below, all in SharpRuntimeTests_Net_WebSockets (85 → 90). #2096 removes a data race on four public ClientWebSocket properties where the finding named one, and stops Dispose()/Abort() destroying the socket underneath a thread still using it. Measured, the defect was worse than recorded: against the pre-repair code all five new tests hang, because Close() does not wake a thread already parked in recv() — so the old code both freed the Socket and left the worker parked. sizeof(ClientWebSocket) grows 360 → 408 under SA-3. It also closes a gap #2088 left: only two of the five *Async members had joined the liveness boundary. TSan reports the race on the reverted accessors and is clean on the repair; ASan reports heap-use-after-free when ConnectAsync leaves the boundary. It was 17,212 immediately before, measured on the same date by ticket #2175. ±0 on the reading below, and deliberately so: #2175 rewrote #2173's sixteen-case pin file rather than adding to it, so the evidence is four mutations rather than a count. #2175 makes Vector2/Vector3/Vector4 Normalize divide unconditionally, matching .NET, so a zero vector now yields NaN in every component with no diagnostic — as do Plane::CreateFromVertices on a degenerate triangle and Matrix4x4::CreateLookAt with eye == target, which used to return a silently singular matrix. Plane::Normalize keeps exactly one guard and it is .NET's DirectXMath overflow mask, not the epsilon fast path the plan believed in; its old < 1e-10f threshold had no .NET counterpart. Measured: both consumers reference System::Numerics in zero places. It was 17,212 immediately before, measured on the same date by ticket #2268. +8 on the 17,204 below, all in SharpRuntimeTests_Core_Base (6,005 → 6,013). #2268 gives all eight integer wrappers NumberStyles::AllowExponent, which NumberStyles::Any has always included. It was a deferred verification, not an approval, and the reference settles it twice over: the digit-buffer-and-scale model is transcribed, and .NET's own test suite pins the decisive rows — including 65E-1 as an OverflowException, which is the row the ticket was waiting for. A widening. One deliberate deviation (an all-zero magnitude with a non-positive scale) is filed as #2356 and pinned. Note the fifth mutation was not observable and was verified under UBSan instead. It was 17,204 immediately before, measured on the same date by ticket #2020 — 17,204 run, 17,204 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2020. +4 on the 17,200 below, all in SharpRuntimeTests_Text (307 → 311). #2020 closes CCF-012: CompositeFormat::Parse was a third hand-written composite-format grammar that skipped everything between the index and the closing brace, so {0,-} parsed cleanly while String::Format had rejected it since #1884. All three doors now share one non-rendering System::detail::scanCompositeFormat. The finding's second claim is false and stays unimplemented: .NET has two composite-format grammars and the index limit is exactly where they differ, so Parse gets no index limit and {1500000} keeps its answer. Adoption also widens — {0 } and friends are now accepted — and a leading space still is not. It was 17,200 immediately before, measured on the same date by ticket #2019 — 17,200 run, 17,200 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2019. +1 on the 17,199 below, in SharpRuntimeTests_Text (306 → 307). #2019 gives the default HtmlEncoder and JavaScriptEncoder .NET's Basic Latin allow-list, so a non-ASCII scalar is escaped rather than passed through. The target set was the half the old approval package recorded as unverifiable; the reference settles it at UnicodeRanges.BasicLatin, and the escape forms are transcribed with it. UrlEncoder needed no change. It was 17,199 immediately before, measured on the same date by ticket #2015 — 17,199 run, 17,199 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2015. +3 on the 17,196 below, all in SharpRuntimeTests_Text (303 → 306). #2015 changed no production statement: it is a decision and its evidence. Public indices, lengths and counts in System::Text stay UTF-8 storage bytes, declared in the permanent-deviations list above rather than repaired, because System::String is a UTF-8 std::string throughout the port and adopting .NET's unit would re-architect every index in it. The argument that makes that faithful rather than merely cheap is measured: .NET's StringBuilder.Remove has no surrogate-pair guard either, so it splits characters exactly as this port does — a different unit would have moved the hazard, not removed it. It was 17,196 immediately before, measured on the same date by ticket #2017 — 17,196 run, 17,196 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2017. +2 on the 17,194 below, all in SharpRuntimeTests_Text (301 → 303). #2017 makes a configured fallback reach every encoding, not only UTF8Encoding — an exception fallback now throws in UTF-16, UTF-32, ASCII and Latin-1 where it used to be silent — and makes a truncated trailing unit reach the fallback instead of vanishing. The default output is byte-identical, which required transcribing .NET's SetDefaultFallbacks for the two UTF-X encodings (U+FFFD, not the base's "?"). One limitation is filed rather than smuggled: EncoderFallback takes a char, so the unencodable scalar is narrowed — #2355. It was 17,194 immediately before, measured on the same date by ticket #2016 — 17,194 run, 17,194 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2016. +2 on the 17,192 below: SharpRuntimeTests_Text 300 → 301 and SharpRuntimeIntegrationTests 915 → 916. #2016 takes the byte-order mark out of GetBytes and puts it in a new GetPreamble(), matching .NET — a mark emitted as payload is concatenated into strings, counted in lengths and written twice by anything that also writes a preamble. The finding named one factory and there were two: BigEndianUnicode() is UnicodeEncoding(true, true) and emitted one as well, so the port was inconsistent with itself. GetPreamble() is deliberately not virtual and not on Encoding, since SA-3 excludes new virtuals on a public base class. It was 17,192 immediately before, measured on the same date by tickets #2014 and #2021 — 17,192 run, 17,192 passed, 0 failed, 0 skipped, measured on 2026-08-17 by tickets #2014 and #2021. +2 on the 17,190 below, all in SharpRuntimeTests_Text (298 → 300). #2014 makes Latin1Encoding convert scalars rather than UTF-8 storage bytes, so GetBytes(u8"é") is the single byte e9 and the whole 0..255 range round-trips; it also factored the UTF-8 decode that existed in five places into System/Text/detail/Utf8Scalar.hpp rather than adding a sixth, leaving the three header-inline copies as #2354. #2021 makes EncodingInfo::GetEncoding() resolve its own code page and reject one this runtime does not implement, instead of handing back UTF-8 for everything — an object that reports one code page and behaves as another is worse than one that refuses. It was 17,190 immediately before, measured on the same date by ticket #2013 — 17,190 run, 17,190 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2013. +2 on the 17,188 below, all in SharpRuntimeTests_Text (296 → 298). #2013 makes the seven factory encodings read-only, so installing a fallback on Encoding::UTF8() no longer changes what every other caller in the process decodes — transcribed from Encoding.cs:485-497, including that the read-only test precedes the null test. sizeof(Encoding) 40 → 48 under SA-3, while UnicodeEncoding/UTF32Encoding are unchanged at 48, their own flags having moved into the base's new padding — a pin written as base + one pointer would have been wrong in both directions. Clone() is deliberately not added: it would be a new virtual on a public base class, which SA-3 excludes. It was 17,188 immediately before, measured on the same date by tickets #2029 and #2030 — 17,188 run, 17,188 passed, 0 failed, 0 skipped, measured on 2026-08-17 by tickets #2029 and #2030. +2 on the 17,186 below, all in SharpRuntimeTests_Diagnostics (225 → 227). #2029 stops ~Process blocking for a redirected child's whole lifetime, and keeps the join rather than detaching the readers as its plan proposed — a detached reader appends into the destroyed object's own string, which would trade the defect for a worse one; bounding the wait with a poll slice and a stop flag gets the same promptness safely. The zombie half is deliberately not repaired: .NET reaps process-wide from a SIGCHLD wait state this port cannot have without colliding with PosixSignalRegistration. #2030 makes both captured-output getters return std::string by value under the readers' own lock, since no reference into a buffer another thread appends to can be made safe. Both P1 modules/diagnostics tickets are now closed, and with them the last P1 blocked on the CCF-019 family. It was 17,186 immediately before, measured on the same date by ticket #1959 — 17,186 run, 17,186 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #1959. +2 on the 17,184 below: SharpRuntimeTests_Threading 464 → 465 and SharpRuntimeIntegrationTests 914 → 915. #1959 closes the last async member of CCF-019, and does it by OWNERSHIP rather than by the waiting destructor #2134 established: a detached thread has no owner whose destructor could wait, and a thread_local slot has no destructor a caller controls, so holding a std::shared_ptr is the boundary — the direct counterpart of the GC reference .NET uses in both places. A public source break in three spellings, landed under SA-2 with all five conditions, including test/consumer/threading_borrowed_callback_negative.cpp (the fixture set is now 17 fixtures / 131 sites) and downstream ticket #2353, which records the measurement — zero sites in either consumer. It was 17,184 immediately before, measured on the same date by tickets #2066 and #2088 — 17,184 run, 17,184 passed, 0 failed, 0 skipped, measured on 2026-08-17 by tickets #2066 and #2088. +4 on the 17,180 below: SharpRuntimeTests_Net_Http 184 → 186 and SharpRuntimeTests_Net_WebSockets 83 → 85. Both apply the liveness boundary #2134 established for the CCF-019 async family — the destructor waits for an in-flight *Async body — with no public signature change. #2088 also repairs the finding's wider half by copying the caller's send buffer, while ReceiveAsync deliberately keeps its reference because that parameter is the out-parameter the result is written into. CCF-019 is still not closed: #1959 remains, as do the owned-tree members. It was 17,180 immediately before, measured on the same date by ticket #2134 — 17,180 run, 17,180 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2134. +4 on the 17,176 below, all in SharpRuntimeTests_Net_Sockets (120 → 124). #2134 gives Socket's four *Async members a liveness boundary: the destructor and move-assignment wait for an in-flight body instead of freeing the storage it is still reading. All four of #2139's pins survive — no enable_shared_from_this, ~Socket still noexcept, still move-assignable and non-copyable, return types unchanged — and sizeof(Socket) 24 → 40 under SA-3. Two implementation facts are worth carrying: shutdown() does not unblock accept() on Linux, so that one body polls in 50 ms slices instead, and a guard captured in the lambda is released only when the CALLER drops the TaskT, because std::async keeps the callable alive that long — the first cut deadlocked on exactly the case it was meant to fix. CCF-019 is not closed: #2066, #2088 and #1959 remain. It was 17,176 immediately before, measured on the same date by ticket #1970 — 17,176 run, 17,176 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #1970. +4 on the 17,172 below, all in SharpRuntimeTests_Threading_Tasks (218 → 222). #1970 makes TaskCanceledException own the task it names instead of borrowing a pointer to it, at no public-signature cost: a Task is a handle over a std::shared_ptr<State>, so a copy of the handle keeps the state alive and keeps observing it, which is .NET's contract rather than a snapshot. sizeof 192 → 200 under SA-3, pinned as a relationship. One pre-existing test was inverted: it asserted address identity, which is the borrowing the finding reports and is the wrong analogue of a .NET reference type — the counterpart of the same object for a value-semantics handle is the same state. CCF-019 is not closed: #1959, #2066, #2088 and #2134 remain. It was 17,172 immediately before, measured on the same date by ticket #1979 — 17,172 run, 17,172 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #1979. +4 on the 17,168 below, all in SharpRuntimeTests_Runtime (164 → 168). #1979 stops a non-cancelled SIGTSTP/SIGTTIN/SIGTTOU delivery suspending the process that merely observed it, and starts invoking a saved non-default disposition. Its design could only propose .NET's rule from a reading with no managed probe, and its own alternative 4 was defer until the reference tree is available — it is, and the derivation corrected the proposal three times: the no-op set is seven signals rather than three, the chaining belongs inside the raw handler rather than in the non-cancelled path, and the default branch restores the saved struct sigaction rather than imposing SIG_DFL. Its tests re-exec the binary rather than forking, because this module starts its watcher lazily and fork() hands a child the already started flag without the thread. It was 17,168 immediately before, measured on the same date by ticket #2228 — 17,168 run, 17,168 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2228. +3 on the 17,165 below, all in SharpRuntimeTests_Core_Base. #2228 gives Guid::NewGuid the platform CSPRNG, closing the last high finding in modules/core (SR-AUD-050). Its blocking question — whether a CSPRNG makes a non-throwing public function able to throw on Emscripten — dissolved on measurement: Emscripten's libc implements getentropy() as __wasi_random_get(), backed by crypto.getRandomValues, and .NET reaches the same source there, so all three platforms have a real CSPRNG and nothing new throws. The function is file-local to Guid.cpp because Core.Base cannot depend on Security.Cryptography.Random. The finding was invisible to ordinary tests by its own admission; it is now caught by the one property that discriminates — a userspace PRNG has state and fork() duplicates it, so the old code made a parent and child emit the identical GUID. It was 17,165 immediately before, measured on the same date by ticket #2128 — 17,165 run, 17,165 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2128. +7 on the 17,158 below: SharpRuntimeTests_Net_Http_Headers 423 → 427 and SharpRuntimeTests_Net_Http 181 → 184; no other executable's count moved. #2128 is the P1 request-smuggling shape, and the reference splits it in two. The singleton half lands at the collection: a second value for any of the twenty-two headers whose .NET parser is built with supportsMultipleValues: false is rejected with FormatException, so Content-Length: 10,20 and Host: a.example,b.example are no longer constructible. The TE+CL half lands on the wire, because it cannot land anywhere else — Transfer-Encoding is a request header and Content-Length a content header, they live in two collections that cannot see each other, and .NET's collections do not enforce RFC 9112 §6.1 either — so HttpClientHandler suppresses its own derived Content-Length when the caller declared Transfer-Encoding or supplied a Content-Length of their own. TryAddWithoutValidation deliberately keeps both values, matching .NET, and that is pinned. Four mutations, all caught. It was 17,158 immediately before, measured on the same date by tickets #2040 and #2352 — 17,158 run, 17,158 passed, 0 failed, 0 skipped, measured on 2026-08-17 by tickets #2040 and #2352. +4 on the 17,154 below, all in SharpRuntimeTests_Net (324 → 328); no other executable's count moved. #2040 makes CookieContainer::Add reject an explicitly supplied Domain that does not domain-match the request URI's host, and makes Cookie's constructors mark their values explicit so the container stops overwriting them — both transcribed from Cookie.VerifyAndSetDefaults, whose rule the plan had only been able to infer. #2352 is the reason to trust the number: three watcher tests waited with a bounded spin (2000 yields) rather than a deadline, and one of them failed inside a full-gate run while passing five times out of five in isolation. A gate that is intermittently green is not evidence, so the three now wait on a real deadline; SharpRuntimeTests_IO ran 693/693 five consecutive times. It was 17,154 immediately before, measured on the same date by ticket #2346 — 17,154 run, 17,154 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2346 through the complete independent gate. +4 on the 17,150 below: #2346 removed one case and added five, SharpRuntimeTests_IO 689 → 693, no other executable's count moved. #2346 took the NotifyFilters → inotify mapping decision (docs/StandingApprovals.md SA-7 — 1a, 2a, 3a, 4c, 5b), which is the one deferral in modules/io the reference tree could not settle and never will, because NotifyFilters names Win32 ReadDirectoryChangesW notifications and inotify's event set is not a relabelling of them. modules/io now has no open implementation work at all. Behaviour changes, all Linux-only and all in docs/Migration-IOLifecycleAndArgumentStrictness.md §8: a filter naming only Attributes/CreationTime/Security stops seeing a content write, LastAccess starts firing for a read, and FileName/DirectoryName stop reporting each other's entries. The default filter is byte-identical. Three mutations, all caught. The previous checkpoint's green-gate qualifications still apply and are restated there. It was 17,150 immediately before, measured on the same date by ticket #2351, and that was the first green gate in this repository's history — 17,150 run, 17,150 passed, 0 failed, 0 skipped, measured on 2026-08-17 by ticket #2351 through the complete independent gate, every executable run separately and continuing past failures. Every checkpoint below this one ends with “the gate is not green”; this one does not. Read what that does and does not mean. Two of the three failure sources simply do not occur in this container — the five PingTests and the SocketTests IPv6 case need networking this container has and the previous one did not — so their disappearance is environmental, not a repair. The third was a repair: #2351 rewrote the two TimeZoneInfoTests.BaseUtcOffset cases that hard-coded a tzdata version rather than a zone property. Europe/Dublin and Africa/Casablanca are negative-DST zones, and since tzdata 2018a the database marks the LARGER offset standard (Dublin: IST +60 standard, GMT daylight), so on tzdata 2026b the literal expectations failed while the port was right — confirmed against TimeZoneInfo.Unix.cs:81-93, whose rule this port already implements. Both cases now derive the expectation from the installed tzdata through an independent glibc oracle, and both gained a season-independent EXPECT_NE(baseOffset, daylightOffset); mutation M2 (report the daylight offset as standard) is caught by four cases including the rewritten Dublin one. A green gate here is not a claim that it is green everywhere — a container without networking will still fail PingTests. It was 17,147 immediately before, measured on the same date by ticket #2098 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,145 run-and-passed, 2 failed, 0 skipped, +13 on a before figure of 17,134 that was measured in the same container, from the same build tree, by stashing the change and rebuilding — so "no regression" here is measured, not asserted. The +13 is exactly the thirteen TextWrapperClosedStateTests cases #2098 added (SharpRuntimeTests_IO 676 → 689; no other executable's count changed). Two things about this reading differ from every reading below it, and both are environmental rather than a repository change. First, the branch: this is next, which carries 52 portability commits the audit branch does not, including test changes, which is why its pre-change floor is 17,134 rather than the 17,131 recorded below. Second, the container: the six inherited failures described below do not occur here — networking and IPv6 work, so all five PingTests and the SocketTests case pass — and two different ones do: TimeZoneInfoTests.BaseUtcOffset_Dublin_StandardIsZeroWithAPositiveDaylight and BaseUtcOffset_AllYearDaylightZoneUsesItsStandardReversion, both caused by tzdata 2026b, which expresses Europe/Dublin with a negative DST offset (standard IST +60, daylight GMT) where the tests expect standard GMT +0. They are inherited, environment-caused, were neither disabled, weakened, skipped nor recategorised, and the gate is not green. #2098 landed Approval IO-1 (docs/StandingApprovals.md SA-3): the four text wrappers now enforce their closed state, sizeof(StringWriter) grows 384 → 392 with the other three free, and half of SR-AUD-337 turned out not to reproduce against .NET — a leaveOpen StreamWriter is never marked disposed upstream either, so that asymmetry with StreamReader is now reproduced and pinned rather than "repaired". It was 17,131 immediately before, measured on 2026-08-12 by ticket #2347 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,123 passed, 6 failed, 2 skipped (17,123 + 6 + 2 = 17,131), +8 on the 17,123 below — exactly the eight WatcherReconfigurationFixture cases #2347 added (SharpRuntimeTests_IO 668 → 676, no other executable's count changed), so no regression anywhere. #2347 removes a real crash: all three FileSystemWatcher reconfiguring members joined the watcher thread unconditionally, and handlers run on that thread, so a handler calling any of them self-joined (std::system_error, code 35) and — because handler invocation had no try/catch — reached std::terminate (re-measured live: SIGABRT, exit 134). EnableRaisingEvents = false from a handler is now permitted with deferred teardown, matching .NET; Path and NotifyFilter are rejected with InvalidOperationException because both re-arm the very watch the calling thread is dispatching from and /rv cannot settle .NET's semantics there; and an exception escaping any handler now reaches Error instead of ending the process. ThreadSanitizer caught a race the first cut introduced — the identity check read watchThread_ from the watcher thread while another thread re-armed it — now answered from a thread_local marker, with enabled_/selfStopPending_ atomic; sizeof/alignof measured 216/8 before and after, so there is no layout change. TSan clean over five runs; five mutations, all caught. This also corrects docs/SystemIONamespaceReviewPlan.md's claim that modules/io had no implementation-ready autonomous work left: needs a design pass is not not implementation-ready. The six failures are the same inherited environment-caused ones described below and were neither disabled, weakened, skipped nor recategorised, so the gate is not green. It was 17,123 immediately before, measured on the same date by ticket #2350 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,115 passed, 6 failed, 2 skipped (17,115 + 6 + 2 = 17,123), +15 on the 17,108 below — exactly the fifteen XLinqNameValidationTests cases #2350 added (SharpRuntimeTests_Xml_Linq 319 → 334, no other executable's count changed), so no regression anywhere. #2350 closes the name-grammar half of the two-door asymmetry #2201 deliberately left open: the Xml.Linq direct serialisers emitted element and attribute names the sibling writer door has rejected since #2076. Measured, 26 of 37 probed names disagreed between the two doors — at the element door and the attribute door — where the finding named three. The repair validates the resolved qualified name ResolveStartTag produces, never XName::ToString()'s Clark notation, which no door emits; it reuses the shipped XmlConvert::VerifyName rather than growing a second grammar, and it holds the boundary #2196 and #2200 already hold — validate at the serialisation doors, not at construction, so constructing, storing and mutating an invalid XName all stay legal. The narrowing is measurably empty: 12 of 12 parsed trees survive the writer door, so no parsed document is affected, and 0 of 607 first-party name-literal sites are rejected. The one genuine narrowing — a leading colon — is documented in docs/Migration-XmlLinqNameValidation.md, matching what #2076 recorded for the writer door. The six failures are the same inherited environment-caused ones described below and were neither disabled, weakened, skipped nor recategorised, so the gate is not green. It was 17,108 immediately before, measured on the same date by ticket #2201 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,100 passed, 6 failed, 2 skipped (17,100 + 6 + 2 = 17,108), +18 on the 17,090 below — exactly the eighteen XLinqNulRejectionTests cases #2201 added (SharpRuntimeTests_Xml_Linq 301 → 319, no other executable's count changed), so no regression anywhere. #2201 is the mirror image of #2085: the writer door truncated at an embedded NUL because the value crossed c_str(), while the Xml.Linq direct serialisers, which have no such boundary, emitted it. Measured, nine doors emitted where the finding named two; all nine now reject, reusing #2085's single detail::ContainsNul detector and its policy. Every value without a NUL is byte-identical, and the non-Char characters other than NUL are still emitted — that is #2349's CheckCharacters decision and #2201 deliberately does not make it. The six failures are the same inherited environment-caused ones described below and were neither disabled, weakened, skipped nor recategorised, so the gate is not green. It was 17,090 immediately before, measured on the same date by ticket #2200 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,082 passed, 6 failed, 2 skipped (17,082 + 6 + 2 = 17,090), +18 on the 17,072 below — exactly the eighteen XLinqDocTypeSerializationTests cases #2200 added (SharpRuntimeTests_Xml_Linq 283 → 301, no other executable's count changed), so no regression anywhere. #2200 is the Xml.Linq half of #2084: XDocumentType has two DOCTYPE doors and WriteTo already delegated to XmlWriter::WriteDocType, so only SerializeTo still held a copy of the pre-#2084 concatenation. It now reuses the shared detail::SelectExternalIdDelimiter / detail::ExternalIdLiteralTerminatesDeclaration rather than growing a second definition; measured, the two doors disagreed on eleven of eighteen probed declarations before and none after, and every already-valid declaration keeps its bytes. The six failures are the same inherited environment-caused ones described below and were neither disabled, weakened, skipped nor recategorised, so the gate is not green. It was 17,072 immediately before, measured on the same date by ticket #2085 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,064 passed, 6 failed, 2 skipped (17,064 + 6 + 2 = 17,072), +4 on the 17,068 below — exactly the four XmlWriterValidationTests cases #2085 added (SharpRuntimeTests_Xml 494 → 498, no other executable's count changed), so no regression anywhere. #2085 stops an embedded NUL silently truncating writer content: measured, six doors lost data where the finding named three, all through the same std::string::c_str() boundary into tinyxml2's const char* API. Content without a NUL is byte-identical, including tab/CR/LF and multi-byte UTF-8. The six failures are the same inherited environment-caused ones described below and were neither disabled, weakened, skipped nor recategorised, so the gate is not green. It was 17,068 immediately before, measured on the same date by ticket #2084 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,060 passed, 6 failed, 2 skipped (17,060 + 6 + 2 = 17,068), +11 on the 17,057 below — exactly the eleven XmlWriterValidationTests cases #2084 added (SharpRuntimeTests_Xml 483 → 494, no other executable's count changed), so no regression anywhere. #2084 repairs the two DOCTYPE ExternalID literals at both producers (XmlWriter::WriteDocType and the second door the finding never named, XmlDocument::CreateDocumentType); every value not containing " keeps its output byte-for-byte, while a publicId containing ", a systemId containing both quotes, a > or a non-XML character are now rejected instead of silently truncated. The six failures are the same inherited environment-caused ones described below and were neither disabled, weakened, skipped nor recategorised, so the gate is not green. It was 17,057 immediately before, measured on the same date by ticket #2104 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,049 passed, 6 failed, 2 skipped (17,049 + 6 + 2 = 17,057), +8 on the 17,049 below — exactly the eight pins #2104 added (SharpRuntimeTests_IO 660 → 668, no other executable's count changed), so no regression anywhere. #2104 is a documentation-and-pins ticket that changed no production statement: one descriptor pin for plan §6.2's second measured positive, two for #2105's observable half and five for #2106, all add-only. The six failures are the same inherited environment-caused ones described below and were neither disabled, weakened, skipped nor recategorised, so the gate is not green. It was 17,049 immediately before, measured on the same date by tickets #2344 and #2345 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,041 passed, 6 failed, 2 skipped (17,041 + 6 + 2 = 17,049), +16 on the 17,033 below — exactly the sixteen WatcherReconfigurationFixture cases those two tickets added (SharpRuntimeTests_IO 644 → 660, no other executable's count changed), so no regression anywhere. The six failures are the same inherited environment-caused ones described below and were neither disabled, weakened, skipped nor recategorised, so the gate is not green. It was 17,033 immediately before, measured on the same date by ticket #2099 through the complete independent gate — every executable run separately, continuing past failures. That reading is 17,025 passed, 6 failed, 2 skipped (17,025 + 6 + 2 = 17,033), +9 on the 17,024 below — exactly the nine ClosedFileStreamFixture cases #2099 added (SharpRuntimeTests_IO 635 → 644, no other executable's count changed), so no regression anywhere. The six failures are the same inherited environment-caused ones described below and were neither disabled, weakened, skipped nor recategorised. It was 17,024 immediately before, measured on the same date by tickets #1986 and #1985 through the complete independent gate — every executable run separately, continuing past failures, because both repository runners stop at the first failing executable and therefore cannot produce a whole-repository total. That reading is 17,016 passed, 6 failed, 2 skipped (17,016 + 6 + 2 = 17,024), +3 on #2341's 17,021 — exactly the three PosixSignalTests cases those two tickets added (SharpRuntimeTests_Runtime 161 → 164, no other executable's count changed), so no regression anywhere. It was 17,021 immediately before, measured on the same date by ticket #2341, whose reading was 17,013 passed, 6 failed, 2 skipped (17,013 + 6 + 2 = 17,021), +7 on #2318's 17,014 — exactly the seven ThreadingMonitorRecursionTests cases #2341 added. Across both checkpoints the six are the same inherited environment-caused failures — five PingTests tracked by #1962 and one SocketTests case that needs IPv6 this container does not provide — and are not regressions, not disabled and not recategorised. The 38th executable is SharpRuntimeTests_IO_IsolatedStorage, added by the modules/io-isolated-storage batch (#2203–#2206), which took the gate from 16,505/37 to 16,563/38. It was 17,014 immediately before, measured by ticket #2318 on the same date. This paragraph's chain was last rebuilt at 15,071/37 by ticket #1932 on 2026-08-01; every reading between 15,071 and 17,014 is recorded checkpoint by checkpoint at the top of NEXT.md (#2318) — including the chain's only decrease, #2284's documented −5 — and is not restated here. The remainder of this note is #1932's own record, preserved verbatim. It was 15,058 immediately before, measured by tickets #1927, #1928, approved #1929 rows 5–6, #1880 and #1875 on the same date; it was 15,024 immediately before, measured by ticket #1897 — the approved option B of Group E in docs/RemainingApprovalDecisions.md §E.1, which makes System::Text::Json::Nodes::JsonNode::Parse build its tree iteratively — on 2026-07-31. #1897 is fully compatible: no accepted input, emitted text, public signature, object layout, vtable or exception specification changed, and it deliberately does not apply a depth bound, so JsonNode::Parse still accepts text that .NET and this module's own JsonDocument::Parse reject beyond DefaultMaxDepth = 64 (documented in the Parse doc-comment, pinned by two tests, and reopenable only as the still-unapproved option A). It was 14,998 immediately before, measured by tickets #1854/#1858/#1862/#1863/#1865/#1879/#1884 — the approved Groups A-D of docs/RemainingApprovalDecisions.md — on the same date. That batch is behaviour-incompatible by design in four places, all documented: Decimal::Parse reads , as a group separator (docs/Migration-DecimalCommaGroupSeparator.md), the four date/time parsers reject text they used to accept, String::Format adopts .NET's brace and alignment grammar, and Single/Double ToString(value, format) emit different E/N/G text. It was 14,920 immediately before, measured by tickets #1921-#1924 (the #1919 public-representation containers, which closed the #1912 Collections comparison-contract family) on the same date; it was 14,890 before that, measured by tickets #1913-#1918 and #1920 (the #1912 family) on the same date; it was 14,815 immediately before, measured by tickets #1904-#1910 (CCF-010) on the same date, and 14,745 before that, measured by tickets #1901/#1902 (CCF-009). This paragraph was last revised at 14,113/36 by ticket #1832; the readings between the two are recorded batch by batch in NEXT.md, which the intervening remediation batches kept as the live floor, and are not restated here. The remainder of this note is #1832's own record, preserved verbatim. The verified baseline is 14,113 tests across 36 component executables and one integration executable, measured by ticket #1832 on 2026-07-29 through the full repository gate (an incremental build was correct for it and for #1805/#1806/#1807/#1808/#1809/#1810/#1811/#1812/#1813/#1817/#1818/#1819/#1820/#1821/#1822/#1825/#1826/#1830/#1832: all twenty changed only .cpp bodies, inline header bodies, test bodies and header doc-comments, with no layout or public-signature change — #1814 additionally declared one new public component edge, Net.Http.JsonCore.Base, taking the graph from 90 to 91 edges and requiring the generated catalogue to be regenerated). It read 14,106 after #1830, 14,098 after #1826, 14,091 after #1813, 14,077 after #1825, 14,070 after #1808, 14,060 after #1809, 14,046 after #1821, 14,041 after #1822, 14,033 after #1820, 14,025 after #1819, 14,021 after #1818, 14,014 after #1817, 14,002 after #1816, 13,994 after #1814, 13,987 after #1812, 13,979 after #1811, 13,970 after #1810, 13,958 after #1807, 13,948 after #1806, 13,937 after #1805 and 13,923 after #1789 earlier the same day — that figure came from a fresh configuration and a clean-first rebuild, which #1789's object-layout change made mandatory — 13,880 after #1788, 13,840 after #1791, 13,790 after #1802, and 13,538 before that, having fallen behind several remediation tickets that each added permanent regressions. This floor should be raised as new tests are added and lowered only with an explicit, documented reason.
  1. Push only to feature/work. Never push to develop or master, and never create tags, without explicit per-action user approval.
  2. SPDX header on every project source/header// SPDX-License-Identifier: MIT + copyright + .NET attribution. Vendored sources retain their upstream headers; Markdown uses an HTML SPDX comment where one is present.
  3. Property naming: always getXxxProperty() / setXxxProperty(). Exception: indexers (C# this[key] equivalents) use getItem() / setItem(), not getItemProperty()/setItemProperty() — a deliberate, consistent convention for the parameterized-property case, applied across every indexer in the codebase.
  4. Namespace syntax: namespace System::Collections::Generic { (C++17 nested form).
  5. Use SharpRuntime::intcs, not int in public APIs that mirror .NET int parameters.
  6. No LINQ in the code this project writes — use std::ranges in ported bodies instead. This does not mean the tree contains no LINQ surface, and the distinction is worth stating here because the two look like a contradiction otherwise: modules/core/include/System/Linq.hpp is a 508-line System::Linq providing Where, Select, FirstOrDefault and ~17 more over std::vector<T>. It exists as a compatibility surface for ported C#/XNA call sites, not as a licence to use those operators in new code. Measured 2026-08-19: a strict search for System::Linq:: finds zero uses in this repository's production code, zero in cna and zero in mobile-eggbert — its only users are its own two test files. So it is a capability offered in advance of a caller; removing it would be a public-surface decision rather than a cleanup.
  7. No merge to master or tags without explicit per-action user approval.
  8. No broad header refactor — naming conventions touch 449+ files and would break CNA.
  9. Copy doc-comments from .NET source — when porting a type, if the .NET source (/rv/tmp/runtime/src/libraries/) has XML doc comments and the sharp-runtime header has none, copy them as Doxygen /** */ comments where the meaning translates cleanly to C++.
  10. At most two parallel compilation jobs. See "Build-resource policy" below. This is binding on every build, rebuild, sanitizer build, probe, fixture, and test script in this repository, permanently and for all future work. The ceiling was four until 2026-07-28, when the user lowered it to three, and two from 2026-08-01; historical ticket records that state a four- or three-job measurement describe what was correct under the then-current rule and are not retro-edited.
  11. Push immediately after every commit. A commit is not finished until it is on the remote. Run git push -u origin <current-branch> as the next action after each git commit — do not batch commits up for a later push, and do not end a turn with the local branch ahead of its upstream. This was introduced on 2026-08-09 after 108 commits (three to nine days old, +42,818/−1,051 across 277 files) were found sitting unpushed on Codex/remediation-batch-1804-namespace-b1yjh5; the container that held them is ephemeral, so that backlog was one reclaim away from being lost. The rule does not relax rules 3 and 9: the push target is the session's designated working branch, and develop, master and tags still need explicit per-action user approval. If a push fails on a network error, retry up to four times with exponential backoff (2s, 4s, 8s, 16s); if it still fails, say so explicitly rather than leaving the commit silently unpushed.
  12. Standing approvals live in docs/StandingApprovals.md — read it before recording any ticket as blocked on an approval. On 2026-08-17 the user granted four: SA-1 commit and push post-audit work directly to next (rules 3 and 9 are otherwise unchanged — develop, master and tags still need per-action approval); SA-2 a public source break may land with a migration note, a per-spelling negative consumer fixture, a #1773-shaped downstream ticket, the full gate, and a measured impact report against the local cna and mobile-eggbert checkouts — which may be read but never edited without a per-action instruction; SA-3 a private data member may be added to or removed from a public type without a fresh ask when no vtable/mangled-symbol/signature/noexcept change is involved, the before/after sizeof is pinned by a layout test, a migration note records the full-consumer-rebuild requirement, and the full gate runs — a vtable or base-class change still asks, and #1888, #1889 and #1896 stay declined; SA-4 Unicode tables are derived from .NET's own generated data in /rv at UCD 16.0, cross-checked against Perl 15.0.0 and Python 15.1.0, version pinned until an explicit bump ticket. On 2026-08-18 the user granted three more: SA-8 where this port publishes a mutable or public representation and .NET's is private, readonly or absent, match .NET and migrate the first-party sites — granted against the recommendation to split the family by hazard, and knowing that t.Item1 becomes t.getItem1Property() permanently under rule 5; SA-9 a type that exists only because .NET has one, and whose content is permanently out of scope, wears .NET's public shape with bodies that throw — which authorises the new Thread data-slot API that LocalDataStoreSlot needs; SA-10 a public signature change (return type, noexcept, nullability, [[deprecated]]) is a public source break and lands under SA-2's five conditions, which nine tickets had wrongly treated as covered only by SA-3. On 2026-08-20 the user also granted SA-15: DateTime reaches a timezone through an abstraction in Core.Base rather than by moving TimeZoneInfo (measured the dearer shape — not header-only, two exception types, a 270-line private POSIX header, and tzdata under every Core.Base consumer), with the caveat accepted that .NET's ToLocalTime() takes no argument, so the source must come from either a hidden registration hook or an explicit overload — a deviation either way, to be recorded as one; an unrecognised culture name is decided by a syntactic BCP-47 check rather than .NET's invariant-mode rule, which accepts only "" and "und" and would reject "de-DE", and that difference must be stated in the header rather than implied; and SA-3 is extended to cover vtable and base-class changes, under five conditions of which the fourth — enumerating every catch clause whose meaning changes — exists because a reparenting is invisible to a layout pin. That unblocks #1980 G-3 and #1997 A-4; #1888, #1889 and #1896 stay declined. On 2026-08-20 the user granted SA-14, three decisions that unblock the date/time chain rooted at #1940 (five tickets): the provider reaches the parser by moving DateTimeFormatInfo into Core.Base and adding overloads on top — measured at two files and zero changed include lines, against 34 files across eight modules for the new-component shape the ticket's wording implied; the culture-concurrency defect is repaired separately and first, with .NET's two-property model (thread_local current culture plus a process-wide DefaultThreadCurrentCulture fallback), because a plain thread_local would silently remove the process-wide setting this port has today; and an unrecognised culture name throws CultureNotFoundException from both doors. Two premises on #1940's own record were re-measured and found wrong — the exception type does exist here and is already thrown from the LCID path, and an unknown name does not "resolve to invariant" but produces an object that claims to be the requested culture and behaves as invariant. On 2026-08-20 the user also granted SA-16, three decisions that close the date/time zone chain: DateTime's style overloads take the zone as a parameter, which is #1941 phase 2's own precedent rather than a second answer to one question -- the alternatives were declined on the record, since accepting only the stamping styles would make a legal .NET style illegal here and a registration hook is the hidden global state #1940 already refused; DateTimeOffset::ParseExact gets an offset token plus the zone for the no-offset default, on the measurement that an offset is not a time zone and a format carrying one needs no zone database at all; and RoundtripKind is made real in BOTH directions, the parse side setting the kind from a zone token and XmlConvert::ToString emitting the marker as .NET's XsdDateTime does -- knowingly changing that member's output, which is why it was asked, and which makes #1945's declaration pin fail and be inverted rather than deleted. That document also records the environment facts those approvals rest on: /rv/tmp/runtime is present in this container (a .NET 11 preview snapshot, so a behaviour read from it must be reported as .NET 11's, not as timeless parity), and so are both downstream consumers. A ticket whose only recorded gate is "/rv absent" or "downstream consumers may not be inspected" is therefore not blocked here; re-verify before treating it as such.

Build-resource policy

This section is permanent and binding for all work in this repository, by any contributor and by any future Codex session, on every ticket — not only the ticket that introduced it. It has two halves: a CPU ceiling and the pre-existing SSD-saving rules. Both must be obeyed together.

CPU ceiling — two jobs, always

  1. Every compilation, link, build, rebuild, sanitizer build, consumer fixture, compile probe, dependency build, CMake configure step that compiles, and test script that performs compilation internally may use at most two parallel jobs / two CPU cores.

  2. Use commands equivalent to:

    cmake --build <dir> --parallel 2
    ninja -C <dir> -j2
    make -C <dir> -j2
    ctest --test-dir <dir> -j2

    or any lower value.

  3. Never use unrestricted or automatically detected parallelism. All of the following are forbidden:

    • a bare ninja invocation, which defaults to every CPU plus two;
    • -j with no number, which is unbounded;
    • --parallel with no explicit maximum, which uses all detected cores;
    • $(nproc), nproc, sysctl -n hw.ncpu, getconf _NPROCESSORS_ONLN, or any equivalent core-count substitution;
    • std::thread::hardware_concurrency() — or any runtime core count — used to choose a build-job count;
    • CMAKE_BUILD_PARALLEL_LEVEL, MAKEFLAGS, NINJA_STATUS-adjacent environment variables, or CI defaults left to expand to all cores;
    • any script that defaults to "all available cores" when no job count is supplied.
  4. When a repository script compiles internally, pass whatever argument or environment variable constrains it to two jobs (for example CMAKE_BUILD_PARALLEL_LEVEL=2, MAKEFLAGS=-j2, or the script's own job-count parameter), and record in the ticket that the constraint was applied.

  5. If a build script cannot currently be limited to two jobs, fix the script first, or use a bounded alternative (a direct cmake --build … --parallel 2 on the same targets). Do not run the unbounded script "just this once".

    scripts/job_count_policy.py is the single resolver used by scripts/check_selective_components.sh, scripts/local_ci_check.sh, and scripts/check_negative_consumer_fixtures.py. Precedence is explicit --jobs, then SHARP_RUNTIME_BUILD_JOBS, then the safe default 2; only 1 or 2 is accepted, and wrappers export the resolved value to nested helpers. Local CI still passes the resolved value explicitly to the fixture checker.

  6. The two-job limit applies even when the machine has more CPU cores. Core count is not a licence to raise it.

  7. Fewer than two jobs is always allowed and is preferred whenever a target is memory-heavy (sanitizer or template-heavy translation units): drop to -j1 rather than risking swap or an OOM kill.

  8. Exceeding two jobs requires new explicit user approval, per action. A previous approval never carries over to another command, another ticket, or another session.

  9. Every final ticket report must list:

    • every build directory used;
    • the maximum parallel job count actually used;
    • any script that required special handling (an argument, an environment variable, or a bounded substitute) to enforce the limit.

SSD-saving rules — unchanged and still binding

Why this matters: repeated from-scratch builds wrote 3.5 TB to this SSD in five days (measured 2026-07-28), on top of the earlier ~270 GB / two-day scratchpad measurement. That is real, irreversible flash wear, not just wasted time. Every avoided full rebuild is avoided write endurance spent. Reuse an existing build directory whenever it is usable — a full reconfigure/rebuild is the exception that must be justified, never the default.

  1. There is a FIXED, CLOSED set of build directory names. Never invent another one, and never suffix one with a ticket number, a date, a branch, or a topic. The complete list:

    Directory Purpose
    build/ the default build and the repository gate
    build-modular/ the modular/selective component build
    build-asan/, build-ubsan/, build-tsan/ sanitizer trees
    build-probe/ every ticket's throwaway probes, ABI experiments, shims and sweeps
    build-consumer/ every ticket's standalone consumer fixture
    build-tmp/ repository-local TMPDIR for mktemp-based scripts
    cmake-build-debug/ the IDE tree

    Directories like build-probe-1794/, build-consumer-1785/, build-asan-sortedset/ are the mistake this rule exists to stop: measured on 2026-07-28, twenty-one such one-shot directories held 441 MB and guaranteed that nothing in them was ever reused. A ticket separates its own work by file name prefix inside the shared directory (build-probe/1797_probe1_escapes.cpp), never by a new directory.

  2. Delete a ticket's probe artefacts once its evidence is transcribed into the design record. The design document is the durable evidence; the binaries are not. Sanitizer probe binaries reach hundreds of megabytes each.

  3. Prefer incremental builds. Do not clean, delete, or reconfigure a build tree unless it is genuinely broken or the configuration genuinely changed; document any such build and why.

  4. Retain ccache wherever it is already configured, and do not retrofit it where doing so would force an unnecessary full recompilation.

  5. Never create a build tree under /tmp, /var/tmp, or /dev/shm, including the per-session scratchpad. Redirect mktemp-based scripts through a repository-local TMPDIR (this repository uses build-tmp/).

  6. Remove large disposable binaries once their results are recorded, and never delete a build directory another session may still be using.


Platform policy

Compile support is not runtime support

The current full build/test baseline is Linux/GCC; the complete production-only All graph is also warning-clean under Clang 19.1.7 and is enforced locally and in CI. Post-modular MinGW-w64 GCC 14-win32/CMake 3.31.6 and Emscripten 5.0.7/CMake 3.31.6 library builds both compile the All graph and a selective Text.Json graph without GoogleTest/runtime execution. Real downstream Apple Clang/Xcode 15.4 builds drove the portability fixes from 1d22a7b2 through b797928f. The repository's tracked CI is Ubuntu-only, so do not describe Windows, Emscripten, or macOS as having the same current test coverage as Linux.

Unsupported runtime operations must still compile. They should throw PlatformNotSupportedException clearly rather than fail the build or silently degrade.

Current platform-limited areas include:

Subsystem Implemented runtime platforms Explicit limitation
System::Net::Sockets Windows and POSIX Socket operations throw on Emscripten.
System::IO::RandomAccess Windows and POSIX Operations throw on Emscripten.
System::AppDomain base directory Windows, macOS, Linux/POSIX Emscripten uses the virtual-FS-relative ./ fallback.
System::TimeZoneInfo Windows and POSIX Emscripten provides UTC/local fallback and rejects system-zone lookup.
System::Diagnostics::Process POSIX Operations throw on Windows/Emscripten.
PosixSignal/PosixSignalRegistration POSIX Registration throws on Windows/Emscripten.
NetworkInterface enumeration Linux Enumeration/query operations throw elsewhere.
FileSystemWatcher Linux/inotify Enabling events throws elsewhere.

What requires compiler-provided native 128-bit integers

System::Decimal, System::Int128, and System::UInt128 require a compiler-provided 16-byte __int128/unsigned __int128 type. This is a compiler-capability dependency, not an OS-name test: x86_64 GCC/Clang and x86_64 MinGW GCC provide it, while MSVC and i686 MinGW GCC do not. The public SHARP_RUNTIME_HAS_NATIVE_INT128 macro is always 0 or 1; CMake sets it from an actual compile probe and SharpRuntimeHelper.hpp provides the same __SIZEOF_INT128__-based fallback for non-CMake consumers.

When the macro is 0, the three direct type headers reject inclusion with a clear diagnostic and the library omits only Decimal.cpp. Otherwise-portable types remain available, with only their native-128-dependent members absent: Int64::BigMul(long,long), the 64-bit Math::BigMul overload, 128-bit BitConverter/BinaryPrimitives overloads, BinaryReader::ReadDecimal, IConvertible/DBNull::ToDecimal, and the Decimal XmlConvert overloads. This is the supported i686 MinGW compile/link boundary used by CNA's Glide backend; it does not claim Decimal or Int128/UInt128 support on that compiler.

The lack of native 128-bit integers is a known, accepted, permanent limitation — never hide it with a hand-rolled representation or reduced semantics. The 2026-07-11 decision remains that the risk and complexity of a from-scratch implementation outweigh its benefit.

Type Requires Availability
System::Decimal 16-byte unsigned __int128 SHARP_RUNTIME_HAS_NATIVE_INT128 == 1
System::Int128 16-byte __int128 SHARP_RUNTIME_HAS_NATIVE_INT128 == 1
System::UInt128 16-byte unsigned __int128 SHARP_RUNTIME_HAS_NATIVE_INT128 == 1

Correct platform abstraction approach

  • POSIX includes (<unistd.h>, <sys/socket.h>, etc.) must not appear in public .hpp headers.
  • Platform-specific code belongs in .cpp files guarded by #ifdef _WIN32 / #elif defined(__EMSCRIPTEN__) / #else (POSIX).
  • On unsupported platforms, throw System::PlatformNotSupportedException with a clear message — never silently fail.
  • Emscripten builds must compile without errors even when the feature is unavailable at runtime.

Status terminology

  • ✅ DONE — implemented, tested, compiles clean on the verified native baseline, no known bugs.
  • ⚠️ PARTIAL — compiles and mostly works but has known, documented API or behavior gaps.
  • ⚠️ PLATFORM-LIMITED — compiles across the intended toolchains but some operations are available only on named platforms and throw explicitly elsewhere.
  • ⚠️ STUB — API surface exists, bodies are no-ops or throw NotImplementedException.

Parity philosophy

sharp-runtime and .NET will naturally differ — C++ has no GC, no IL, no runtime reflection, and no delegate infrastructure. The goal is maximum practical parity: the public API, method semantics, default values, error messages, and algorithmic behaviour should match .NET as closely as C++ allows.

Known permanent deviations (not bugs, not TODO):

  • Reflection (System::Type, System::Activator, Enum.GetNames/GetValues, etc.) — completely out of scope. Stubs are the correct end state.
  • GC (System::GC) — all methods are no-ops. Memory is managed by RAII / std::shared_ptr.
  • Delegates — three tiers, not one blanket rule (verified 2026-07-11 while auditing ticket 72, since the previous one-line version of this bullet was itself inaccurate for two of the three): the majority of delegate-shaped types (Action, Func, EventHandler, and most *EventHandler/*Callback aliases across the codebase) are bare using X = std::function<...>; aliases — single-target only, no multicast, no BeginInvoke/EndInvoke (async delegate invocation is out of scope entirely, matching .NET's own removal of the pattern). But System::Delegate (modules/core/include/System/Delegate.hpp) is a real multicast delegate base class with working Combine/Remove/RemoveAll/GetInvocationList, and System::MulticastAction<Args...> (modules/core/include/System/MulticastAction.hpp) is a purpose-built multicast event-field type with +=/= and reentrancy-safe snapshot invocation — both genuinely support multicast where a ported type needs it. Delegate::DynamicInvoke always throws NotImplementedException (no late-bound object[] invocation equivalent in C++) in all three tiers.
  • Serialization ([Serializable], SerializationInfo) — ignored; not needed for game code.
  • P/Invoke / interop — out of scope.
  • The unit of every public index, length and count is a UTF-8 storage byte, where .NET's is a UTF-16 code unit — decided by ticket #2015 (SR-AUD-290, SR-AUD-296) on 2026-08-17 and declared, not repaired. System::String is a UTF-8 std::string throughout this runtime, so Encoding::GetCharCount of U+1F600 is 4 where .NET reports 2, and StringBuilder's Length/Insert/Remove/CopyTo index bytes. Adopting .NET's unit is not a change to System::Text; it is a re-architecture of every index in the port. What makes this the faithful adaptation rather than merely the cheap one is that a byte index into UTF-8 is the exact analogue of a code-unit index into UTF-16, including the ability to split a character: .NET's StringBuilder.Remove validates only the numeric range (StringBuilder.cs:1024-1042), with no surrogate-pair guard, so it can leave a lone surrogate and an ill-formed string exactly as this port can leave an ill-formed UTF-8 sequence. Adopting .NET's unit would have moved that hazard to a different character, not removed it. Pinned by TextUnitContractTests.Decl2015_*, including that the unit is consistent across the component — a mixture would be far worse than either unit consistently applied.
  • Unicode normalizationStringNormalizationExtensions::IsNormalized returns true and Normalize returns its argument unchanged for every input. Decided by ticket #2338 on 2026-08-19 and declared, not repaired — and the point is that this is not a divergence: it is exactly .NET's behaviour in invariant globalization mode, which says so in its own comment ("In Invariant mode we assume all characters are normalized because we don't support any linguistic operations on strings", Normalization.cs:11-40). .NET has no normalization tables of its own — it delegates to ICU on Unix and NLS on Windows, and CharUnicodeInfoData.cs, the source of record SA-4 names, carries zero decomposition, combining-class, composition-exclusion or quick-check data. Two alternatives were offered and declined: own UCD tables plus a UAX #15 implementation (size L-to-XL, and a second Unicode version to keep in step with SA-4's 16.0), and an ICU dependency, which is the shape this list already declines for cryptography — "a large new external dependency" — so taking it would reverse a standing decision rather than make a new one. Measured: zero call sites in cna and in mobile-eggbert; the only in-repository uses are the type's own tests. What a caller must read into it: true means this runtime performs no linguistic normalization, not that the string is in the requested form. The form is validated on every platform, because CheckNormalizationForm runs before the invariant shortcut (#2386). Pinned by StringNormalizationTests.Decl2338_*.
  • tzdata rule structuresTimeZoneInfo::GetAdjustmentRules() returns an empty array and HasSameRules() therefore cannot distinguish two zones that share a base offset and a DST flag (America/New_York and America/Havana report as same-rule zones where .NET reports false). Decided by ticket #2185 on 2026-08-19 and declared, not repaired, on two measurements. It is not closable by sampling libc at any granularity — two zones can agree on every sampled instant and still differ in rule, so no finer sampling turns offsets into rules; closing it needs tzdata's own structures (the TZif POSIX-TZ footer, or the full transition table with its per-era type records), i.e. a TZif reader, which is out of scope for this port. And the failure is one-directional: this method can only ever be too permissive, never too strict, so a caller using it as a necessary condition is correct and one using it as a sufficient condition is not. The layout cost an earlier design measured (sizeof(TimeZoneInfo) 160 → 184) is real but is not what blocks it and is not paid. Pinned by TimeZoneInfoTests.Decl2185_*.
  • Symmetric/asymmetric cryptography, X.509 certificates, TLS (System.Security.Cryptography's Aes*/RSA*/EC*/ChaCha20Poly1305/CryptoStream, System.Security.Cryptography.X509Certificates, System.Net.Security's SslStream and friends) — out of scope by explicit decision (2026-07-07): implementing this correctly needs either a large new external dependency (OpenSSL/mbedTLS) or a hand-rolled, security-critical implementation, neither of which is worth it for game code. Hash algorithms (MD5/SHA*/HMAC/PBKDF2 — no key material, no confidentiality guarantees to get wrong) are already ported and remain in scope; they are not affected by this deviation.

When a method cannot be meaningfully implemented (e.g. it requires reflection), it should throw System::NotImplementedException with a comment explaining why — never silently return a wrong value.


Porting checklist — criteria for ported / ✅ DONE

A type may be marked ported only when all of the following hold:

1. Implementation complete

  • A header under the owning module's include/System/.../*.hpp exists with the full public API: all public methods, constructors, properties, and operators that appear in the .NET ref/ surface file.
  • Properties follow getXxxProperty() / setXxxProperty() naming.
  • Complex types have a .cpp body file; simple types may be header-only.
  • No method body is a bare throw NotImplementedException() stub — those are STUB, not ported.

2. Correct C++ mapping

  • SharpRuntime::intcs (not int) for public API parameters that mirror .NET int.
  • Namespace opened with C++17 nested syntax: namespace System::Collections::Generic {.
  • No LINQ — use std::ranges instead.
  • POSIX-only internals are in .cpp files behind #ifdef, not in public .hpp headers.

3. Doc-comments

  • Doxygen /** */ block comments on every public type and method.
  • Comments copied/adapted from .NET XML doc-comments in /rv/tmp/runtime/src/libraries/ where available.

4. SPDX header

Every .hpp and .cpp file starts with:

// SPDX-License-Identifier: MIT
// Copyright (c) Robert Vokac and contributors
// Portions based on .NET runtime API (MIT License, Copyright .NET Foundation and Contributors)

5. Logic parity with .NET reference

  • Compare the C++ implementation in sharp-runtime against the reference C# source in /rv/tmp/runtime/src/libraries/.
  • All non-trivial method bodies must match the .NET logic (algorithm, edge-case handling, error conditions).
  • Verify that default messages, constants, and HResult/error codes match the .NET source where applicable.
  • Discrepancies must be either fixed or explicitly documented as intentional deviations.

6. Clean build

  • cmake --build build --parallel 2zero errors, zero warnings.

7. Tests passing

  • scripts/run_component_tests.sh build — all component and integration tests pass exactly once (no failures, no crashes).
  • At least basic GoogleTest coverage exists for the ported type's key methods.

Architecture invariants

  • Complex types: .hpp declaration + .cpp body. Move bodies to .cpp when a header grows unwieldy.
  • Simple types: header-only is fine.
  • CMake: component-specific CONFIGURE_DEPENDS globs discover modules/*/{src,tests}/*.cpp; scripts/validate_module_boundaries.py validates every implementation/header owner and public/private/test dependency. Every module declares its include root, sources, tests, dependencies, and platform setup in modules/<module>/CMakeLists.txt.
  • Component boundaries: internal code depends on narrow physical targets (Core.Base, Collections.Core, etc.), never the Core, Collections, or All compatibility umbrellas. Public-header edges are PUBLIC_DEPENDENCIES, implementation-only edges are PRIVATE_DEPENDENCIES, and test-only edges are TEST_DEPENDENCIES. BlockingCollection<T> belongs to Collections.Blocking; do not add its Threading requirements back to Collections.Core or weaken the Text.Json isolation fixture.
  • Vendored libs: GoogleTest, nlohmann/json, tinyxml2, miniz, all under vendor/. Never commit binaries. Files under vendor/ are third-party source unmodified from upstream and are exempt from this project's SPDX-header, doc-comment, and getXxxProperty()/namespace-syntax naming rules — those rules apply only to module include/, src/, and tests/ trees.
  • Templates: deferred inline definitions after forward declarations to resolve circular includes.
  • Collection mutation counters: a collection with a fail-fast enumerator must hold its counter as System::Collections::detail::MutationCounter and its enumerator must snapshot detail::MutationVersion (System/Collections/detail/MutationCounter.hpp). Never a bare intcs++ on a signed counter is undefined behaviour at INTCS_MAX, and the implicitly declared assignment operator would transplant the source's counter into the destination, leaving an enumerator apparently valid over storage the assignment destroyed. Both defects existed in fourteen collections and are recorded with reproductions in docs/CollectionVersionCounterSweep.md. detail::NarrowMutationCounter has no user left and must not gain one. It was the 32-bit counter for the two types whose measured layout had no room for eight bytes; ticket #1788 moved LinkedList<T> off it (growing sizeof(LinkedList<T>) 40 → 48) and ticket #1789 moved BitArray off it (growing sizeof(BitArray::Enumerator) 32 → 40), each under its own explicit user approval, so no collection retains a 2^32 enumerator-snapshot ABA horizon. The alias survives only as history and as the second instantiation the counter tests pin. SortedSet<T> keeps its own ulongcs counter inside the shared State its live views co-own (ticket #1786) — do not migrate it. When a collection's counter is widened, its enumerator's snapshot must be widened in the same change: a narrow snapshot compared against a wide counter is a silent truncation that leaves the alias in place while the code claims otherwise.
  • Test-only access seams: a class template that a production header declares inside namespace SharpRuntime::Testing and never defines (CollectionVersionAccess, SortedSetVersionAccess) may be defined in exactly one file, and every suite that needs it must include that file. For the collection mutation counters that file is modules/collections/tests/support/CollectionVersionSeam.hpp; add a new collection there, once, through its SHARP_RUNTIME_COLLECTION_VERSION_SEAM macro. Never write template<> struct CollectionVersionAccess<…> { … } in a test translation unit. Five suites did, in two divergent families, and two token-different definitions of one class in one program is a one-definition-rule violation that is ill-formed with no diagnostic required: measured on 2026-07-29, swapping two object files on the link line changed the answer a correctly written suite got, and ld, -flto -Wodr, ASan with detect_odr_violation=2 and UBSan all said nothing (docs/CollectionVersionTestSeamDesign.md, ticket #1800). scripts/check_version_seam_odr.py enforces this and runs in scripts/local_ci_check.sh; never define a seam in modules/*/include or modules/*/src, because that would make it reachable from a consumer and break the consumer-side fixture that pins it — test/consumer/collections_mutation_version_negative.cpp for CollectionVersionAccess (2 sites, #1787/#1801) and test/consumer/collections_sorted_set_version_negative.cpp for SortedSetVersionAccess (15 sites, #1803). Every seam needs both checks — they catch different mutations. Ticket #1804 (2026-07-30) closed one earlier gap in the seam checker: giving a seam's primary template a body in a public header used to make it stop being discovered as a seam, so check_version_seam_odr.py exited 0 while one of two seams silently vanished; the checker now surfaces a defined primary and rejects it as a seam defined in a production tree (docs/CollectionVersionTestSeamDesign.md §15). The consumer fixture is still required for a mutation the checker cannot see — making a collection's private counter public — which is caught only by compilation (docs/NegativeConsumerFixtureValidation.md §18.4 row four). A seam added by a future ticket must therefore gain a test/consumer/*_negative.cpp site too, not only a single definition site. Note also what neither check can express: a consumer that reopens namespace SharpRuntime::Testing and writes its own explicit specialisation does get the access the friend declaration grants, for both seams; that is well-formed ISO C++, is unsupported, and is recorded in §18.5 rather than assumed away.
  • Negative consumer fixtures: a test/consumer/*_negative.cpp proves that a spelling a ticket outlawed is rejected by the compiler. It must carry a // NEGATIVE-FIXTURE: component=<Component> directive, an #ifndef SHARP_RUNTIME_NEGATIVE_SITE / #define … 0 / #endif prelude, and one #if SHARP_RUNTIME_NEGATIVE_SITE == N guard per negative site, each holding exactly one // NEGATIVE(<kebab-id>): <expected diagnostic fragment> marker (further alternatives on following // | <fragment> lines). Site numbers must be 1..N; the #else branch is where the migrated spelling goes. With no site selected the file must compile with zero diagnostics — that clean baseline is what lets a per-site verdict be attributed to its own source, so add (void)x; wherever disabling a site orphans a local. scripts/check_negative_consumer_fixtures.py compiles the baseline plus each site separately (-fsyntax-only, -Wall -Wextra -Wpedantic -Werror, at most two jobs, include directories derived from the CMake component metadata) and runs in scripts/local_ci_check.sh. Never assert only that a whole fixture fails to compile: one broken line hides every other line, and a whole-file check reported a false pass while one of eleven claims had silently become legal again (docs/NegativeConsumerFixtureValidation.md, ticket #1801).

plan.sqlite3 namespace review workflow

plan.sqlite3 (table task) tracks indexed .NET types from dotnet/runtime. Rows started with an empty status; the current maintainer snapshot is fully classified. Full workflow detail lives in prompt.md — this is the summary:

  1. For each type where status is '' or todo (System-namespace types first), look up what it does in /rv/tmp/runtime/src/libraries/ and classify it without asking the user:
    • Port it → check if the file exists in sharp-runtime, review against the full checklist, port or fix, then set status = 'ported' and commit.
    • Out of scope / irrelevant → set status = 'ignore', and set outofscope = 1 for permanent-deviation categories (reflection, GC internals, P/Invoke, serialization infra, etc.) or outofscope = 0 otherwise.
    • Genuinely ambiguous → set status = 'tobedecided' rather than guessing; the user reviews these by hand later.
  2. Keep processing items back-to-back — do not stop between items to ask for confirmation.

Valid statuses written by the current workflow are '' (unset), todo, ported, ignore, and tobedecided. The database also contains legacy ignored rows; treat them as classified and do not rename them mechanically. in_progress does not exist — porting happens directly with no intermediate state.

State lives in plan.sqlite3 + git history, not conversation memory, so this process resumes cleanly after any context reset — just re-open prompt.md and continue from Step 1.


Useful commands

# Build
cmake --build build --parallel 2

# Run all tests
scripts/run_component_tests.sh build

# Errors/warnings only
cmake --build build --parallel 2 2>&1 | grep -E "error:|warning:" | grep -v "^#"

# Run a specific suite
./build/SharpRuntimeTests_Net_Sockets --gtest_filter="TcpClient*"