Repository navigation
fix(certification): emit one canonical decimal form for every double, and gate it in CI - #592
Merged
IanFrelinger merged 7 commits intoSep 11, 2026
Conversation
…on every target The canonical payload is the message every certification signature is computed over, so the same record must produce the same bytes wherever it is signed or checked. The decimal form of a double did not: WriteNumber(double) delegates it to whichever formatter the target ships, and the three targets Ashlar.Certification.Contracts builds for do not agree. Measured through BuildPayload in the devtest container. The netstandard2.0 asset under Mono 6.12 writes 1/3 as 0.33333333333333331, 0.007 as 0.0070000000000000001 and 1e-7 as 9.9999999999999995E-08, where net8.0 and net10.0 - byte-identical to each other on all 18 values probed - write 0.3333333333333333, 0.007 and 1E-07. Negative zero diverges the other way: net8.0/net10.0 emit -0 and the ns2.0 asset emits 0, because Mono's formatter drops the sign under "R", "G17" and the default alike. Since #589 the payload is written rather than serialized, so the form is a local decision. It is now made: 17 significant digits on the invariant culture, through WriteRawValue with input validation left on, in both the brick emitter and the composition emitter. G17 because round-trip fidelity is not negotiable for bytes that back a signature and 17 digits is the width at which a binary64 always survives the round trip - a width, not a heuristic. All 22 values probed formatted to character-identical text and re-parsed to the identical bit pattern on all three targets. "R" is disqualified on measurement: Mono's "R" is not .NET's (1/3, 1e16 and double.Epsilon all differ), so it would leave the divergence it was meant to close. A fixed format is disqualified on fidelity: F17 of double.Epsilon is a string of zeros. A shortest-round-trip ladder was measured and works, but makes the bytes depend on the parser at every precision as well as the formatter. Both zeros collapse onto 0 deliberately. Forcing -0 makes the writers agree and leaves the readers disagreeing: under Mono both double.Parse("-0") and GetDouble() on {"x":-0} return +0.0, so a record file written with -0 is re-emitted as 0 by an ns2.0 consumer and the two legs compute different bytes from the same record. Collapsing gives up the sign bit of a zero alone (-0.0 == 0.0) and is stable through emit, read and re-emit everywhere. NaN and the infinities are refused as CanonicalPayloadException rather than left to throw ArgumentException out of Utf8JsonWriter, which VerifySignature does not catch and so threw into its host; the trust verifier now reports payload-not-canonical rather than the catch-all payload-unbuildable. No record in this tree changes bytes. All three signing sites reach the signer with escapeRate zero or null, no production code populates Attempts, and the three committed signed records carry "escapeRate": 0 and keep their signatures. The corpora keep every pinned payload, byte length and SHA-256 - G17 of 0, 0.25 and 0.5 is 0, 0.25 and 0.5 on every target - and gain four brick cases and two composition cases carrying the values that were not exactly representable. Verified: 618 certification tests green on net8.0 and net10.0; all nine brick payloads byte-identical and SHA-256-identical on the shipped netstandard2.0 asset under Mono 6.12, and under PublishTrimmed partial, PublishTrimmed full, full with reflection re-enabled and PublishAot on net10.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and make it refuse #589 added scripts/portability/trim-aot-canonical-bytes-probe.sh and nothing in CI ran it. `dotnet build` exercises one of the ways a host may publish, and the canonical signing payload and the certified transition entry hash have to be the same bytes under the others - which is why both producers are written field by field rather than serialized. That claim was a local measurement. It is now the trim-aot-canonical-bytes job in the existing Runtime Portability Gate: a four-configuration matrix (trim-partial, trim-full, trim-full-reflection-on, aot) with fail-fast off, each publishing the minimal certification consumer self-contained linux-x64 on net10.0 and executing the published binary against the same two corpora the xunit golden suites read. One job per mode so a single-mode regression names itself in the check list. No second workflow: this is a sibling job with no `needs`, matching the conventions of the net9 lane beside it. The probe gained a negative half first, because promoting it as it stood would have promoted a weak gate: byte equality alone passes against a build whose verifier had been trimmed into unconditional agreement. Each published binary must now also refuse a signature altered by one bit, a signature over other bytes, a content hash that no longer binds, a schema version that selects no payload lane, a proposer supplying one parameter key twice, and each of NaN and the two infinities - and the byte comparison is fed a deliberately mutated expectation so the comparison itself is shown to be live. The empty-corpus guard extends to the new block. Every check line is teed to one log so the job summary re-reads results rather than re-deriving them, and an absent log says so rather than rendering an empty table that reads like a pass. Both paths: lists gained src/Ashlar.Certification.State/** and transition-entry-hashes.golden.json. The consumer references the one and reads the other, and neither was filtered in, so a change to the transition builder or its corpus would not have fired the workflow - the lane would have looked healthy by never running. Advisory, deliberately: not added to required_status_checks, and no continue-on-error anywhere. The step comment repeats why the publish properties live inside the generated consumer csproj and must never become `-p:` switches. Verified in the devtest container on all four configurations: 9 canonical payloads byte-identical, 3 transition entry hashes identical, 12 refusals answered, 0 trim/AOT analysis warnings each; the summary step rendered against a real probe log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trim/AOT probe's negative half flips one bit of a valid signature and
requires the published binary to answer signature-invalid. The local held the
altered bytes and was named for a conclusion the check does not draw: the probe
observes that one changed bit is rejected, nothing about intent. `altered` is
what the surrounding check text already says ("signature altered by one bit"),
so the name and the line it prints now agree.
No behaviour change; the buffer, the flip and the expected failure code are
unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not fix 17 significant digits on the invariant culture fixes the WIDTH of a double on every target. It does not fix the rounding of a value that falls exactly halfway between two 17-digit decimals: that tie is still broken by the target's own formatter, and the targets break it in opposite directions. Measured through BuildPayload, 0.500003814697265625 (0.5 + 2^-18, exactly representable, inside escapeRate's documented 0.0-1.0 range) is written 0.50000381469726562 on net8.0 and net10.0 and 0.50000381469726563 on the shipped netstandard2.0 asset under Mono 6.12 - same width, different bytes, and these bytes back a signature. The branch shipped that as "one decimal form on every target" in six places. That reads as a closed property and it is not one, so every place that asserts the parity now says what the width fixes and what it leaves to the formatter: CertificationRecordSigning.WriteNumberOrNull, the composition emitter that copies it, SPEC-006, both corpus _readme strings, CONSUMING.md's netstandard2.0 row and the CHANGELOG. The direction is unchanged and is still a large improvement - 1/3, 0.007, 1e-7, 0.1 and negative zero all diverged before this work and do not now - but a reader computing a signature over an arbitrary double has to know which class is still open. The residual is pinned rather than described. CanonicalDoubleRepresentationTests carries the value and BOTH known forms, asserts they are neighbours at the last digit and nowhere else, and asserts both read back as the identical bit pattern, so the divergence is a byte-parity residual and not a loss of value. It is pinned there rather than in canonical-payloads.golden.json deliberately: a corpus case would turn scripts/ns20-canonical-bytes-probe.sh red over a divergence no producer in this tree can reach, and both _readme strings now say so. Closing the class means deriving the digits from the bit pattern in the emitter instead of asking a formatter for a width; that would move the bytes of every awkward double again, so it is stated rather than slipped in. Three comments that #589 left behind and this branch made false are corrected with it: WritePayload and BuildPayload both still claimed the emitter keeps "whatever decimal form the target produces", and CompositionCanonicalPayload- GoldenTests still sent readers to a corpus _readme for a restriction the corpus no longer has. CONSUMING.md's inserted clause is closed so the row is one sentence again, and the migration header now covers negative zero, which is not a non-zero double and also moves. No canonical bytes change: the emitter is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y printed The lane's green rested on the consumer's exit code alone. The script wrote a log for the job summary and never read it back, so a published binary that returned 0 having checked nothing, or one that printed PROBE FAIL and returned 0 anyway, made the job green - the same hole the net9 lane closed in #587, in a lane that exists precisely because a trimmed or AOT publish can degrade in silence. check_log now requires each run's output to carry PROBE PASS, all three section verdicts, and one OK payload line per case in canonical-payloads.golden .json, counted from the corpus so a corpus that grows without the lane following it fails too; an empty or short log is a failure on its own. Each run is captured to its own ${label}.run.log and then appended to the cumulative log the summary renders, because "one OK line per case" stops meaning anything once four configurations have appended to one file. The per-run logs upload with it. A configuration name matching none of the four used to skip every configuration, leave FAILURES at 0, and exit 0 printing "every configuration emitted the golden bytes" over a zero-byte log - so a typo or a rename in matrix.configuration turned four checks green having published and executed nothing. Verified before the fix: `... probe.sh trim-fulll` exited 0 with an empty log; it now exits 1 naming the four. The count of configurations that actually ran is checked as well, because an unknown name and a label renamed without the list are opposite mistakes and the name check only catches the first. The final line reports how many configurations ran rather than claiming all of them did. Exercised against both mutations that proved the hole: a log with PROBE PASS and no payload lines, and a log saying PROBE FAIL over a zero exit code, are both refused, as are a missing section verdict, a short payload-line count and a non-zero exit with a complete log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IanFrelinger
deleted the
cursor/claude-trim-aot-lane-and-double-parity
branch
September 11, 2026 13:19
IanFrelinger
added a commit
that referenced
this pull request
Sep 12, 2026
…his workflow able to start at all (#596) * ci(portability): run the netstandard2.0 asset under Mono, and make the probe read its own transcript scripts/ns20-canonical-bytes-probe.sh has existed since #585 and nothing in CI has ever called it. netstandard2.0 is the leg that has actually diverged -- twice: the verifier contradictions closed by #585 (4 of 7 ns2.0 verdicts contradicting net8.0) and the decimal form of doubles closed by #592. A probe nothing runs is a measurement nobody has taken. netstandard2.0 is not a runtime, so there is nothing to run tests "on". The new mono-netstandard20-consumer lane does what the script always did: builds the shipped ns2.0 assets of Ashlar.Certification.Contracts and Ashlar.Certification.State, publishes a net472 consumer against them, and EXECUTES that consumer under mono:latest against the same canonical-payloads.golden.json and transition-entry-hashes.golden.json the xunit golden suites read. Five sections: canonical payload bytes, certified transition entry hashes, verifier parity with net8.0, the StateLogVerifier options overload, and unknown schema versions. The script now re-reads its own transcript, in the shape scripts/portability/net9-probe.sh already used. It requires: * a "runtime: Mono " line -- a probe reporting "not Mono" loaded the ns2.0 asset somewhere else and measured a different target entirely; * all five section PASS: lines, by their exact text; * no FAIL lines; * a per-case OK count above a floor, so a truncated transcript is not a pass; * a zero exit from the container. A consumer that exited 0 having checked nothing is a failed run. This repository has already shipped the opposite assumption once, in the first version of the trim/AOT lane. scripts/ns20-canonical-bytes-probe.sh is added to BOTH paths: lists. It does not live under scripts/portability/, and without that line the job would exist and never fire -- which looks exactly like a healthy lane. Measured before wiring, on this machine (build in the devtest container, run under mono:latest): * 32 per-case checks across 5 sections, NS20_PROBE=OK; * with one section's PASS: line suppressed in the probe source, the script fails with "missing PASS line for: verifier verdicts on the netstandard2.0 asset agree with net8.0." and NS20_PROBE=FAIL. Advisory, like its two siblings: path-filtered so it cannot become a required context, and no continue-on-error, so a red run is a real netstandard2.0 regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(ci): two workflow files have never started, and shell-lint now refuses the shape Runtime Portability Gate and Dogfood Continuous Proof are both rejected by GitHub at dispatch. The run is created, marked failed, contains ZERO jobs, and is named after the file path instead of the workflow's own name:. No check run is posted to the pull request, so gh pr checks lists nothing and a PR looks clean. Seven runs each, going back to the day each file was written -- while three CHANGELOG entries described what the portability lanes executed. Two distinct faults. runtime-portability-gate.yml reads the runner context from a JOB-level env: block, in all three jobs. That context is only available from inside a step. The workdirs now come from RUNNER_TEMP through GITHUB_ENV in a first step, which is the documented way to do this. dogfood-continuous-proof.yml passes a multi-line commit message to a single git commit -m whose continuation lines start at column 0. That terminates the enclosing block scalar, so the file has never been valid YAML either. Now repeated -m flags, which git joins with a blank line between them, so the message keeps its paragraphs. The check: scripts/ci/verify-workflow-context-availability.sh parses every workflow file and fails on any runner., steps., job. or env. reference above a step. jobs.<id>.outputs is excluded -- it is evaluated AFTER the steps run, so reading a step output there is both legal and the only way job outputs are ever written. A file it cannot parse at all is also a failure, which is how the second fault surfaced. It runs in shell-lint: unfiltered, required, and the workflow whose own header says it exists so parse-level breakage never has to wait for a weekly or manual gate to surface. A workflow that cannot start is exactly that category, and an unfiltered required check is the only position from which it is visible. PyYAML is deliberately NOT optional there. scripts/rc-gate-tier-e.sh treats it as optional with a fallback; for a gate that is the same "healthy by never running" failure this commit exists to catch, so the script exits non-zero when it is missing and the workflow installs it. Measured: the check finds both faults on master and passes on this branch. The job-outputs exclusion was added after it flagged three legitimate step-output references in full-platform-readiness-gate, reusable-container-publish and reusable-release-nuget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
IanFrelinger
pushed a commit
that referenced
this pull request
Sep 14, 2026
VERSION read 0.1.2 while 0.1.2 was already published on nuget.org, so the version string in this tree no longer identified its content. THE MINOR MOVED RATHER THAN THE PATCH, because signed bytes changed. #592 gave every double one canonical decimal form across all three target frameworks: the netstandard2.0 asset under Mono had been writing different digits from net8.0/net10.0 for the same value, and the canonical payload is the message every certification signature is computed over. Both golden corpora changed with it. #621 compounds it - a host that supplied a brick signer previously got composition records under the committed dev key and now gets them under its own. A record minted under 0.1.2 and the same record minted now are not byte-identical, so 0.1.3 would have told a consumer holding stored signed records that this was a safe patch. Corrections landed in the same pass, each verified against the artefact rather than against a status document: - Section 5.4b's counts - 104 commits past v0.1.2, seven touching src/Ashlar.Certification.Contracts/ - were ACCURATE when recorded on 2026-09-13. A draft of this commit "corrected" them to 37 and 3; that was wrong, measured in a shallow clone whose master stops at 37 commits. In a full clone it is 112 and 7. The body now carries no count, since it changes on every merge, and the note says to re-derive it in a full clone - a shallow checkout also makes `git describe` find no tag at all, which is how the bad measurement looked plausible. - Section 5.4b said scripts/release-preflight-local.sh "is the only thing that inspects both". It is not: release-staging-on-label.yml resolves the canonical version from root VERSION and runs automatically when a PR is labelled, and resolve-canonical-package-version.sh, verify-docs-published-version.sh and tests/uat/tier9.sh read it too. What none of them do is compare VERSION to the latest tag, which is the actual gap the section exists to name. - README's self-extend bullet claimed the operator-governed path has "a post-apply canary that auto-rolls-back a change that fails verification", unconditionally. SelfExtendAdmissionBridge takes IPostApplyVerification as an optional parameter defaulting to null, and rolls back only "when verification is not null". AddCertificationInfrastructure supplies one, so the supported wiring has the canary; a host constructing the bridge by hand and passing nothing gets no canary and no rollback, silently. Scoped to what holds. - Four go-criteria were met and left unticked: security defaults documented, NuGet 0.1.2 published, GHCR images published, GHCR images available. Ticked with one line of evidence each. The GHCR line now also records that nexo-api is amd64-only by design, which a reader would otherwise infer from the multi-arch claim beside it. A no-go item whose own text read "resolved" was still an open checkbox; it is closed. Verified in-container: cert-gate 922/922. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IanFrelinger
added a commit
that referenced
this pull request
Sep 15, 2026
…628) * chore(release): bump VERSION to 0.2.0 and correct the release record VERSION read 0.1.2 while 0.1.2 was already published on nuget.org, so the version string in this tree no longer identified its content. THE MINOR MOVED RATHER THAN THE PATCH, because signed bytes changed. #592 gave every double one canonical decimal form across all three target frameworks: the netstandard2.0 asset under Mono had been writing different digits from net8.0/net10.0 for the same value, and the canonical payload is the message every certification signature is computed over. Both golden corpora changed with it. #621 compounds it - a host that supplied a brick signer previously got composition records under the committed dev key and now gets them under its own. A record minted under 0.1.2 and the same record minted now are not byte-identical, so 0.1.3 would have told a consumer holding stored signed records that this was a safe patch. Corrections landed in the same pass, each verified against the artefact rather than against a status document: - Section 5.4b's counts - 104 commits past v0.1.2, seven touching src/Ashlar.Certification.Contracts/ - were ACCURATE when recorded on 2026-09-13. A draft of this commit "corrected" them to 37 and 3; that was wrong, measured in a shallow clone whose master stops at 37 commits. In a full clone it is 112 and 7. The body now carries no count, since it changes on every merge, and the note says to re-derive it in a full clone - a shallow checkout also makes `git describe` find no tag at all, which is how the bad measurement looked plausible. - Section 5.4b said scripts/release-preflight-local.sh "is the only thing that inspects both". It is not: release-staging-on-label.yml resolves the canonical version from root VERSION and runs automatically when a PR is labelled, and resolve-canonical-package-version.sh, verify-docs-published-version.sh and tests/uat/tier9.sh read it too. What none of them do is compare VERSION to the latest tag, which is the actual gap the section exists to name. - README's self-extend bullet claimed the operator-governed path has "a post-apply canary that auto-rolls-back a change that fails verification", unconditionally. SelfExtendAdmissionBridge takes IPostApplyVerification as an optional parameter defaulting to null, and rolls back only "when verification is not null". AddCertificationInfrastructure supplies one, so the supported wiring has the canary; a host constructing the bridge by hand and passing nothing gets no canary and no rollback, silently. Scoped to what holds. - Four go-criteria were met and left unticked: security defaults documented, NuGet 0.1.2 published, GHCR images published, GHCR images available. Ticked with one line of evidence each. The GHCR line now also records that nexo-api is amd64-only by design, which a reader would otherwise infer from the multi-arch claim beside it. A no-go item whose own text read "resolved" was still an open checkbox; it is closed. Verified in-container: cert-gate 922/922. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(release): warn that VERSION is stale before a tag is cut, and test the guards Two gaps docs/release/RELEASE-READINESS.md named and nothing closed. 1. NOTHING WARNED THAT VERSION WAS STALE. release.yml asserts a tag matches root VERSION, but only at tag time and only when ref_type == 'tag' - by then the tag exists and the guard's only move is to refuse the release. That is the good failure, but it fails late, and it is how 0.1.2 came to sit in the tree while 0.1.2 was already published. scripts/check-version-staleness.sh compares VERSION to the newest v* tag and to nuget.org. It runs from release-preflight-local.sh - the last human checkpoint before a tag - and from rc-gate. Advisory by design: it always exits 0. Whether a tree is "too far" past its tag is a judgement call, and a check that reddens a lane on a judgement call is how lanes get muted in this repository (docs/HowGatesGoQuiet.md). It needs a FULL clone, and says so when it does not have one. A shallow checkout truncates the history, git describe then finds no tag, and the check silently measures nothing - which is not hypothetical: a draft of the VERSION bump in #628 "corrected" this section's commit counts using numbers measured in a shallow clone, where master stops at 37 commits. The rc-gate checkout sets fetch-depth: 0 and fetch-tags for exactly this reason. 2. THE RELEASE GUARDS HAD NO TESTS. scripts/lib/release-staging-guards.sh stands between a mislabelled package and nuget.org, and was only ever exercised as a side effect of a real release - which has run three times in this repository's lifetime. A guard exercised only by the event it guards is indistinguishable from a guard that does nothing. tests/scripts/release-guards.test.sh covers normalize_semver, assert_valid_semver and assert_version_matches_canonical: matching tag exits 0, mismatched exits nonzero AND says "does not match", an ABSENT VERSION file exits 0 with SKIPPED while an EMPTY one refuses, and a bare version still matches a v-prefixed tag. It also pins that the three nuget-visibility scripts raise a short poll budget unless ASHLAR_NUGET_VERIFY_ALLOW_SHORT=1 - nuget.org indexing lags publication, and a short budget reports "not visible" for a package that published fine. 21 assertions, pure bash, no network or dotnet, so they run in shell-lint on every pull request rather than in a monthly lane. Mutation-measured: disabling the mismatch check fails exactly one assertion and leaves 20 passing. shell-lint's bash -n sweep covered scripts/ only, so tests/ shell was not even syntax-checked. Widened to both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(release): complete the release variable table and stop citing workflows by line Section 5.4 is headed "Repository variables / secrets (release workflow)" and listed six. The release workflows read FIFTEEN. The nine missing ones are added, each with what UNSET means, because for most of them unset is the intended state and the default is the behaviour you get: - NUGET_POST_PUSH_VERIFY and RELEASE_CROSS_VERIFY both test != 'false', so only the literal string false disables them. A reader who set either to 0 or off would believe it was disabled and be wrong. - NUGET_POST_PUSH_ATTEMPTS (40) and NUGET_POST_PUSH_SLEEP_SEC (15) are the ~10-minute nuget.org visibility budget. nuget.org indexing lags publication, so a shortened budget reports "not visible" for a package that published fine - which reads as a failed release. - NUGET_STAGING_FEED_URL and NUGET_STAGING_API_KEY are the only way to rehearse a publish against something that is not nuget.org, and neither was documented. - RELEASE_NOTIFICATION_WEBHOOK_URL, NUGET_POST_PUSH_VERIFY_PACKAGE_IDS, and GITHUB_TOKEN for completeness, the last so its absence from the configurable rows is not read as an omission. Every value re-read from the live API on 2026-09-14: three variables and one secret exist; the other eleven are genuinely unset. The section also cited three workflow lines - reusable-release-nuget.yml:226, :185 and release.yml:198. All three resolved correctly today, and all three will rot the next time those files are edited, which is the defect this document has already been corrected for twice. They now name the step or the condition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(self-extend): annotate the audit's stale line anchors and scope the A4 claim docs/SELF-EXTEND-AUDIT.md is named by the public-v0.x go criterion "Known limitations documented: certification-evidence.md + SELF-EXTEND-AUDIT.md current", so it is read as live rather than as a dated sprint record. Its control-flow trace carries 28 file.cs:line anchors from the audit date. Measured 2026-09-14: five now land on a bare brace or a blank line, and landing on real code is not the same as landing on the code described - BackgroundAgentRegistry.cs:203 is an ArgumentNullException throw where the trace describes machine-origin agent validation. Annotated rather than re-pinned. That is this repository's policy for dated documents, and re-pinning has already failed twice in this tree: a re-anchoring in docs/certification-evidence.md went stale within hours of being written, and a correction to two line ranges in ci/cert-gate-assertions.md was itself wrong. Member and type names are stable; the note says to search for those. The note also scopes A4. The audit lists "Post-apply verification with automatic rollback exists and is tested" among the enforced invariants, which reads as unconditional. SelfExtendAdmissionBridge takes IPostApplyVerification as an optional parameter defaulting to null and rolls back only "when verification is not null". AddCertificationInfrastructure supplies one, so the supported wiring has the canary; a host constructing the bridge by hand and passing nothing gets no canary and no rollback, silently. README carried the same overclaim and is corrected in the same branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: t <t@t> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related changes: one canonical decimal form for doubles on every target, and the trim/AOT canonical-bytes probe promoted from a script to a CI job. Both became possible once #589 made the payload emitter hand-written.
The divergence
The emitter delegated a double's decimal form to the target — its own comment said so. Measured through the real
BuildPayloadin containers:1/30.33333333333333330.333333333333333310.0070.0070.00700000000000000011e-71E-079.9999999999999995E-08double.Epsilon5E-3244.9406564584124654E-324-0.0-00So a record carrying a non-zero double is signed differently depending on which framework loaded the signer. Latent, not live: the only three signing sites emit
escapeRate0 or null and never populateAttempts, and the corpora deliberately used only exactly-representable values — which is exactly why nothing caught it.Choosing the form, by measurement
"R"— rejected on evidence. Mono's"R"differs from .NET 10's for1/3,1e16anddouble.Epsilon.F17ofdouble.Epsilonis0.00000000000000000; the value is destroyed. No fixed width spans 10⁻³²⁴ to 10³⁰⁸.G17invariant — chosen. 17 significant digits is the fixed width at which a binary64 always round-trips (a property, not a heuristic), it measured character-identical on all three targets, and it is already what the divergent ns2.0 leg produces — so the hardest-to-test leg needs no new code path.Emitted through
WriteRawValue(text, skipInputValidation: false), so every number still gets a structural check. NaN and the infinities are now refused asCanonicalPayloadExceptionrather than escaping as anArgumentExceptionthe verifier's catch list did not cover.Negative zero is normalised to
0, and the reason is not aesthetic. Emitting-0is identical across the three emitters, but on the ns2.0 leg it is not stable through a read-back: under Mono bothdouble.Parse("-0")andJsonDocument…GetDouble()return+0.0. A record file written with-0would be re-emitted as0by an ns2.0 consumer, and the two legs would compute different bytes from the same record.One residual, stated rather than papered over
A reviewer measured that
G17is not perfectly canonical: Mono rounds an exact tie at the 17th significant digit away from zero where .NET 8/10 do not. Rather than claim parity it does not have, this PR keepsG17and qualifies every place that asserted parity — the emitter comment,SPEC-006, the changelog — and pins the tie case inCanonicalDoubleRepresentationTestswith both known forms, so the residual is visible rather than folklore. Closing it fully means deriving the decimal text from the bit pattern withBigInteger, which is a larger change than this one.The CI lane
trim-aot-canonical-bytesadded as a job in the existingruntime-portability-gate.yml(from #587) — no second workflow. It publishes a consumer authored outside the repo, self-contained, and runs each of four configurations: trim-partial, trim-full, trim-full-reflection-on, and AOT. Advisory: not inrequired_status_checks, nocontinue-on-error.Review caught the same hole #587 had: the lane's verdict rested on the consumer's exit code, and a configuration name matching none of the four made every run return 0 while reporting success. It now reads back what the published binary actually printed, validates the selector up front, and counts RAN markers.
Verification
All in containers. Builds clean on
netstandard2.0,net8.0,net10.0. Certification suites 634/634. The four new awkward-double corpus cases emit identical SHA-256s on net8.0, net10.0, the ns2.0 asset under Mono, and all four published trim/AOT configurations. The five original golden cases are byte-untouched — only the_readmechanged.Not covered
No CI job runs Mono, so the awkward-double cases are checked there only by a local script. The leg that actually diverged is the one without a gate — worth its own change.
🤖 Generated with Claude Code