Skip to content

Commit 94fefdc

Browse files
committed
feat(numerics,uri,text-json,time-zone,xml-linq,threading): six needs_user tickets answered and landed (#2170, #2390, #2391, #2118, #2185, #1899)
All six were `needs_user`; all six were answered on 2026-08-19 and are written up as SA-12 and SA-13 in docs/StandingApprovals.md. Two 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 -- TotalOrderIeee754Comparer gains the IEqualityComparer<T> base, 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, subobject at offset 8, pinned as an OFFSET because a bare sizeof does not discriminate a single base that merely grew. The growth has no .NET counterpart -- .NET's comparer is a readonly struct and a C# struct pays nothing to implement an interface. IEquatable<> deliberately not reproduced. Module graph unchanged at 41/93. Five mutations, all caught. A divergence the ticket did not name was found and deliberately NOT bundled: GetHashCode hashes the bit pattern where .NET hashes the value, filed as #2392. #2390 -- SA-12, a GENERAL RULE rather than a decision about one type: where this port has a real creator, `internal` members become private with that creator a friend; where it has none, they stay public and the divergence is recorded in the header. Both mechanical alternatives declined for stated reasons. #2391 -- UriBuilder::Equals/GetHashCode delegate to the built Uri. #2004's non-throwing guarantee is WITHDRAWN knowingly. It is a convergence rather than a reversal: #2004's own justification had already become false when #1995 made Uri::GetHashCode hash a canonical identity key. Five mutations, all caught -- M1 only after the test meant to catch it was rewritten, and probing the premise instead of trusting it uncovered #2393. #2118, #2185, #1899 change no production statement -- decisions and their evidence, the shape of #2202/#2015/#2324. #2118 declares GetRawText's re-rendering (nlohmann retains no source spans). #2185 makes SR-AUD-228 a PERMANENT deviation on two measurements. #1899 closes carrying its own impossibility proof, with both premises pinned. Gate: 17,543 run, 17,543 passed, 0 failed, 0 skipped across 38 executables (+10). Build directory: build/ only, --parallel 2 throughout. No script needed special handling.
1 parent 6142815 commit 94fefdc

18 files changed

Lines changed: 947 additions & 155 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
# Declaration — `JsonElement::GetRawText()` re-renders and will not preserve source text (#2118)
3+
4+
Ticket **#2118** (SR-AUD-325, cause TJ-D), decided 2026-08-19. **It changes no production
5+
statement**: it is a decision and its evidence, the shape of #2202, #2015 and #2324.
6+
7+
## The contract, and what this port does instead
8+
9+
.NET's `JsonElement.GetRawText()` is `_parent.GetRawValueAsString(_idx)`
10+
(`JsonElement.cs:1196-1201`), which slices the **original document bytes** and transcodes them
11+
(`JsonDocument.cs:700-704`). Nothing is re-rendered.
12+
13+
This port holds a *parsed* `nlohmann` tree. **`nlohmann`'s DOM retains no source spans at all**, so
14+
`dump()` can only re-render from the parsed value:
15+
16+
| source | .NET returns | this port returns |
17+
|---|---|---|
18+
| `1e+01` | `1e+01` | `10.0` |
19+
| `1.10` | `1.10` | `1.1` |
20+
| `"a"` | `"a"` | `"a"` |
21+
| `{ "a" : 1 }` | `{ "a" : 1 }` | `{"a":1}` |
22+
23+
## Why declared rather than repaired
24+
25+
Honouring the contract means `JsonDocument` retaining the original text **and every `JsonElement`
26+
carrying an offset and length into it** — an object-layout change to *both* types, and a parse-time
27+
and memory cost paid by **every** caller whether or not `GetRawText` is ever called. That was
28+
offered and **declined**; #2117 had already grown `JsonElement` 48 → 56 in the same session, and
29+
this would have grown it again to buy a member most callers never touch.
30+
31+
Note what the gate was *not*: the `/rv RE-VERIFIED ABSENT` line in the ticket's notes was stale, and
32+
the reference **confirms** the divergence rather than resolving it. `/rv` was never this ticket's
33+
gate; the substrate was.
34+
35+
## What is lost, precisely
36+
37+
The **representation**, never the **value**. `GetRawText` always returns valid JSON that parses back
38+
to an equal element, and re-rendering is **idempotent** — a second pass changes nothing. A document
39+
already in the renderer's canonical form round-trips byte for byte. A caller using `GetRawText` as a
40+
value carrier is unaffected; only one reading it as *source text* is.
41+
42+
## Pins
43+
44+
`JsonGatedBehaviourPins.PIN2118GetRawTextReRendersRatherThanReturningSourceText` was **renamed and
45+
re-roled** as `Decl2118_GetRawTextReRendersRatherThanReturningSourceText`: it was a *gated* pin ("a
46+
defect knowingly still present") and is now a *declaration*. Each row additionally records what .NET
47+
would have returned, so the gap is explicit rather than implied, and the test fails the moment the
48+
limitation lifts. `Decl2118_TheValueSurvivesEvenThoughTheRepresentationDoesNot` pins the half that
49+
makes the limitation tolerable.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
# Migration — `TotalOrderIeee754Comparer<T>` implements `IEqualityComparer<T>` (#2170)
3+
4+
Ticket **#2170** (SR-AUD-042 remainder), landed 2026-08-19 under a per-action approval.
5+
6+
## What changed
7+
8+
`System::Numerics::TotalOrderIeee754Comparer<T>` — all three specializations (`float`, `double`,
9+
`System::Half`) — now derives from `System::Collections::Generic::IEqualityComparer<T>` in addition
10+
to `IComparer<T>`. `Equals` and `GetHashCode` became `override`s of that interface.
11+
12+
## Why it needed an ask, and what it costs
13+
14+
`sizeof` grows **8 → 16** on every specialization, with the `IEqualityComparer<T>` subobject at
15+
**offset 8** — a second vptr. `alignof` stays 8.
16+
17+
This is a **silent binary break**: nothing fails to compile, but a consumer that was built against
18+
the 8-byte type and links against the 16-byte one has a layout mismatch. **Every consumer must
19+
rebuild.** `docs/StandingApprovals.md` SA-3 and SA-8 both say in terms that a vtable or base-class
20+
change still asks per action; SA-10 covers signature changes and does not reach a base class. The
21+
approval was granted on 2026-08-19 on this measurement:
22+
23+
* header-only and stateless;
24+
* **zero** users in this repository outside its own test file;
25+
* **zero** sites in `cna` and **zero** in `mobile-eggbert`;
26+
* precedent: #1788 (`LinkedList<T>` 40 → 48) and #1789 (`BitArray::Enumerator` 32 → 40) were each
27+
granted the same way.
28+
29+
## What a caller gains
30+
31+
Exactly one thing, and it is the thing #2169 could not deliver: **polymorphic binding.** A
32+
specialization can now be passed where `const IEqualityComparer<T>&` is required. Before this
33+
change that was a *compile error*, not a wrong answer — so no existing call site can have depended
34+
on the old behaviour.
35+
36+
`Compare`, `Equals` and `GetHashCode` all return exactly what they returned before. No answer moved.
37+
38+
## Three things the reference settles, recorded because they are easy to get wrong
39+
40+
1. **The growth has no .NET counterpart.** .NET's comparer is a `readonly struct`
41+
(`TotalOrderIeee754Comparer.cs:16`), and implementing an interface costs a C# struct no storage
42+
at all. .NET pays nothing for the same surface. In C++ polymorphic binding requires a base class
43+
and a base class with virtual members requires a vtable pointer. The 8 → 16 is a C++ artifact.
44+
45+
2. **`IEquatable<TotalOrderIeee754Comparer<T>>` is deliberately not reproduced.** .NET implements it
46+
as `Equals(TotalOrderIeee754Comparer<T> other) => true` (`:204`) — every instance of a stateless
47+
comparer equals every other, which a defaulted `operator==` on an empty C++ type already says.
48+
A third base would cost a third vptr to express nothing new. Pinned absent by
49+
`Decl2170_IEquatableIsNotReproduced`.
50+
51+
3. **`GetHashCode` diverges from .NET, and #2170 deliberately does not close it.** See below.
52+
53+
## Known divergence carried forward, not introduced
54+
55+
.NET's `GetHashCode(T obj)` is `obj.GetHashCode()` (`TotalOrderIeee754Comparer.cs:198-202`) — the
56+
*value's own* hash. `Double.GetHashCode` normalizes so that, in its own comment, "all NaNs and both
57+
zeros have the same hash code", and **this port's `Double::GetHashCode` already matches .NET exactly**,
58+
normalization included. This port's comparer instead hashes the **bit pattern**, so:
59+
60+
| | .NET | this port |
61+
|---|---|---|
62+
| `GetHashCode(-0.0) == GetHashCode(+0.0)` | `true` | `false` |
63+
| two distinct NaN payloads hash equal | `true` | `false` |
64+
65+
Both satisfy the hash contract — equality here *is* bit-pattern identity, so equal always implies
66+
equal hash — and .NET's is simply coarser. But the difference is **directly observable** through a
67+
public member. It is filed as its own ticket rather than folded into #2170 because closing it would
68+
invert **five shipped pins** (`Float_SignedZerosAreDistinct`, `Float_NaNPayloadsAreDistinguished`,
69+
`Double_EqualityAndHash`, `Double_HashFoldsBothHalvesRatherThanTruncating`, `Half_EqualityAndHash`),
70+
which is a behaviour decision of its own and not a consequence of adding a base class.
71+
72+
Mutation **M4** — adopting .NET's value hash — is caught by `Float_SignedZerosAreDistinct`, which is
73+
the evidence that those pins are load-bearing rather than incidental.
74+
75+
## Module graph
76+
77+
**Unchanged at 41 modules / 93 edges.** `IEqualityComparer<T>` lives in
78+
`modules/core/include/System/Collections/Generic/`, i.e. `Core.Base`, which `Numerics` already
79+
listed in `PUBLIC_DEPENDENCIES`. No new public component edge was needed.
80+
81+
## Mutation testing
82+
83+
Five mutations, **all caught**:
84+
85+
| # | Mutation | Caught by |
86+
|---|---|---|
87+
| M1 | drop the `IEqualityComparer` base from the `double` specialization | compile error (the polymorphic-binding test cannot bind) |
88+
| M2 | swap the two bases' order | `Fix2170_TheSecondBaseCostsExactlyOneVptr` — the offset-8 assertion |
89+
| M3 | give `Equals` a by-value signature, making it an overload rather than an override | compile error — the class stays abstract |
90+
| M4 | adopt .NET's value hash | `Float_SignedZerosAreDistinct` |
91+
| M5 | invert `Equals` | `Fix2170_BindsPolymorphicallyAsIEqualityComparer` + two pre-existing cases |
92+
93+
M2 is the one worth keeping: a bare `sizeof == 16` passes against it, because a single base that
94+
merely grew would give the same number. Only the subobject offset discriminates.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
# Migration — `UriBuilder::Equals`/`GetHashCode` delegate to the built `Uri` (#2391)
3+
4+
Ticket **#2391**, landed 2026-08-19 on an explicit user decision (`docs/StandingApprovals.md` SA-13).
5+
6+
## What changed
7+
8+
```cpp
9+
// before (#2004)
10+
bool Equals(const UriBuilder& other) const { return ToString() == other.ToString(); }
11+
intcs GetHashCode() const { return std::hash<std::string>{}(ToString()); }
12+
13+
// after (#2391) — .NET's UriBuilder.cs:277-279
14+
bool Equals(const UriBuilder& other) const; // via the built Uri, comparand parsed as a string
15+
intcs GetHashCode() const; // getUriProperty().GetHashCode()
16+
```
17+
18+
## Two behaviour changes, in opposite directions
19+
20+
**1. Identity became canonical (a widening of equality).** `UriBuilder` now compares through
21+
`Uri`, which has had canonical identity since #1995 — folded scheme, folded host, resolved default
22+
port, path and query, with fragment and user-info excluded. So these pairs, formerly **unequal**,
23+
are now **equal**:
24+
25+
| | |
26+
|---|---|
27+
| `Host = "example.com"` vs `Host = "EXAMPLE.COM"` | host case is folded |
28+
| `Host = "example.com"` vs the same with `Port = 80` | an explicit default port resolves away |
29+
30+
Identity is canonical, not blind: a different host, or a non-default port, is still unequal.
31+
32+
**2. `Equals` and `GetHashCode` can throw again (a narrowing of totality).** **This is the cost, it
33+
was stated before the decision was taken, and it must not be quietly softened later.** For a builder
34+
whose rendering does not parse, both members now throw `UriFormatException` — **including
35+
`b.Equals(b)`**. Two measured routes survive, both ordinary setters:
36+
37+
* `setHostProperty("[::1")` — an unterminated IP literal, which #1996 G-1 deliberately does not wrap;
38+
* `setHostProperty("")` — an empty host (#2000).
39+
40+
(`setHostProperty("h:abc")` and `("h:99999")` left this set with #1996 G-1: they are bracketed now
41+
and parse.)
42+
43+
### Why that is not simply re-breaking what #2004 fixed
44+
45+
#2004 removed the delegation for a real reason: the pair disagreed at the worst possible place,
46+
`b.Equals(b)` returning `true` while `b.GetHashCode()` **threw**, leaving an object that compared
47+
equal to itself with no obtainable hash. Its repair made *hashing* stop parsing. #2391 closes the
48+
same gap **from the other side**: both members now go through the built `Uri`, so they are total on
49+
exactly the same set of objects and `Equals ⇒ equal hash` holds across the whole of it. What changed
50+
is *which* set — the parseable builders rather than all of them.
51+
52+
### And #2004's own justification had already become false
53+
54+
#2004 argued the string hash was *value-identical* to delegating, because "`Uri::parse` assigns
55+
`absoluteUri_ = uriString` on every branch it accepts, and `Uri::GetHashCode` hashes exactly that".
56+
**#1995 made `Uri::GetHashCode` hash the canonical identity key instead**, earlier in the same
57+
session, which silently falsified that argument. Keeping #2004 would have meant a builder and the
58+
`Uri` it builds hashing **differently** — precisely the defect #2004 existed to prevent, one level
59+
up. So this is a convergence, not a reversal.
60+
61+
## The asymmetry is .NET's and is deliberate
62+
63+
`UriBuilder.Equals(object)` is `rparam is not null && Uri.Equals(rparam.ToString())`:
64+
65+
* **this** builder goes through the `Uri` property, so an unparseable **self throws**;
66+
* the **other** is handed over as a **string**, and `Uri.Equals(string)` runs
67+
`TryCreate(s, UriKind.RelativeOrAbsolute, out _)` and **returns false** when that fails.
68+
69+
So an unparseable *other* is merely unequal. Reproducing only one half would be tidier and would not
70+
be .NET. Pinned by `Fix2391_TheComparandIsAStringSoAnUnparseableOTHERIsMerelyUnequal`.
71+
72+
Note `RelativeOrAbsolute`, **not** `Absolute` — the opposite of what #1997 A-3 chose for the
73+
*creation* overloads. The two questions have different answers.
74+
75+
## Tests
76+
77+
Four shipped pins were **inverted, not deleted** — each had been written anticipating exactly this:
78+
79+
| Pin | Was | Now |
80+
|---|---|---|
81+
| `HashIsObtainableWhereverEqualsSucceeds_OtherUnparseableRenderings` | both members answer | `Fix2391_AnUnparseableBuilderNowThrowsFromBothMembers` |
82+
| `Decl1995_BuilderHashNoLongerMatchesTheBuiltUrisHash` | the two hashes differ | `Fix2391_BuilderHashIsTheBuiltUrisHash` — they agree by construction |
83+
| `DeliberatelyUnequalPairsStayUnequal_PinsTheGatedIdentityChange` | the pairs are unequal | `Fix2391_TheseFormerlyUnequalPairsAreNowEqual` |
84+
| `EqualsImpliesEqualHashAcrossEveryEqualityClass` | included two unparseable shapes | domain shrank; the exclusion is asserted in place |
85+
86+
`HashIsObtainableWhereverEqualsSucceeds_MalformedPort` needed no change: `"h:abc"` parses since
87+
#1996 G-1.
88+
89+
## Mutation testing
90+
91+
Five mutations, **all caught** — but M1 only after the test that was supposed to catch it was
92+
rewritten, and the reason is worth keeping:
93+
94+
| # | Mutation | Caught by |
95+
|---|---|---|
96+
| M1 | comparand kind `Absolute` instead of `RelativeOrAbsolute` | `Fix2391_TheComparandKindIsRelativeOrAbsoluteAndItIsLoadBearing` |
97+
| M2 | build the other side too, so both throw | `Fix2391_TheComparandIsAStringSoAnUnparseableOTHERIsMerelyUnequal` |
98+
| M3 | revert `GetHashCode` to #2004's string hash | four cases |
99+
| M4 | revert `Equals` to #2004's text compare | three cases |
100+
| M5 | an unparseable other returns `true` | the asymmetry pin |
101+
102+
**M1 was first recorded as an unobservable equivalence, and that record was wrong.** The reasoning
103+
was that `getUriProperty()` is always absolute, so a relative comparand can never be equal and both
104+
spellings return `false`. Probing the premise instead of trusting it produced a measurement that
105+
breaks it: **this port's `Uri(std::string)` constructor accepts `"://example.com/"` while its own
106+
`TryCreate(s, UriKind::Absolute)` rejects it.** So `self` can be a `Uri` the strict comparand parse
107+
would refuse, and then `emptyScheme.Equals(emptyScheme)` is `true` under the reference spelling and
108+
`false` under the mutation — a builder unequal to itself. That is now the discriminating assertion.
109+
110+
The two-grammar inconsistency itself is out of scope here and is filed as **#2393**.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<!-- SPDX-License-Identifier: MIT -->
2+
# Declaration — Xml.Linq borrowed views stay a documented contract (#1899 closed)
3+
4+
Ticket **#1899** (SR-AUD-333, CCF-019, probe cases X15/X17), closed 2026-08-19. **It changes no
5+
production statement.**
6+
7+
## What #1899 wanted, and why it cannot be had
8+
9+
#1898 made the borrowed-view contract explicit and testable. #1899 would have made violating it
10+
*impossible*. Its design was completed, and it **proves the original requirement impossible** — for
11+
two independent reasons, either of which is sufficient on its own:
12+
13+
1. **An owning handle to an object no `shared_ptr` owns cannot be manufactured.** The topmost
14+
ancestor `Ancestors()` yields has no parent, so there is nothing inside the tree holding a share
15+
to hand out.
16+
2. **`XElement`, `XDocument` and `XContainer` are routinely automatic-storage objects** — 51 such
17+
declarations in this repository's own tests alone. They have no control block, so
18+
`std::enable_shared_from_this` would throw `std::bad_weak_ptr` rather than rescue the caller. It
19+
would convert a latent use-after-free into a **guaranteed throw at a correct call site**.
20+
21+
## The alternative, offered and declined
22+
23+
Changing `Ancestors`/`AncestorsAndSelf` to return a non-owning view type that cannot outlive the
24+
tree — a public source break under SA-2/SA-10. Declined on 2026-08-19
25+
(`docs/StandingApprovals.md` SA-13). The contract stays as #1898 left it: stated as preconditions,
26+
postconditions, invalidation and failure behaviour, and pinned.
27+
28+
## Where the proof lives, and why there
29+
30+
In `modules/xml-linq/tests/System/Xml/Linq/XLinqBorrowedViewTests.cpp`, **beside the contract it
31+
justifies** — not only in the ticket. A future reader who sees "documented, not enforced" should not
32+
have to assume nobody tried.
33+
34+
`Decl1899_AutomaticStorageNodesHaveNoControlBlockToShareFrom` and
35+
`Decl1899_TheTopmostAncestorHasNoParentToOwnIt` pin the two **premises**, because a proof whose
36+
premises silently stop being true is not a proof. They fail the day either premise does, which is
37+
exactly when #1899 would be worth reopening.

0 commit comments

Comments
 (0)