<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://lattice-substrate.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://lattice-substrate.github.io/" rel="alternate" type="text/html" /><updated>2026-09-05T00:34:33+00:00</updated><id>https://lattice-substrate.github.io/feed.xml</id><title type="html">Lattice Substrate</title><subtitle>Engineering articles on deterministic data processing, standards-grade conformance, and the practices that produce evidence of correctness.</subtitle><author><name>Mark Lenhardt</name></author><entry><title type="html">Lawful Substrates: A Constitutional Eighth Strategy for Computational Worlds</title><link href="https://lattice-substrate.github.io/blog/2026/09/04/governed-lawful-substrates/" rel="alternate" type="text/html" title="Lawful Substrates: A Constitutional Eighth Strategy for Computational Worlds" /><published>2026-09-04T00:00:00+00:00</published><updated>2026-09-04T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/09/04/governed-lawful-substrates</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/09/04/governed-lawful-substrates/"><![CDATA[<p>A substrate should not be defined by the nouns visible in its demonstration.</p>

<p>A document is not a substrate law. Neither is a spreadsheet, database, canvas, notebook, object image, browser DOM, application model, or runtime. Each may be an excellent computational medium. Each may embody a coherent substrate for a domain. None is general merely because many things can be encoded inside it.</p>

<p>The deeper question is constitutional:</p>

<blockquote>
  <p><strong>What remains invariant when computational worlds acquire new subjects, tools, domains, owners, implementations, and peers?</strong></p>
</blockquote>

<p>This article proposes a law- and relation-centric answer. A <strong>lawful substrate</strong> is an underlying computational constitution whose laws govern identity, typed relations, authority, admission, transition, observation, isolation, effects, lineage, finite closure, extension, and federation. Domain systems elaborate their operations into that constitution. They do not rewrite it. Concrete implementations realize the constitution. They do not become its universal ontology.</p>

<p>Here, <em>lawful</em> means admitted under an explicit computational constitution. It does not mean merely compliant with external legislation, centrally administered, or globally owned. Governance is the mediation of commitment by stated law and authority; federation preserves multiple such authorities.</p>

<p>Documents, direct manipulation, liveness, persistence, and malleability are central research achievements and design goals. A constitutional account places them at the level of experience and realization, then asks the additional question: <strong>under what laws can unlike realizations remain comparable, extensible, auditable, and federable?</strong></p>

<p>The proposed category adds a law- and relation-centered strategy to seven overlapping historical strategies. Its organizing center is unity by invariant law rather than unity by one privileged representation.</p>

<h2 id="a-converging-research-program">A converging research program</h2>

<p>Substrate research draws together a long lineage of attempts to treat the complete computational environment as a coherent design object.</p>

<p>Engelbart treated computing as an environment for augmenting individual and collective intellect (<a href="https://www.dougengelbart.org/pubs/augment-3906.html">Engelbart, 1962</a>). Nelson sought structures adequate to information that is complex, changing, and indeterminate (<a href="https://doi.org/10.1145/800197.806036">Nelson, 1965</a>). Kay and Goldberg described a personal dynamic medium broad enough to combine communication, memory, simulation, and authoring (<a href="https://doi.org/10.1109/C-M.1977.217672">Kay and Goldberg, 1977</a>). Smalltalk made a live, inspectable object world into both programming environment and medium (<a href="https://doi.org/10.1145/155360.155364">Kay, 1993</a>). Persistent programming later attacked the artificial boundary between short-lived computation and durable data (<a href="https://doi.org/10.1093/comjnl/26.4.360">Atkinson et al., 1983</a>; <a href="https://doi.org/10.1007/BF01231642">Atkinson and Morrison, 1995</a>).</p>

<p>Across these lineages, the whole computational environment was already the design object. Their ideas subsequently continued through programming languages, operating systems, databases, distributed systems, security, hypermedia, provenance, and human-computer interaction, each of which developed a tractable part of the larger problem.</p>

<p>The contemporary Software Substrates program brings these strands into direct conversation. <a href="https://doi.org/10.22152/programming-journal.org/2023/7/13">Jakubovic, Edwards, and Petricek (2023)</a> argue that programming systems lack a common structure comparable to the theories used to compare programming languages. Their technical-dimensions framework seeks to make programming systems cumulative research rather than a succession of incomparable personal visions. The first dedicated Software Substrates workshop in 2025 collected sharply different vision statements, and the 2026 workshop retained “substrate” as an umbrella over interactive authoring, computational media, malleable systems, local-first work, integration domains, and other traditions (<a href="https://software-substrates.github.io/proceedings/2025/">Software Substrates, 2025</a>; <a href="https://2026.programming-conference.org/home/substrates-2026">Software Substrates, 2026</a>).</p>

<p>The proceedings identify the present research opportunity. The universal review of the 2025 contributions observes that working systems are valuable tests of feasibility, yet packaging ideas inside different demo systems makes abstract comparison difficult (<a href="https://software-substrates.github.io/proceedings/2025/">Software Substrates, 2025</a>). The 2026 call describes a spectrum running from accounts broad enough to find substrate-like qualities in nearly any runtime to visions that expect a successful substrate to converge on a canonical structure (<a href="https://2026.programming-conference.org/home/substrates-2026">Software Substrates, 2026</a>).</p>

<p>The task is to develop a vocabulary strong enough to compare complete computational worlds without prematurely selecting one realization as universal.</p>

<h2 id="current-substrate-foundations">Current substrate foundations</h2>

<p>Current substrate research contributes essential design principles for complete computational environments.</p>

<p><a href="https://software-substrates.github.io/proceedings/2025/statements/Paper%203%20-%20Jonathan%20Edwards%20-%20Substrate%20vision%20statement.pdf">Edwards (2025)</a> defines a substrate through a complete and self-sufficient programming system, persistent code and data, direct manipulation, live programming, continuity between use and programming, and conceptual unity. The compact slogan is a “WYSIWYG document, DB, &amp; PL in one.” This is a strong realization strategy. It attacks impedance mismatches directly and places the user inside a coherent computational world rather than outside a stack of opaque services.</p>

<p><a href="https://software-substrates.github.io/proceedings/2025/statements/Paper%205%20-%20Clemens%20Klokmose%20-%20Substrates,%20From%20Webstrates%20and%20Beyond.pdf">Klokmose (2025)</a> develops another powerful lineage from instrumental interaction and Webstrates. Its principles reject a hard technical boundary between tools and data, demand manipulation across devices and users, use transclusion for composition, seek modification during use, permit semantically distinct substrate layers, and make edit and interaction history first-class. Webstrates operationalizes much of this by turning the browser DOM into collaboratively editable, persistent computational media (<a href="https://doi.org/10.1145/2807442.2807446">Klokmose et al., 2015</a>). A decade of work around that platform has also documented the trade between general principle and the pragmatics of building real systems (<a href="https://doi.org/10.1145/3569895">Borowski et al., 2022</a>).</p>

<p><a href="https://doi.org/10.1145/3706598.3714006">Mackay and Beaudouin-Lafon (2025)</a> distinguish information substrates from interaction substrates and study how interaction can gain power without forcing all complexity directly onto the user. Local-first software restores local primacy, ownership, offline operation, longevity, and collaboration (<a href="https://doi.org/10.1145/3359591.3359737">Kleppmann et al., 2019</a>). CRDTs provide mathematically defined convergence for classes of replicated data (<a href="https://inria.hal.science/inria-00609399">Shapiro et al., 2011</a>). These are substantial parts of any serious future substrate.</p>

<p>The constitutional proposal complements these lines of work by supplying a level at which their agreements and disagreements can be stated without requiring any one artifact model to become universal.</p>

<p>The distinction is:</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th>Realization-centered answer</th>
      <th>Constitutional answer</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>What does the user manipulate?</td>
      <td>Documents, cells, objects, views, instruments</td>
      <td>Whatever a domain law admits as subjects and relations</td>
    </tr>
    <tr>
      <td>How does computation feel?</td>
      <td>Live, direct, observable, malleable</td>
      <td>A property to be realized and empirically evaluated</td>
    </tr>
    <tr>
      <td>What persists?</td>
      <td>An image, document graph, database, operation history</td>
      <td>Identities and relations whose persistence conditions are explicit</td>
    </tr>
    <tr>
      <td>What may happen?</td>
      <td>Whatever the implementation exposes</td>
      <td>Only transitions admitted under stated law and authority</td>
    </tr>
    <tr>
      <td>What crosses machines or owners?</td>
      <td>Replicated state, operations, documents, messages</td>
      <td>Claims whose meaning, authority, lineage, and local admission remain distinct</td>
    </tr>
    <tr>
      <td>How does the system grow?</td>
      <td>Plugins, packages, code, schemas, documents</td>
      <td>Conservative extension under a non-self-amending root law</td>
    </tr>
  </tbody>
</table>

<p>A document/database/programming-language synthesis may be an exceptionally good substrate. It answers the realization question by selecting an integrated medium. A constitutional kernel answers the generality question by stating the laws under which integrated media can differ without losing meaning.</p>

<h2 id="seven-historical-strategies-and-a-constitutional-eighth">Seven historical strategies and a constitutional eighth</h2>

<p>The following classification is a comparative map of what each lineage tends to make primary. Real systems frequently cross categories.</p>

<table>
  <thead>
    <tr>
      <th>Strategy</th>
      <th>Primary source of unity</th>
      <th>Representative lineage</th>
      <th>Characteristic strength</th>
      <th>Characteristic limit for a general substrate</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1. Image- and object-centered</td>
      <td>One live object world or system image</td>
      <td>Smalltalk and related image systems (<a href="https://doi.org/10.1145/155360.155364">Kay, 1993</a>)</td>
      <td>Immediate inspection, modification, and continuity between system and program</td>
      <td>Tends to privilege one object ontology and one world boundary</td>
    </tr>
    <tr>
      <td>2. Persistent-world</td>
      <td>Orthogonal persistence or a durable address space</td>
      <td>Persistent programming and persistent object systems (<a href="https://doi.org/10.1093/comjnl/26.4.360">Atkinson et al., 1983</a>; <a href="https://doi.org/10.1007/BF01231642">Atkinson and Morrison, 1995</a>)</td>
      <td>Removes accidental boundaries between transient computation and durable data</td>
      <td>Persistence alone does not settle authority, effects, lineage, or federation</td>
    </tr>
    <tr>
      <td>3. Document- and hypermedia-centered</td>
      <td>Addressable, linked, editable media</td>
      <td>Nelsonian hypertext, computational media, Webstrates (<a href="https://doi.org/10.1145/800197.806036">Nelson, 1965</a>; <a href="https://doi.org/10.1145/2807442.2807446">Klokmose et al., 2015</a>)</td>
      <td>Unifies content, computation, interaction, linking, and authoring</td>
      <td>A document model can silently become the universal ontology</td>
    </tr>
    <tr>
      <td>4. Data-model- and database-centered</td>
      <td>A formal data model and query/derivation operations</td>
      <td>Relational database systems (<a href="https://doi.org/10.1145/362384.362685">Codd, 1970</a>)</td>
      <td>Stable relations, declarative manipulation, and durable shared state</td>
      <td>Data semantics do not by themselves govern arbitrary external execution or authority</td>
    </tr>
    <tr>
      <td>5. Protocol- and narrow-waist-centered</td>
      <td>A small set of interface constraints</td>
      <td>End-to-end design and REST (<a href="https://doi.org/10.1145/357401.357402">Saltzer, Reed, and Clark, 1984</a>; <a href="https://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm">Fielding, 2000</a>)</td>
      <td>Independent implementation and large-scale interoperability</td>
      <td>The waist governs exchange, not the complete semantics of a personal computational world</td>
    </tr>
    <tr>
      <td>6. Coordination- and component-centered</td>
      <td>Communication or composition among autonomous parts</td>
      <td>Linda tuple spaces and component/integration systems (<a href="https://doi.org/10.1145/2363.2433">Gelernter, 1985</a>)</td>
      <td>Decoupling, coordination, and plural execution loci</td>
      <td>Coordination does not automatically preserve exact identity, admission, or causal evidence</td>
    </tr>
    <tr>
      <td>7. Provenance- and content-graph-centered</td>
      <td>Identified artifacts and derivation graphs</td>
      <td>PROV-O, SWHIDs, and in-toto (<a href="https://www.w3.org/TR/prov-o/">W3C, 2013</a>; <a href="https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html">Software Heritage, 2021</a>; <a href="https://www.usenix.org/conference/usenixsecurity19/presentation/torres-arias">Torres-Arias et al., 2019</a>)</td>
      <td>Durable identity, derivation, attribution, and supply-chain evidence</td>
      <td>Evidence models often describe results after the fact rather than constituting the law of every transition</td>
    </tr>
    <tr>
      <td><strong>8. Law- and relation-centered</strong></td>
      <td><strong>An invariant constitution over plural typed subjects and total evidenced transitions</strong></td>
      <td><strong>Proposed here</strong></td>
      <td><strong>Unity without ontological flattening; governed extension and federation</strong></td>
      <td><strong>Must prove that the law is implementable, conservative, and useful across genuinely unrelated domains</strong></td>
    </tr>
  </tbody>
</table>

<p>The eighth strategy incorporates the first seven as possible realizations, domain substrates, or imported law families. Their favored nouns become explicit domain commitments rather than implicit universal ontology.</p>

<p>The organizing principle is:</p>

<blockquote>
  <p><strong>Unity without ontological flattening.</strong></p>
</blockquote>

<p>A file need not become a process. A process need not become a theorem. A theorem need not become a document. A document need not become a database row. They may remain distinct subjects while sharing laws of identity, relation, admission, consequence, evidence, extension, and federation.</p>

<h2 id="a-substrate-constitution">A substrate constitution</h2>

<p>Let the invariant kernel be:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>K := &lt;Subj, Id, Eq, Rel, Auth, Admit, Step,
      Obs, Iso, Emit, Lin, Close, Ext, Fed&gt;
</code></pre></div></div>

<p>The components are law families:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Subj</code> governs the formation and admission of subject kinds without enumerating every domain noun or requiring one universal data structure.</li>
  <li><code class="language-plaintext highlighter-rouge">Id</code> and <code class="language-plaintext highlighter-rouge">Eq</code> govern identity, equality, succession, aliasing, and distinction.</li>
  <li><code class="language-plaintext highlighter-rouge">Rel</code> governs how relation kinds are formed and admitted, including endpoint conditions, direction, authority, and consequence; overlays supply domain meanings.</li>
  <li><code class="language-plaintext highlighter-rouge">Auth</code> identifies who or what may propose, attest, admit, delegate, or revoke.</li>
  <li><code class="language-plaintext highlighter-rouge">Admit</code> governs entry into the committed world.</li>
  <li><code class="language-plaintext highlighter-rouge">Step</code> governs lawful state transition and refusal.</li>
  <li><code class="language-plaintext highlighter-rouge">Obs</code>, <code class="language-plaintext highlighter-rouge">Iso</code>, and <code class="language-plaintext highlighter-rouge">Emit</code> govern captured inputs, isolation assumptions, visible outputs, ambient influence, and external effects.</li>
  <li><code class="language-plaintext highlighter-rouge">Lin</code> governs causal lineage rather than merely chronological logging.</li>
  <li><code class="language-plaintext highlighter-rouge">Close</code> requires the dependency and execution closure of a transition to be finite and admitted before commitment.</li>
  <li><code class="language-plaintext highlighter-rouge">Ext</code> governs overlays, capabilities, succession, and promotion without silent root mutation.</li>
  <li><code class="language-plaintext highlighter-rouge">Fed</code> governs exchange among independently governed worlds.</li>
</ul>

<p>A domain overlay <code class="language-plaintext highlighter-rouge">O</code>, a local policy <code class="language-plaintext highlighter-rouge">P</code>, and an admitted finite capability closure <code class="language-plaintext highlighter-rouge">A</code> specialize but do not replace <code class="language-plaintext highlighter-rouge">K</code>. “Owner” denotes the local locus of authority, which may be a person, group, institution, or formally constituted process. For current state <code class="language-plaintext highlighter-rouge">s</code>, proposal <code class="language-plaintext highlighter-rouge">q</code>, and captured boundary <code class="language-plaintext highlighter-rouge">c</code>, the core judgment is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>decide_(K,O,P,A) : S × Q × C
                 -&gt; Admit(S × R × E)
                  + Reject(S × F × E)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">R</code> is a result, <code class="language-plaintext highlighter-rouge">F</code> is a first lawful failure, and <code class="language-plaintext highlighter-rouge">E</code> is an evidence object whose required contents and adequacy are themselves fixed by the active law. The sum is disjoint. The judgment has no silent third outcome.</p>

<p>Let <code class="language-plaintext highlighter-rouge">X_(K,O,P,A)</code> be the declared boundary request space. If a concrete boundary accepts bytes, decoding and malformed-input rejection belong inside this space or in an immediately preceding total judgment. Admission is an outcome, never a precondition. The minimal totality and determinism obligation is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>for every boundary request x, there exists exactly one y
such that decide_(K,O,P,A)(x) = y.
</code></pre></div></div>

<p>In notation:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>∀x ∈ X_(K,O,P,A). ∃! y. decide_(K,O,P,A)(x) = y
</code></pre></div></div>

<p>A rejection preserves the committed state. It may produce evidence explaining refusal, but it does not smuggle in a partial successor:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>decide(s,q,c) = Reject(s,f,e)
</code></pre></div></div>

<p>An admission identifies the successor, result, and evidence together:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>decide(s,q,c) = Admit(s',r,e)
</code></pre></div></div>

<p>This makes enforcement part of the denotation of the law. “Persistent,” “malleable,” “open,” “local,” “secure,” and “observable” are not sufficient as values. A law needs a scope, a satisfaction condition, a violation condition, a deciding authority, an enforcement point, a total outcome, and evidence adequate to audit the decision.</p>

<h2 id="conservative-narrowing-under-extension">Conservative narrowing under extension</h2>

<p>Within a fixed observable alphabet <code class="language-plaintext highlighter-rouge">Σ</code>, additional law narrows behavior:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Beh_(i+1) ⊆ Beh_i
</code></pre></div></div>

<p>An extension may introduce new subject kinds or operations, so its behavior lives over an expanded alphabet. Let <code class="language-plaintext highlighter-rouge">Comp(K)</code> be the triples <code class="language-plaintext highlighter-rouge">(O,P,A)</code> lawfully composable with <code class="language-plaintext highlighter-rouge">K</code>; let <code class="language-plaintext highlighter-rouge">⊕</code> denote that governed composition, not arbitrary union; let <code class="language-plaintext highlighter-rouge">Beh(X)</code> be the observable behaviors admitted by <code class="language-plaintext highlighter-rouge">X</code>; let <code class="language-plaintext highlighter-rouge">Σ_K</code> be the root observable alphabet; and let <code class="language-plaintext highlighter-rouge">π_(Σ_K)</code> project an extended behavior onto that alphabet. The root-preservation obligation is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>∀(O,P,A) ∈ Comp(K).
  π_(Σ_K)(Beh(K ⊕ O ⊕ P ⊕ A)) ⊆ Beh(K)
</code></pre></div></div>

<p>For a purely conservative extension that adds vocabulary without restricting old behavior, the stronger equality holds:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>π_(Σ_K)(Beh(K ⊕ O)) = Beh(K)
</code></pre></div></div>

<p>This distinction matters. A spreadsheet overlay may introduce cells, ranges, formulas, recalculation, and workbook-specific failures. A theorem overlay may introduce terms, goals, elaboration, proof objects, and kernel rejection. A build overlay may introduce artifacts, dependency edges, compilers, and link steps. Each expands the vocabulary. None may redefine root identity, invent unrecorded authority, conceal effects, escape closure, or erase lineage.</p>

<p>Owner-reserved freedom is itself part of the constitution. A law should classify each dimension as one of:</p>

<table>
  <thead>
    <tr>
      <th>Status</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>invariant</td>
      <td>every realization must preserve it</td>
    </tr>
    <tr>
      <td>required</td>
      <td>the realization must provide it</td>
    </tr>
    <tr>
      <td>prohibited</td>
      <td>no lawful realization may exhibit it</td>
    </tr>
    <tr>
      <td>owner-selected</td>
      <td>the local owner chooses within an admitted space</td>
    </tr>
    <tr>
      <td>overlay-selected</td>
      <td>a domain law chooses within an admitted space</td>
    </tr>
    <tr>
      <td>variable-but-evidenced</td>
      <td>realizations may differ, but the selected value and consequence must be recorded</td>
    </tr>
  </tbody>
</table>

<p>Explicit reservation distinguishes owner discretion from underspecification. Without it, one implementation may treat silence as freedom while another treats it as prohibition or obligation.</p>

<h2 id="the-law-families">The law families</h2>

<h3 id="identity-before-storage">Identity before storage</h3>

<p>Most software lets its storage mechanism answer identity questions by accident. A pathname, memory address, database key, URL, object pointer, row number, hash, or UI position becomes “the thing” because that is what the implementation happens to expose.</p>

<p>A lawful substrate separates at least:</p>

<ul>
  <li>subject identity from representation;</li>
  <li>equality from byte identity;</li>
  <li>succession from mutation;</li>
  <li>aliases from identities;</li>
  <li>local names from global references;</li>
  <li>content identity from contextual identity;</li>
  <li>imported identity from locally admitted identity.</li>
</ul>

<p>Content-addressed identifiers such as SWHIDs solve an important subset: stable reference to source-code artifacts by intrinsic structure (<a href="https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html">Software Heritage, 2021</a>). They do not alone state whether two executions are the same act, whether a derived result is authorized, whether a foreign identifier is locally admitted, or whether a successor preserves the identity of its predecessor. Those are additional laws.</p>

<h3 id="typed-relations-before-a-universal-container">Typed relations before a universal container</h3>

<p>A lawful substrate is relation-centric, but it is not committed to the relational model as the single representation of everything. It requires relations to have admitted kinds, endpoint conditions, direction, authority, and consequences.</p>

<p>The root may know that a relation is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>produced-by(result, execution)
used-input(execution, subject)
authorized-by(proposal, authority)
succeeds(new, old)
interprets(view, subject)
imports(local, foreign)
</code></pre></div></div>

<p>The domain may know that another relation is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cell-depends-on(cell_7, cell_2)
theorem-uses-lemma(T, L)
document-transcludes(D1, D2)
object-instantiates-schema(x, S)
</code></pre></div></div>

<p>The first group can be constitutional. The second group remains domain law. Generality comes from preserving the distinction, not from encoding every domain noun as a generic node and then pretending the semantics survived.</p>

<h3 id="authority-and-admission-before-capability">Authority and admission before capability</h3>

<p>The existence of a tool or operation does not authorize its use. The ability to parse a foreign claim does not require accepting it. The possession of a capability does not permit that capability to admit itself.</p>

<p>Security research has long separated mechanism from policy and emphasized complete mediation (<a href="https://doi.org/10.1109/PROC.1975.9939">Saltzer and Schroeder, 1975</a>). Enforceable-policy work asks which policies a monitor can actually enforce (<a href="https://doi.org/10.1145/353323.353382">Schneider, 2000</a>). Proof-carrying code makes acceptance conditional on checkable evidence (<a href="https://doi.org/10.1145/263699.263712">Necula, 1997</a>). SPKI’s local authorization model demonstrates that useful trust need not begin with one universal naming sovereign (<a href="https://www.rfc-editor.org/rfc/rfc2693">Ellison et al., 1999</a>).</p>

<p>A substrate constitution should absorb the lesson directly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Propose(x) ≠ Authorize(x) ≠ Admit(x) ≠ Execute(x)
</code></pre></div></div>

<p>and:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>origin(proposal) cannot be the sole source of the authority
required to admit that proposal.
</code></pre></div></div>

<p>This is the non-self-admission rule. A capability may propose its successor. It cannot manufacture the root authority that makes the successor lawful.</p>

<h3 id="total-transition-and-first-refusal">Total transition and first refusal</h3>

<p>Partial functions are often convenient inside an implementation. They are a poor external constitution. At the commitment boundary, every admitted request should yield exactly one admitted successor or one explicit rejection.</p>

<p>A first-failure order is not merely an error-message preference. It makes refusal deterministic and prevents implementations from exposing different partial interpretations of the same invalid proposal. Where several conditions fail, the law states which failure is authoritative.</p>

<p>This supports:</p>

<ul>
  <li>reproducible conformance testing;</li>
  <li>stable audit evidence;</li>
  <li>cross-implementation comparison;</li>
  <li>denial without partial effects;</li>
  <li>reasoning about adversarial inputs;</li>
  <li>exact correspondence between abstract law and concrete behavior.</li>
</ul>

<h3 id="observation-isolation-and-effects">Observation, isolation, and effects</h3>

<p>A transition cannot be explained by naming only its explicit arguments. External computation may observe environment variables, files, clocks, randomness, process state, network replies, inherited descriptors, locale, kernel behavior, hardware features, or previous mutable state. It may emit files, packets, subprocesses, UI changes, or other effects.</p>

<p>A lawful substrate therefore makes the observation boundary and effect boundary explicit. Isolation is not a slogan that “nothing else mattered.” It is evidence of what was excluded, admitted, or captured.</p>

<p>At minimum, evidence for an externally realized transition should bind:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>proposal identity
law and overlay versions
authority and admission decision
exact tool identity
finite dependency/execution closure
captured observations and inputs
isolation conditions
termination and failures
outputs and emissions
committed successor state
</code></pre></div></div>

<p>A log stating that an event happened is weaker. A provenance graph stating that one entity was derived from another is stronger. A lineage-bearing execution record binds the exact admitted act to the exact causal and authority closure that made its result possible.</p>

<p>PROV-O gives a cross-domain vocabulary for entities, activities, agents, generation, use, attribution, and derivation (<a href="https://www.w3.org/TR/prov-o/">W3C, 2013</a>). in-toto binds authorized supply-chain steps, materials, products, and attestations (<a href="https://www.usenix.org/conference/usenixsecurity19/presentation/torres-arias">Torres-Arias et al., 2019</a>). A lawful substrate treats such evidence not as optional reporting attached after execution, but as part of the transition result itself.</p>

<h3 id="finite-admitted-closure">Finite admitted closure</h3>

<p>A computation whose operative closure is “whatever happens to be available” is not governed. The closure need not be small, and its internals need not be implemented in one language. It must be finite for the act being admitted, identifiable, and closed under the dependencies that can influence the result.</p>

<p>Formally, for an admitted execution <code class="language-plaintext highlighter-rouge">a</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Close(a) is finite
and every member of Close(a) is admitted under the active law.
</code></pre></div></div>

<p>Libraries and abstractions can serve as governed compression. At the commitment boundary, their identities, versions, authorities, and causal contributions must be admitted rather than ambient.</p>

<blockquote>
  <p><strong>Ungoverned abstraction is the failure. Governed abstraction is compression.</strong></p>
</blockquote>

<p>Conceptual unity is compatible with implementation heterogeneity. A realization may contain compilers, stores, processes, protocols, proof kernels, UI frameworks, and hardware layers. The constitutional requirement is <strong>no ungoverned semantic seams</strong>.</p>

<h3 id="lawful-extension-without-root-mutation">Lawful extension without root mutation</h3>

<p>A substrate that cannot grow becomes a museum. A substrate whose extensions may silently redefine its law becomes an application platform with privileged plugins.</p>

<p>Lawful extension requires at least:</p>

<ul>
  <li>explicit extension identity and version;</li>
  <li>declared new vocabulary and operations;</li>
  <li>proof, decision, or test evidence for root preservation;</li>
  <li>explicit migration or succession when old behavior is narrowed;</li>
  <li>no self-admission;</li>
  <li>revocation and rollback semantics;</li>
  <li>visible consequences for existing subjects and relations.</li>
</ul>

<p>The root can permit new domains without knowing their nouns. It can permit new tools without granting those tools constitutional authority. Recursive growth is therefore possible without recursive surrender.</p>

<h2 id="four-levels-of-substrate-architecture">Four levels of substrate architecture</h2>

<p>A constitutional account distinguishes four levels of commitment.</p>

<table>
  <thead>
    <tr>
      <th>Level</th>
      <th>Proper contents</th>
      <th>Category error to avoid</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Constitutional substrate law</strong></td>
      <td>Identity, relation, authority, admission, transition, observation, effects, lineage, closure, extension, federation</td>
      <td>Treating cells, documents, rows, windows, notebooks, or a DOM as universal by default</td>
    </tr>
    <tr>
      <td><strong>Concrete realization</strong></td>
      <td>Runtime, process model, storage, transport, cryptography, proof machinery, operating-system interface</td>
      <td>Treating one mechanism as the only possible meaning of the law</td>
    </tr>
    <tr>
      <td><strong>Domain substrate or overlay</strong></td>
      <td>Documents, spreadsheets, proofs, builds, experiments, media, workflows, simulations</td>
      <td>Granting a domain privileged power to rewrite the root constitution</td>
    </tr>
    <tr>
      <td><strong>Application and interface</strong></td>
      <td>Editor, notebook, spreadsheet view, IDE, inspector, collaborative workspace</td>
      <td>Presenting a product workflow as a universal substrate law</td>
    </tr>
  </tbody>
</table>

<p>A document can be a lawful subject. A spreadsheet can be a domain substrate. A database can realize persistence and query. A browser DOM can be a pragmatic common medium. Each belongs at an explicit architectural level.</p>

<p>Putting a domain noun into the root immediately imports assumptions about granularity, identity, transactions, conflict resolution, presentation, ownership, synchronization, addressability, and what counts as an operation. Those assumptions may be excellent for the intended world. They cease to be general when they become invisible.</p>

<p>Generality depends on preserving both plurality and level distinctions. Unlike substrate realizations become comparable only when root law, domain law, concrete realization, and interface are stated separately.</p>

<h2 id="elaboration-rich-domains-small-constitutional-judgments">Elaboration: rich domains, small constitutional judgments</h2>

<p>The useful analogy is a language elaborator and a small trusted core. Rich surface operations are translated into a smaller judgment that the kernel can check. The kernel does not need every surface construct as a primitive.</p>

<p>A substrate operation follows the same shape:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rich domain operation
        ↓ domain elaboration
identified subjects + typed relations + proposed transition
+ authority + finite closure + captured boundary
        ↓ constitutional decision
admitted successor or evidenced rejection
</code></pre></div></div>

<p>Consider four unrelated operations.</p>

<p><strong>Recalculate a spreadsheet.</strong> The overlay identifies formula subjects and dependency relations, elaborates recalculation into a derivation proposal, supplies the admitted evaluator and finite input closure, and declares the expected result kind. The root decides identity, authority, closure, observation, effects, and commitment. It does not need a universal <code class="language-plaintext highlighter-rouge">Cell</code> primitive.</p>

<p><strong>Check a theorem.</strong> The overlay identifies terms, declarations, dependencies, and the proof-checking operation. The constitutional layer governs which checker and environment are admitted, what exact closure was used, and whether the accepted proof object may enter committed state. It does not decide the theorem’s mathematical meaning for the domain.</p>

<p><strong>Produce a build artifact.</strong> The overlay supplies source and dependency relations, toolchain actions, and expected artifacts. The constitutional layer binds exact tool identities, observations, isolation, emissions, and lineage. It does not need <code class="language-plaintext highlighter-rouge">Compiler</code> to be a root subject kind.</p>

<p><strong>Capture a scientific observation.</strong> The overlay owns the scientific schema, units, calibration rules, and domain validity. The constitutional layer governs authority, capture identity, observation boundaries, transformations, and evidentiary succession. It must not infer a scientific conclusion the domain has not authorized.</p>

<p>The root is general because it remains ignorant of domain semantics that it has no right to invent, while remaining strict about consequences that no domain may conceal.</p>

<h2 id="federation-and-semantic-continuity">Federation and semantic continuity</h2>

<p>The field of distributed systems provides mature theories of event order, replication, convergence, consensus, failure, and serializability. Lamport’s causal ordering and state-machine reasoning made the absence of a single global time an explicit scientific problem in 1978 (<a href="https://www.microsoft.com/en-us/research/publication/time-clocks-ordering-events-distributed-system/">Lamport, 1978</a>). CRDTs formalize convergence for replicated data under specified conditions (<a href="https://inria.hal.science/inria-00609399">Shapiro et al., 2011</a>).</p>

<p>For lawful substrates, those mechanisms sit inside a broader federation problem:</p>

<blockquote>
  <p><strong>How can a complete computational world preserve semantic continuity across independently governed machines, people, organizations, and law versions?</strong></p>
</blockquote>

<p>A federated lawful substrate carries the established distributed-systems questions into the constitutional domain:</p>

<table>
  <thead>
    <tr>
      <th>Distributed mechanism asks</th>
      <th>Substrate federation must also ask</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Did replicas converge?</td>
      <td>Did they converge on a state each owner was authorized to admit?</td>
    </tr>
    <tr>
      <td>Which event happened first?</td>
      <td>Which law and authority made each event meaningful?</td>
    </tr>
    <tr>
      <td>Can nodes agree?</td>
      <td>Does agreement preserve local refusal and local ownership?</td>
    </tr>
    <tr>
      <td>Is a message authentic?</td>
      <td>Is its asserted meaning understood, and is it locally authorized?</td>
    </tr>
    <tr>
      <td>Was data replicated?</td>
      <td>Were identity, lineage, restrictions, and succession preserved?</td>
    </tr>
    <tr>
      <td>Can an operation be merged?</td>
      <td>May its consequences cross this governance boundary?</td>
    </tr>
  </tbody>
</table>

<p>Two laws follow.</p>

<p>First, intelligibility is not authority:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Understand_j(x) does not imply Authorize_j(x)
</code></pre></div></div>

<p>A world may decode a foreign subject, validate its evidence, and understand the law under which it was produced while still refusing to admit, execute, promote, or trust it.</p>

<p>Second, trust is not silently transitive:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Trust_i(j) and Trust_j(k) do not imply Trust_i(k)
</code></pre></div></div>

<p>Transitivity may be explicitly authorized by local policy, but it cannot be smuggled in as a property of connectivity.</p>

<p>A distributed substrate therefore includes more than synchronized documents or replicated state. Local-first systems restore the local copy as primary and show that collaboration can preserve data ownership (<a href="https://doi.org/10.1145/3359591.3359737">Kleppmann et al., 2019</a>). A constitutional substrate generalizes the boundary: local authority remains local even when meanings and evidence are globally traversable.</p>

<blockquote>
  <p><strong>Global intelligibility does not imply global authorization.</strong></p>
</blockquote>

<h2 id="formal-and-empirical-obligations">Formal and empirical obligations</h2>

<p>A law-centered account separates what formal law can establish from what human experience must demonstrate.</p>

<p>A proof can show that an extension is conservative, a transition deterministic, a closure finite, or an authority check complete relative to a model. Empirical study determines whether a person can discover a capability, understand a consequence, comfortably manipulate a representation, or appropriate a tool during use.</p>

<p>Claims such as malleability, directness, conviviality, learnability, and human-scale coherence require empirical evaluation. Accordingly, current substrate work treats interaction as first-class (<a href="https://software-substrates.github.io/proceedings/2025/statements/Paper%203%20-%20Jonathan%20Edwards%20-%20Substrate%20vision%20statement.pdf">Edwards, 2025</a>; <a href="https://software-substrates.github.io/proceedings/2025/statements/Paper%205%20-%20Clemens%20Klokmose%20-%20Substrates,%20From%20Webstrates%20and%20Beyond.pdf">Klokmose, 2025</a>; <a href="https://doi.org/10.1145/3706598.3714006">Mackay and Beaudouin-Lafon, 2025</a>). The constitutional category gives those systems a common formal floor alongside human-computer interaction research.</p>

<p>The division of labor is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>formal and executable law:
    what may happen, under whose authority, with what evidence

human-centered realization:
    what can be perceived, understood, discovered, and appropriated

domain law:
    what the operation means in the subject matter
</code></pre></div></div>

<p>A serious substrate science coordinates all three while preserving their distinct evidentiary roles.</p>

<h2 id="what-would-make-the-category-scientific">What would make the category scientific</h2>

<p>A position becomes a research program only when it can fail. The law- and relation-centric category should be judged by at least the following tests.</p>

<h3 id="1-domain-independence">1. Domain independence</h3>

<p>The same unchanged root law must govern several genuinely unrelated domains. Demonstrating three document variants is insufficient. A stronger experiment would combine, for example, a proof development, a build, a scientific capture, and a collaborative document without adding their nouns to the kernel.</p>

<p><strong>Failure condition:</strong> the root must be edited whenever a new domain is introduced.</p>

<h3 id="2-conservative-extension">2. Conservative extension</h3>

<p>An overlay or capability must either preserve existing root-observable behavior or declare an explicit restrictive succession. Projection onto the old vocabulary must satisfy the conservation equation.</p>

<p><strong>Failure condition:</strong> an extension silently changes the meaning of existing identity, authority, transition, or evidence.</p>

<h3 id="3-total-and-ordered-refusal">3. Total and ordered refusal</h3>

<p>Every admitted input must yield one admitted successor or one explicit first failure, with no partial commitment and no implementation-dependent error race.</p>

<p><strong>Failure condition:</strong> malformed or conflicting proposals produce ambient exceptions, partial effects, or different authoritative failures across conforming implementations.</p>

<h3 id="4-closure-and-causal-evidence">4. Closure and causal evidence</h3>

<p>An externally executed transition must identify a finite admitted closure and bind its inputs, observations, isolation assumptions, outputs, effects, and termination to the committed result.</p>

<p><strong>Failure condition:</strong> the result depends on an unrecorded ambient resource, or the evidence cannot distinguish two causally different executions.</p>

<h3 id="5-independent-realization">5. Independent realization</h3>

<p>At least two materially different implementations must realize the same abstract law and agree on admitted observations. Otherwise the “law” may be only a description of one program.</p>

<p><strong>Failure condition:</strong> correspondence requires importing hidden behavior from the reference implementation.</p>

<h3 id="6-federation-without-sovereignty-collapse">6. Federation without sovereignty collapse</h3>

<p>Two independently governed worlds must exchange understandable claims while retaining local admission, local refusal, and explicit trust boundaries.</p>

<p><strong>Failure condition:</strong> interoperability requires one global owner, one mutable service, or automatic transitive trust.</p>

<h3 id="7-non-self-admission">7. Non-self-admission</h3>

<p>A capability may propose an extension but cannot create the authority by which that extension is admitted.</p>

<p><strong>Failure condition:</strong> executing an untrusted extension is necessary to decide whether it was safe to admit.</p>

<h3 id="8-empirical-validation-of-experience">8. Empirical validation of experience</h3>

<p>Where a realization claims malleability, direct manipulation, understandability, or lower cognitive burden, those claims must be evaluated with people and tasks appropriate to the intended users.</p>

<p><strong>Failure condition:</strong> a formal property is presented as evidence of a human outcome it does not entail.</p>

<p>Together, these tests make the category a refutable technical claim.</p>

<h2 id="the-case-in-one-judgment">The case in one judgment</h2>

<p>A lawful substrate can be summarized as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>K; O; P; A ⊢ &lt;s,q,c&gt;
    ⇓ Admit&lt;s',r,e&gt;
    + Reject&lt;s,f,e&gt;
</code></pre></div></div>

<p>subject to:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>∀(O,P,A) ∈ Comp(K).
π_(Σ_K)(Beh(K ⊕ O ⊕ P ⊕ A)) ⊆ Beh(K)
</code></pre></div></div>

<p>The first line says every proposed consequence is totally decided under explicit root law, domain law, owner policy, admitted capability closure, current state, and captured boundary. The result is either a committed successor with evidence or an evidenced refusal that preserves committed state.</p>

<p>The second line says domains, owners, and capabilities may add vocabulary and narrow permission, but they may not exhibit root-observable behavior forbidden by the constitution.</p>

<p>Everything else follows:</p>

<ul>
  <li>documents and databases may be worlds without becoming universal law;</li>
  <li>implementations may be heterogeneous without hiding semantic seams;</li>
  <li>capability may grow without self-authorization;</li>
  <li>provenance may become causal lineage rather than decorative metadata;</li>
  <li>distribution may preserve local authority rather than dissolve it;</li>
  <li>owners may retain explicit freedom without relying on underspecification;</li>
  <li>unlike domains may remain unlike while participating in one governed computational world.</li>
</ul>

<p>The field has already recovered the ambition to treat complete computational environments as research objects. The next step is to distinguish the medium from its constitution.</p>

<blockquote>
  <p><strong>Open authoring; governed commitment.</strong></p>
</blockquote>

<h2 id="references">References</h2>

<ul>
  <li>Malcolm P. Atkinson and Ronald Morrison. 1995. “Orthogonally Persistent Object Systems.” <em>The VLDB Journal</em> 4:319–401. <a href="https://doi.org/10.1007/BF01231642">doi:10.1007/BF01231642</a>.</li>
  <li>Malcolm P. Atkinson, Peter J. Bailey, Ken J. Chisholm, W. Paul Cockshott, and Ronald Morrison. 1983. “An Approach to Persistent Programming.” <em>The Computer Journal</em> 26(4):360–365. <a href="https://doi.org/10.1093/comjnl/26.4.360">doi:10.1093/comjnl/26.4.360</a>.</li>
  <li>Marcel Borowski, Bjarke V. Fog, Carla F. Griggio, James R. Eagan, and Clemens N. Klokmose. 2022. “Between Principle and Pragmatism: Reflections on Prototyping Computational Media with Webstrates.” <em>ACM Transactions on Computer-Human Interaction</em> 30(4). <a href="https://doi.org/10.1145/3569895">doi:10.1145/3569895</a>.</li>
  <li>Edgar F. Codd. 1970. “A Relational Model of Data for Large Shared Data Banks.” <em>Communications of the ACM</em> 13(6):377–387. <a href="https://doi.org/10.1145/362384.362685">doi:10.1145/362384.362685</a>.</li>
  <li>Jonathan Edwards. 2025. “Substrate Vision Statement.” In <em>Substrates 2025 Proceedings</em>. <a href="https://software-substrates.github.io/proceedings/2025/statements/Paper%203%20-%20Jonathan%20Edwards%20-%20Substrate%20vision%20statement.pdf">PDF</a>.</li>
  <li>Carl M. Ellison, Bill Frantz, Butler Lampson, Ron Rivest, Brian Thomas, and Tatu Ylonen. 1999. <em>SPKI Certificate Theory</em>. RFC 2693, Experimental. <a href="https://www.rfc-editor.org/rfc/rfc2693">RFC Editor</a>; <a href="https://doi.org/10.17487/RFC2693">doi:10.17487/RFC2693</a>.</li>
  <li>Douglas C. Engelbart. 1962. <em>Augmenting Human Intellect: A Conceptual Framework</em>. SRI Summary Report AFOSR-3223. <a href="https://www.dougengelbart.org/pubs/augment-3906.html">Primary text</a>.</li>
  <li>Roy Thomas Fielding. 2000. <em>Architectural Styles and the Design of Network-based Software Architectures</em>. Ph.D. dissertation, University of California, Irvine. <a href="https://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm">Official dissertation</a>.</li>
  <li>David Gelernter. 1985. “Generative Communication in Linda.” <em>ACM Transactions on Programming Languages and Systems</em> 7(1):80–112. <a href="https://doi.org/10.1145/2363.2433">doi:10.1145/2363.2433</a>.</li>
  <li>Joel Jakubovic, Jonathan Edwards, and Tomas Petricek. 2023. “Technical Dimensions of Programming Systems.” <em>The Art, Science, and Engineering of Programming</em> 7(3), Article 13. <a href="https://doi.org/10.22152/programming-journal.org/2023/7/13">doi:10.22152/programming-journal.org/2023/7/13</a>.</li>
  <li>Alan Kay and Adele Goldberg. 1977. “Personal Dynamic Media.” <em>Computer</em> 10(3):31–41. <a href="https://doi.org/10.1109/C-M.1977.217672">doi:10.1109/C-M.1977.217672</a>.</li>
  <li>Alan C. Kay. 1993. “The Early History of Smalltalk.” <em>ACM SIGPLAN Notices</em> 28(3):69–95. <a href="https://doi.org/10.1145/155360.155364">doi:10.1145/155360.155364</a>.</li>
  <li>Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. 2019. “Local-First Software: You Own Your Data, in Spite of the Cloud.” In <em>Onward! 2019</em>, 154–178. <a href="https://doi.org/10.1145/3359591.3359737">doi:10.1145/3359591.3359737</a>.</li>
  <li>Clemens N. Klokmose, James R. Eagan, Siemen Baader, Wendy E. Mackay, and Michel Beaudouin-Lafon. 2015. “Webstrates: Shareable Dynamic Media.” In <em>Proceedings of UIST ’15</em>. <a href="https://doi.org/10.1145/2807442.2807446">doi:10.1145/2807442.2807446</a>.</li>
  <li>Clemens Nylandsted Klokmose. 2025. “Substrates: From Webstrates and Beyond.” In <em>Substrates 2025 Proceedings</em>. <a href="https://software-substrates.github.io/proceedings/2025/statements/Paper%205%20-%20Clemens%20Klokmose%20-%20Substrates,%20From%20Webstrates%20and%20Beyond.pdf">PDF</a>.</li>
  <li>Leslie Lamport. 1978. “Time, Clocks, and the Ordering of Events in a Distributed System.” <em>Communications of the ACM</em> 21(7):558–565. <a href="https://www.microsoft.com/en-us/research/publication/time-clocks-ordering-events-distributed-system/">Author publication page</a>; <a href="https://doi.org/10.1145/359545.359563">doi:10.1145/359545.359563</a>.</li>
  <li>Wendy E. Mackay and Michel Beaudouin-Lafon. 2025. “Interaction Substrates: Combining Power and Simplicity in Interactive Systems.” In <em>Proceedings of the 2025 CHI Conference on Human Factors in Computing Systems</em>, Article 687. <a href="https://doi.org/10.1145/3706598.3714006">doi:10.1145/3706598.3714006</a>.</li>
  <li>George C. Necula. 1997. “Proof-Carrying Code.” In <em>POPL ’97</em>, 106–119. <a href="https://doi.org/10.1145/263699.263712">doi:10.1145/263699.263712</a>.</li>
  <li>Theodor H. Nelson. 1965. “Complex Information Processing: A File Structure for the Complex, the Changing and the Indeterminate.” In <em>Proceedings of the 20th ACM National Conference</em>, 84–100. <a href="https://doi.org/10.1145/800197.806036">doi:10.1145/800197.806036</a>.</li>
  <li>Jerome H. Saltzer and Michael D. Schroeder. 1975. “The Protection of Information in Computer Systems.” <em>Proceedings of the IEEE</em> 63(9):1278–1308. <a href="https://doi.org/10.1109/PROC.1975.9939">doi:10.1109/PROC.1975.9939</a>.</li>
  <li>Jerome H. Saltzer, David P. Reed, and David D. Clark. 1984. “End-to-End Arguments in System Design.” <em>ACM Transactions on Computer Systems</em> 2(4):277–288. <a href="https://doi.org/10.1145/357401.357402">doi:10.1145/357401.357402</a>.</li>
  <li>Fred B. Schneider. 2000. “Enforceable Security Policies.” <em>ACM Transactions on Information and System Security</em> 3(1):30–50. <a href="https://doi.org/10.1145/353323.353382">doi:10.1145/353323.353382</a>.</li>
  <li>Marc Shapiro, Nuno Preguiça, Carlos Baquero, and Marek Zawirski. 2011. “Conflict-Free Replicated Data Types.” In <em>Stabilization, Safety, and Security of Distributed Systems</em>, LNCS 6976, 386–400. <a href="https://inria.hal.science/inria-00609399">INRIA record</a>; <a href="https://doi.org/10.1007/978-3-642-24550-3_29">doi:10.1007/978-3-642-24550-3_29</a>.</li>
  <li>Software Heritage. 2021–2026. “SoftWare Heritage Persistent IDentifiers (SWHIDs).” <a href="https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html">Official specification</a>.</li>
  <li>Software Substrates. 2025. <em>Substrates 2025 Proceedings</em>. Initial workshop held June 3, 2025, at ‹Programming› 2025 in Prague. <a href="https://software-substrates.github.io/proceedings/2025/">Proceedings</a>.</li>
  <li>Software Substrates. 2026. “Substrates-26: Call for Contributions and Program.” ‹Programming› 2026. <a href="https://2026.programming-conference.org/home/substrates-2026">Official workshop page</a> and <a href="https://software-substrates.github.io/proceedings/2026/">proceedings</a>.</li>
  <li>Santiago Torres-Arias, Hammad Afzali, Trishank Karthik Kuppusamy, Reza Curtmola, and Justin Cappos. 2019. “in-toto: Providing Farm-to-Table Guarantees for Bits and Bytes.” In <em>28th USENIX Security Symposium</em>, 1393–1410. <a href="https://www.usenix.org/conference/usenixsecurity19/presentation/torres-arias">USENIX publication page</a>.</li>
  <li>Timothy Lebo, Satya Sahoo, and Deborah McGuinness, editors. 2013. <em>PROV-O: The PROV Ontology</em>. W3C Recommendation, 30 April 2013. <a href="https://www.w3.org/TR/prov-o/">Specification</a>.</li>
</ul>]]></content><author><name>Mark Lenhardt</name></author><category term="substrates" /><category term="systems" /><category term="formal-methods" /><category term="distributed-systems" /><summary type="html"><![CDATA[A law- and relation-centric account of computational substrates: invariant laws of identity, authority, transition, lineage, closure, extension, and federation without forcing every domain into one ontology.]]></summary></entry><entry><title type="html">From Multiprecision to Fixed-Width: Replacing Burger-Dybvig with Schubfach</title><link href="https://lattice-substrate.github.io/blog/2026/03/05/multiprecision-to-fixed-width-schubfach/" rel="alternate" type="text/html" title="From Multiprecision to Fixed-Width: Replacing Burger-Dybvig with Schubfach" /><published>2026-03-05T00:00:00+00:00</published><updated>2026-03-05T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/03/05/multiprecision-to-fixed-width-schubfach</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/03/05/multiprecision-to-fixed-width-schubfach/"><![CDATA[<p><a href="/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig/">Part 1</a> of this series implemented the Burger-Dybvig algorithm for IEEE 754 to decimal conversion. The implementation uses <code class="language-plaintext highlighter-rouge">math/big.Int</code> for exact multiprecision arithmetic, produces the shortest round-trip decimal string, and applies ECMA-262 even-digit tie-breaking. It is correct. It allocates on every call. This article describes what happens when you replace it with the <a href="https://drive.google.com/file/d/1IEeATSVnEE6TkrHlCYNY2GjaraBjOT4f/view">Schubfach algorithm</a> (Giulietti, 2022), implemented from scratch in Go with the same ECMA-262 conformance contract, validated against the same 286,362 oracle test vectors.</p>

<p>The result: 31.3% geometric mean throughput improvement across all API workloads (n=6, p=0.002 for every number-dense comparison), with zero conformance regressions. The performance gain concentrates where the algorithm change predicts it should (number-dense payloads), and disappears where it should (string-dominant payloads).</p>

<h2 id="what-burger-dybvig-actually-costs">What Burger-Dybvig Actually Costs</h2>

<p>The Burger-Dybvig implementation from Part 1 does four things per number. It initializes four <code class="language-plaintext highlighter-rouge">big.Int</code> values (R, S, M+, M-). It scales them by a power of 10 from a 700-entry precomputed cache. It extracts digits one at a time via <code class="language-plaintext highlighter-rouge">big.Int</code> division. It terminates when boundary conditions indicate the shortest representation. Every operation in that pipeline touches the heap. <code class="language-plaintext highlighter-rouge">big.Int</code> arithmetic allocates on multiply, on division, on comparison when the operand exceeds one machine word.</p>

<p>The CPU profile for the Burger-Dybvig path shows where <code class="language-plaintext highlighter-rouge">math/big</code> time goes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>2.70s  1.32%  math/big.mulAddVWW
2.33s  1.14%  math/big.nat.mulAddWW
2.28s  1.12%  math/big.(*Int).mul
1.80s  0.88%  math/big.nat.norm
1.71s  0.84%  math/big.nat.mul
</code></pre></div></div>

<p>That is 10.82s of CPU time in <code class="language-plaintext highlighter-rouge">math/big</code> during a benchmark run covering 19 workload categories (total sample time 204.18s). The memory profile adds detail. <code class="language-plaintext highlighter-rouge">jcsfloat.FormatDouble</code> accounts for 2.91% of cumulative heap allocation. That breaks down through the call chain: <code class="language-plaintext highlighter-rouge">generateDigits</code> (2.01% cumulative), which calls <code class="language-plaintext highlighter-rouge">scaleByPower10</code> → <code class="language-plaintext highlighter-rouge">pow10Big</code> (1.29% flat). The <code class="language-plaintext highlighter-rouge">pow10Big</code> function returns a defensive <code class="language-plaintext highlighter-rouge">new(big.Int).Set(cached)</code> copy on every call to prevent callers from corrupting the cache. That defensive copy is correct. It also means every <code class="language-plaintext highlighter-rouge">FormatDouble</code> invocation allocates at least one <code class="language-plaintext highlighter-rouge">big.Int</code> just to look up a power of 10, before any digit extraction begins.</p>

<p>The per-call allocation profile from the standalone <code class="language-plaintext highlighter-rouge">jcsfloat</code> benchmarks in Part 1:</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th style="text-align: right">ns/op</th>
      <th style="text-align: right">B/op</th>
      <th style="text-align: right">allocs/op</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>integer (42)</td>
      <td style="text-align: right">803</td>
      <td style="text-align: right">64</td>
      <td style="text-align: right">5</td>
    </tr>
    <tr>
      <td>fraction (3.14159…)</td>
      <td style="text-align: right">2,898</td>
      <td style="text-align: right">96</td>
      <td style="text-align: right">6</td>
    </tr>
    <tr>
      <td>subnormal (5e-324)</td>
      <td style="text-align: right">1,406</td>
      <td style="text-align: right">224</td>
      <td style="text-align: right">4</td>
    </tr>
    <tr>
      <td>max safe integer</td>
      <td style="text-align: right">2,938</td>
      <td style="text-align: right">88</td>
      <td style="text-align: right">5</td>
    </tr>
  </tbody>
</table>

<p>Four to six allocations per number. For a JSON document with 2,048 numbers in an array, that is 8,000 to 12,000 allocations spent solely on converting numbers to strings.</p>

<h2 id="schubfach-the-same-problem-different-arithmetic">Schubfach: The Same Problem, Different Arithmetic</h2>

<p>Schubfach solves the same problem as Burger-Dybvig: given an IEEE 754 binary64 value, find the shortest decimal string that round-trips. The difference is how it computes the answer.</p>

<p>Where Burger-Dybvig represents the value and its boundaries as ratios of arbitrary-precision integers and iterates to extract digits, Schubfach works entirely in fixed-width integer arithmetic. The core operation is a 64x64-to-128-bit multiplication of the float’s significand against a precomputed power-of-10 constant, followed by a shift and comparison against interval bounds that are themselves fixed-width. No heap allocation in the digit generation path. No <code class="language-plaintext highlighter-rouge">math/big</code>. No defensive copies of cached values.</p>

<p>Go does not have a native <code class="language-plaintext highlighter-rouge">uint128</code> type. The 128-bit multiplication uses <code class="language-plaintext highlighter-rouge">math/bits.Mul64</code>, which returns a <code class="language-plaintext highlighter-rouge">(hi, lo)</code> pair:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">hi</span><span class="p">,</span> <span class="n">lo</span> <span class="o">:=</span> <span class="n">bits</span><span class="o">.</span><span class="n">Mul64</span><span class="p">(</span><span class="n">significand</span><span class="p">,</span> <span class="n">pow10TableHi</span><span class="p">[</span><span class="n">index</span><span class="p">])</span>
</code></pre></div></div>

<p>The upper 64 bits contain the information needed to determine the digit string. The lower 64 bits determine rounding. Carry propagation through the 128-bit product must be exact. A single bit error in the table or the multiplication produces wrong output for specific IEEE 754 values that may not surface during random testing. The oracle vectors catch these errors; random fuzzing alone does not reliably reach the affected bit patterns.</p>

<p>The precomputed table replaces Burger-Dybvig’s 700-entry <code class="language-plaintext highlighter-rouge">*big.Int</code> cache with a fixed-size array of <code class="language-plaintext highlighter-rouge">[2]uint64</code> pairs (696 entries, covering exponents -348 to 347). Where <code class="language-plaintext highlighter-rouge">pow10Big</code> returns a heap-allocated defensive copy on every call, the Schubfach table is read-only after initialization and accessed by index with no allocation.</p>

<p>The ECMA-262 formatting stage (<code class="language-plaintext highlighter-rouge">formatECMA</code> and its four branch helpers from Part 1) is shared between implementations. That code takes <code class="language-plaintext highlighter-rouge">(negative bool, digits string, n int)</code> and produces the final string. It is algorithm-independent and unchanged.</p>

<h2 id="the-tie-breaking-problem">The Tie-Breaking Problem</h2>

<p>Part 1 described ECMA-262 even-digit tie-breaking as “the most subtle part of the algorithm.” That description applies to any shortest-round-trip algorithm, not just Burger-Dybvig.</p>

<p>In Burger-Dybvig, tie-breaking is explicit. The <code class="language-plaintext highlighter-rouge">midpointDigit</code> function compares <code class="language-plaintext highlighter-rouge">2R</code> against <code class="language-plaintext highlighter-rouge">S</code> using exact <code class="language-plaintext highlighter-rouge">big.Int</code> arithmetic. When <code class="language-plaintext highlighter-rouge">2R == S</code>, the digit <code class="language-plaintext highlighter-rouge">d</code> is checked: if even, keep it; if odd, round up. The invariants are visible in the code because the arithmetic is exact and the comparison is direct.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Burger-Dybvig: exact midpoint detection</span>
<span class="k">func</span> <span class="n">midpointDigit</span><span class="p">(</span><span class="n">d</span> <span class="kt">int</span><span class="p">,</span> <span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">)</span> <span class="kt">byte</span> <span class="p">{</span>
    <span class="n">state</span><span class="o">.</span><span class="n">scratch3</span><span class="o">.</span><span class="n">Lsh</span><span class="p">(</span><span class="o">&amp;</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
    <span class="n">cmp</span> <span class="o">:=</span> <span class="n">state</span><span class="o">.</span><span class="n">scratch3</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="o">&amp;</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">cmp</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">cmp</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">d</span><span class="o">%</span><span class="m">2</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>In Schubfach, the midpoint condition is encoded in the interval bounds. The algorithm computes whether the value falls strictly inside, at the boundary of, or outside the interval of valid shortest representations. Modifying the boundary comparison to prefer even digits requires understanding where in the interval arithmetic the rounding decision is made, and adjusting the comparison predicates accordingly.</p>

<p>The adjustment is small in code. A few comparison operators change from strict to inclusive (or vice versa) depending on the parity of the significand’s least significant bit. But the reasoning behind <em>which</em> operators to change requires following the proof in <a href="https://drive.google.com/file/d/1IEeATSVnEE6TkrHlCYNY2GjaraBjOT4f/view">Giulietti’s paper</a> to understand which invariant each comparison maintains.</p>

<p>The even-digit preference also influences the boundary comparisons themselves. This is the same pattern described in Part 1 for Burger-Dybvig’s <code class="language-plaintext highlighter-rouge">cmpRoundDown</code> and <code class="language-plaintext highlighter-rouge">cmpHigh</code> functions, where <code class="language-plaintext highlighter-rouge">isEven</code> switches between inclusive and strict comparisons:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Burger-Dybvig boundary comparison (from Part 1)</span>
<span class="k">func</span> <span class="n">cmpRoundDown</span><span class="p">(</span><span class="n">lhs</span><span class="p">,</span> <span class="n">rhs</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">isEven</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="m">0</span>   <span class="c">// Inclusive at boundary</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&lt;</span> <span class="m">0</span>        <span class="c">// Strict</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Schubfach has an analogous structure, but the comparison operates on 64-bit values derived from the 128-bit product rather than on <code class="language-plaintext highlighter-rouge">big.Int</code> ratios. The oracle vectors serve as the correctness proof. Any tie-breaking error produces a different digit string for at least one value in the 286,362-vector dataset. The oracle test fails with the exact bit pattern that triggered the divergence.</p>

<h2 id="benchmark-methodology">Benchmark Methodology</h2>

<p>Comparing two implementations of the same specification requires more than <code class="language-plaintext highlighter-rouge">go test -bench</code>. Both binaries must produce byte-identical output for every input. The performance comparison must separate the number formatting cost from everything else in the pipeline: parsing, string escaping, UTF-16 key sorting, memory allocation for the value tree.</p>

<p>The benchmark lab runs both implementations against identical workloads at three levels:</p>

<ol>
  <li><strong>API benchmarks</strong> via Go’s <code class="language-plaintext highlighter-rouge">testing.B</code> with <code class="language-plaintext highlighter-rouge">-count=6</code>, measuring <code class="language-plaintext highlighter-rouge">Canonicalize()</code> and <code class="language-plaintext highlighter-rouge">Verify()</code> calls directly. benchstat requires a minimum of 6 samples per comparison to compute confidence intervals at the 0.95 level.</li>
  <li><strong>CLI end-to-end timings</strong> with process startup, file I/O, and argument parsing included (9 runs per workload per implementation, 2 warmup runs discarded)</li>
  <li><strong>Conformance verification</strong> with SHA-256 digest comparison on every output, differential fuzzing (1,000 random cases), and determinism checks (identical output across repeated runs)</li>
</ol>

<p>The 19 workload categories span the space intentionally. Some are dominated by number formatting (<code class="language-plaintext highlighter-rouge">number-heavy</code>, <code class="language-plaintext highlighter-rouge">numeric-boundary</code>). Some have zero numbers (<code class="language-plaintext highlighter-rouge">long-string</code>, <code class="language-plaintext highlighter-rouge">control-escapes</code>, <code class="language-plaintext highlighter-rouge">surrogate-pair</code>). Some mix both (<code class="language-plaintext highlighter-rouge">small</code>, <code class="language-plaintext highlighter-rouge">medium</code>, <code class="language-plaintext highlighter-rouge">mixed-prod</code>, <code class="language-plaintext highlighter-rouge">nested-mixed</code>). The zero-number workloads serve as a control group: any performance change on those workloads would indicate the algorithm swap affected code paths it should not have touched.</p>

<p>The environment: Linux 6.8, Go 1.25.6, 12th Gen Intel i7-12700K (20 logical cores), CPU governor <code class="language-plaintext highlighter-rouge">performance</code>. Both implementations run under the same conditions on the same machine in the same benchmark invocation.</p>

<h2 id="conformance-evidence">Conformance Evidence</h2>

<p>Before any performance discussion, the gates:</p>

<table>
  <thead>
    <tr>
      <th>Suite</th>
      <th style="text-align: right">Cases</th>
      <th style="text-align: right">Passed</th>
      <th style="text-align: right">Failed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>cyberphone (official)</td>
      <td style="text-align: right">36</td>
      <td style="text-align: right">36</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>lab workloads</td>
      <td style="text-align: right">116</td>
      <td style="text-align: right">116</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td>RFC 8785 (official)</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">2</td>
      <td style="text-align: right">0</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td style="text-align: right"><strong>154</strong></td>
      <td style="text-align: right"><strong>154</strong></td>
      <td style="text-align: right"><strong>0</strong></td>
    </tr>
  </tbody>
</table>

<p>Additional evidence:</p>

<ul>
  <li>SHA-256 digests of canonical output are byte-identical between implementations for every valid workload. Not equivalent. Identical.</li>
  <li>1,000 differential fuzz cases with randomized seeds. Zero divergences.</li>
  <li>Zero oracle mismatches. Zero determinism failures. Zero invalid-input parity issues (both implementations reject the same inputs with the same failure classes).</li>
</ul>

<p>The Schubfach implementation produces the same bytes as Burger-Dybvig for every input tested. The remainder of this article is about cost, not correctness.</p>

<h2 id="api-benchmarks-number-dense-workloads">API Benchmarks: Number-Dense Workloads</h2>

<p>These are workloads where <code class="language-plaintext highlighter-rouge">FormatDouble</code> is invoked frequently enough to dominate the profile. All results are means of 6 samples:</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th style="text-align: right">Schubfach (ns/op)</th>
      <th style="text-align: right">Burger-Dybvig (ns/op)</th>
      <th style="text-align: right">Ratio</th>
      <th>p</th>
      <th style="text-align: right">allocs/op (S)</th>
      <th style="text-align: right">allocs/op (BD)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>number-heavy</td>
      <td style="text-align: right">13,530</td>
      <td style="text-align: right">20,855</td>
      <td style="text-align: right">1.54x</td>
      <td>0.002</td>
      <td style="text-align: right">60</td>
      <td style="text-align: right">80</td>
    </tr>
    <tr>
      <td>numeric-boundary</td>
      <td style="text-align: right">12,396</td>
      <td style="text-align: right">18,294</td>
      <td style="text-align: right">1.48x</td>
      <td>0.002</td>
      <td style="text-align: right">38</td>
      <td style="text-align: right">52</td>
    </tr>
    <tr>
      <td>nested-mixed</td>
      <td style="text-align: right">2,182</td>
      <td style="text-align: right">3,066</td>
      <td style="text-align: right">1.41x</td>
      <td>0.002</td>
      <td style="text-align: right">45</td>
      <td style="text-align: right">49</td>
    </tr>
    <tr>
      <td>small</td>
      <td style="text-align: right">1,243</td>
      <td style="text-align: right">1,667</td>
      <td style="text-align: right">1.34x</td>
      <td>0.002</td>
      <td style="text-align: right">22</td>
      <td style="text-align: right">24</td>
    </tr>
    <tr>
      <td>medium</td>
      <td style="text-align: right">340,270</td>
      <td style="text-align: right">581,178</td>
      <td style="text-align: right">1.71x</td>
      <td>0.002</td>
      <td style="text-align: right">6,380</td>
      <td style="text-align: right">7,394</td>
    </tr>
    <tr>
      <td>mixed-prod</td>
      <td style="text-align: right">136,311</td>
      <td style="text-align: right">178,287</td>
      <td style="text-align: right">1.31x</td>
      <td>0.002</td>
      <td style="text-align: right">2,340</td>
      <td style="text-align: right">2,595</td>
    </tr>
    <tr>
      <td>array-256</td>
      <td style="text-align: right">303,348</td>
      <td style="text-align: right">474,369</td>
      <td style="text-align: right">1.56x</td>
      <td>0.002</td>
      <td style="text-align: right">6,131</td>
      <td style="text-align: right">7,151</td>
    </tr>
    <tr>
      <td>array-2048</td>
      <td style="text-align: right">2,549,000</td>
      <td style="text-align: right">4,048,000</td>
      <td style="text-align: right">1.59x</td>
      <td>0.002</td>
      <td style="text-align: right">49,135</td>
      <td style="text-align: right">57,341</td>
    </tr>
  </tbody>
</table>

<p>Every comparison reaches statistical significance at p=0.002. The speedups range from 1.31x (<code class="language-plaintext highlighter-rouge">mixed-prod</code>) to 1.71x (<code class="language-plaintext highlighter-rouge">medium</code>).</p>

<p>The allocation column is the structural signature. <code class="language-plaintext highlighter-rouge">number-heavy</code> drops from 80 to 60 allocations (25% reduction). <code class="language-plaintext highlighter-rouge">numeric-boundary</code> drops from 52 to 38 (27% reduction). <code class="language-plaintext highlighter-rouge">array-2048</code> drops from 57,341 to 49,135 (14% reduction). Each eliminated allocation corresponds to a <code class="language-plaintext highlighter-rouge">math/big</code> operation that Schubfach replaces with fixed-width arithmetic.</p>

<h2 id="api-benchmarks-string-dominant-workloads-control-group">API Benchmarks: String-Dominant Workloads (Control Group)</h2>

<p>These workloads contain few or no numbers. The number formatter is not the bottleneck:</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th style="text-align: right">Schubfach (ns/op)</th>
      <th style="text-align: right">Burger-Dybvig (ns/op)</th>
      <th style="text-align: right">Ratio</th>
      <th>p</th>
      <th style="text-align: right">allocs/op (S)</th>
      <th style="text-align: right">allocs/op (BD)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>control-escapes</td>
      <td style="text-align: right">1,335</td>
      <td style="text-align: right">1,251</td>
      <td style="text-align: right">0.94x</td>
      <td>0.002</td>
      <td style="text-align: right">21</td>
      <td style="text-align: right">21</td>
    </tr>
    <tr>
      <td>long-string</td>
      <td style="text-align: right">74,802</td>
      <td style="text-align: right">73,588</td>
      <td style="text-align: right">0.98x</td>
      <td>0.026</td>
      <td style="text-align: right">14</td>
      <td style="text-align: right">14</td>
    </tr>
    <tr>
      <td>rfc-key-sorting</td>
      <td style="text-align: right">3,018</td>
      <td style="text-align: right">2,968</td>
      <td style="text-align: right">0.98x</td>
      <td>0.009</td>
      <td style="text-align: right">47</td>
      <td style="text-align: right">47</td>
    </tr>
    <tr>
      <td>surrogate-pair</td>
      <td style="text-align: right">647</td>
      <td style="text-align: right">640</td>
      <td style="text-align: right">0.99x</td>
      <td>0.002</td>
      <td style="text-align: right">14</td>
      <td style="text-align: right">14</td>
    </tr>
    <tr>
      <td>unicode</td>
      <td style="text-align: right">1,549</td>
      <td style="text-align: right">1,559</td>
      <td style="text-align: right">1.01x</td>
      <td>0.108</td>
      <td style="text-align: right">19</td>
      <td style="text-align: right">19</td>
    </tr>
    <tr>
      <td>deep</td>
      <td style="text-align: right">36,651</td>
      <td style="text-align: right">36,689</td>
      <td style="text-align: right">1.00x</td>
      <td>0.784</td>
      <td style="text-align: right">843</td>
      <td style="text-align: right">843</td>
    </tr>
    <tr>
      <td>deep-64</td>
      <td style="text-align: right">16,237</td>
      <td style="text-align: right">16,240</td>
      <td style="text-align: right">1.00x</td>
      <td>0.589</td>
      <td style="text-align: right">387</td>
      <td style="text-align: right">387</td>
    </tr>
  </tbody>
</table>

<p>Allocation counts are identical across all seven workloads. The number formatter was not invoked (or invoked for a trivial count of values). Burger-Dybvig wins on <code class="language-plaintext highlighter-rouge">control-escapes</code> (6%), <code class="language-plaintext highlighter-rouge">long-string</code> (2%), <code class="language-plaintext highlighter-rouge">rfc-key-sorting</code> (2%), and <code class="language-plaintext highlighter-rouge">surrogate-pair</code> (1%). These are small effects. <code class="language-plaintext highlighter-rouge">deep</code>, <code class="language-plaintext highlighter-rouge">deep-64</code>, and <code class="language-plaintext highlighter-rouge">unicode</code> show no statistically significant difference.</p>

<p>This is the control group. Identical allocation counts confirm that the Schubfach change is isolated to the number formatting path. The small Burger-Dybvig advantages on string workloads are consistent with binary layout, code alignment, or instruction cache effects from the different compilation units.</p>

<h2 id="api-benchmarks-large-payloads">API Benchmarks: Large Payloads</h2>

<p>The <code class="language-plaintext highlighter-rouge">large</code> workload (approximately 460KB of JSON) shows the largest absolute time savings:</p>

<table>
  <thead>
    <tr>
      <th>Workload</th>
      <th style="text-align: right">Schubfach (ns/op)</th>
      <th style="text-align: right">Burger-Dybvig (ns/op)</th>
      <th style="text-align: right">Ratio</th>
      <th>p</th>
      <th style="text-align: right">allocs/op (S)</th>
      <th style="text-align: right">allocs/op (BD)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>canonicalize/large</td>
      <td style="text-align: right">11,928,619</td>
      <td style="text-align: right">20,545,684</td>
      <td style="text-align: right">1.72x</td>
      <td>0.002</td>
      <td style="text-align: right">204,776</td>
      <td style="text-align: right">237,568</td>
    </tr>
    <tr>
      <td>verify/canonical/large</td>
      <td style="text-align: right">11,506,131</td>
      <td style="text-align: right">20,018,040</td>
      <td style="text-align: right">1.74x</td>
      <td>0.002</td>
      <td style="text-align: right">204,775</td>
      <td style="text-align: right">237,559</td>
    </tr>
    <tr>
      <td>verify/noncanonical/large</td>
      <td style="text-align: right">11,908,081</td>
      <td style="text-align: right">20,607,667</td>
      <td style="text-align: right">1.73x</td>
      <td>0.002</td>
      <td style="text-align: right">204,776</td>
      <td style="text-align: right">237,570</td>
    </tr>
  </tbody>
</table>

<p>Schubfach is 72-74% faster across all three modes. The allocation reduction (204,776 vs 237,568, a 14% drop) understates the effect: the eliminated allocations are <code class="language-plaintext highlighter-rouge">math/big</code> operations that carry CPU cost beyond their memory footprint. At this payload size the <code class="language-plaintext highlighter-rouge">math/big</code> overhead accumulates to approximately 8.6ms of additional wall-clock time per canonicalization.</p>

<h2 id="memory-profile-comparison">Memory Profile Comparison</h2>

<p>The memory profile is where the implementation difference is most directly visible.</p>

<p><strong>Burger-Dybvig, jcsfloat allocation footprint (cumulative, showing call chain):</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jcsfloat.FormatDouble             2.91% cum
  └─ jcsfloat.generateDigits     2.01% cum
       └─ jcsfloat.pow10Big      1.29% flat
</code></pre></div></div>

<p>The cumulative values overlap: <code class="language-plaintext highlighter-rouge">FormatDouble</code>’s 2.91% <em>includes</em> <code class="language-plaintext highlighter-rouge">generateDigits</code>’s 2.01%, which <em>includes</em> <code class="language-plaintext highlighter-rouge">pow10Big</code>’s 1.29%. The flat allocation in <code class="language-plaintext highlighter-rouge">pow10Big</code> represents the defensive <code class="language-plaintext highlighter-rouge">new(big.Int).Set()</code> copies.</p>

<p><strong>Schubfach, jcsfloat allocation footprint:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jcsfloat.FormatDouble             1.16% cum
  └─ jcsfloat.appendIntegerFixed  0.57% flat
  └─ jcsfloat.formatECMA         0.20% flat
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">FormatDouble</code> cumulative drops from 2.91% (Burger-Dybvig) to 1.16% (Schubfach). The remaining allocations are in <code class="language-plaintext highlighter-rouge">appendIntegerFixed</code> (byte slice growth for trailing zeros) and <code class="language-plaintext highlighter-rouge">formatECMA</code> (string building). Both are shared formatting concerns, not digit generation.</p>

<p>The <code class="language-plaintext highlighter-rouge">math/big</code> entries are gone entirely. No <code class="language-plaintext highlighter-rouge">pow10Big</code>. No <code class="language-plaintext highlighter-rouge">nat.make</code>. No <code class="language-plaintext highlighter-rouge">nat.set</code>. No <code class="language-plaintext highlighter-rouge">generateDigits</code> (which in Burger-Dybvig performs <code class="language-plaintext highlighter-rouge">big.Int</code> division in a loop). The 700-entry <code class="language-plaintext highlighter-rouge">*big.Int</code> power-of-10 cache is eliminated. The <code class="language-plaintext highlighter-rouge">digitState</code> pool with its nine <code class="language-plaintext highlighter-rouge">big.Int</code> fields (<code class="language-plaintext highlighter-rouge">r</code>, <code class="language-plaintext highlighter-rouge">s</code>, <code class="language-plaintext highlighter-rouge">mPlus</code>, <code class="language-plaintext highlighter-rouge">mMinus</code>, <code class="language-plaintext highlighter-rouge">scratch1</code>, <code class="language-plaintext highlighter-rouge">scratch2</code>, <code class="language-plaintext highlighter-rouge">scratch3</code>, <code class="language-plaintext highlighter-rouge">quot</code>, <code class="language-plaintext highlighter-rouge">rem</code>) is eliminated.</p>

<p>The CPU profile shows the same pattern from the time axis. The <code class="language-plaintext highlighter-rouge">math/big</code> functions that appear in the Burger-Dybvig profile do not appear anywhere in the Schubfach profile:</p>

<table>
  <thead>
    <tr>
      <th>Function</th>
      <th>Burger-Dybvig CPU</th>
      <th>Schubfach CPU</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.mulAddVWW</code></td>
      <td>2.70s (1.32%)</td>
      <td>absent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.nat.mulAddWW</code></td>
      <td>2.33s (1.14%)</td>
      <td>absent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.(*Int).mul</code></td>
      <td>2.28s (1.12%)</td>
      <td>absent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.nat.norm</code></td>
      <td>1.80s (0.88%)</td>
      <td>absent</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">math/big.nat.mul</code></td>
      <td>1.71s (0.84%)</td>
      <td>absent</td>
    </tr>
  </tbody>
</table>

<p>They are replaced by <code class="language-plaintext highlighter-rouge">math/bits.Mul64</code>, which the Go compiler inlines. It does not appear as a named function in the profile because it executes as a single <code class="language-plaintext highlighter-rouge">MULQ</code> instruction on x86-64.</p>

<h2 id="what-the-profiles-reveal-about-the-pipeline">What the Profiles Reveal About the Pipeline</h2>

<p>The top application-level functions in both CPU profiles are structurally identical:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Schubfach:
  jcs.serializeString             7.60s   3.73%
  jcstoken.(*parser).parseString  6.30s   3.09%
  jcs.validateValueTree           2.73s   1.34%
  jcs.serializeObject             2.60s   1.28%

Burger-Dybvig:
  jcs.serializeString             6.97s   3.41%
  jcstoken.(*parser).parseString  5.25s   2.57%
  jcs.validateValueTree           2.64s   1.29%
  jcs.serializeObject             2.48s   1.21%
</code></pre></div></div>

<p>(Both profiles are dominated by runtime functions above these: <code class="language-plaintext highlighter-rouge">strconv.leftShift</code>, <code class="language-plaintext highlighter-rouge">runtime.nextFreeFast</code>, <code class="language-plaintext highlighter-rouge">runtime.memclrNoHeapPointers</code>, and <code class="language-plaintext highlighter-rouge">aeshashbody</code> occupy the top positions in both builds. The application-level functions listed here appear below them.)</p>

<p>String serialization, string parsing, value tree validation, and object serialization (which includes UTF-16 key sorting via <code class="language-plaintext highlighter-rouge">sort.Slice</code>) dominate the application-level profile in both builds. These are the same code in both builds. Replacing the number formatter moved it off the critical path for number-dense payloads, but the pipeline bottleneck for general workloads was never number formatting. It was always parsing and serialization.</p>

<p>The Schubfach profile shows higher absolute times for these functions (7.60s vs 6.97s for <code class="language-plaintext highlighter-rouge">serializeString</code>) because the benchmark ran more iterations in the same wall-clock budget. Schubfach is faster per-call, so Go’s benchmark framework runs more iterations, and the cumulative CPU time in the shared code paths increases accordingly.</p>

<p>The next meaningful performance improvement for json-canon is not in number formatting. It is in the parser (which builds a full value tree with heap-allocated strings for every key and value) and the serializer (which sorts keys via <code class="language-plaintext highlighter-rouge">sort.Slice</code> with a closure). Those are architectural decisions that would require API changes to address, not algorithm swaps within an existing interface.</p>

<h2 id="geometric-means">Geometric Means</h2>

<p>benchstat reports the following geometric means across all 51 API benchmark comparisons (n=6):</p>

<table>
  <thead>
    <tr>
      <th>Metric</th>
      <th>Schubfach</th>
      <th>Burger-Dybvig</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>sec/op</td>
      <td>12.32us</td>
      <td>16.18us</td>
      <td>+31.33% faster</td>
    </tr>
    <tr>
      <td>B/s</td>
      <td>31.20 MiB/s</td>
      <td>23.76 MiB/s</td>
      <td>+31.33% higher throughput</td>
    </tr>
    <tr>
      <td>B/op</td>
      <td>15.70 KiB</td>
      <td>16.30 KiB</td>
      <td>-3.81% less memory</td>
    </tr>
    <tr>
      <td>allocs/op</td>
      <td>157.7</td>
      <td>174.3</td>
      <td>-10.54% fewer allocations</td>
    </tr>
  </tbody>
</table>

<p>The B/op reduction (3.81%) is smaller than the allocs/op reduction (10.54%) because the eliminated allocations are small (<code class="language-plaintext highlighter-rouge">big.Int</code> internal slices, typically 8-64 bytes each). The throughput improvement exceeds the allocation reduction because the saved allocations are not just memory operations. They are also CPU operations: <code class="language-plaintext highlighter-rouge">big.Int</code> multiply involves digit-by-digit multiplication with carry propagation, normalization, and bounds checking. Eliminating the allocations eliminates the arithmetic they supported.</p>

<h2 id="statistical-evidence">Statistical Evidence</h2>

<p>The API benchmarks were run with <code class="language-plaintext highlighter-rouge">-count=6</code>. benchstat computes valid confidence intervals for all 51 comparisons.</p>

<p>Of the 51 comparisons, 40 reach statistical significance at p &lt; 0.05. The 11 that do not are workloads where the number formatter is irrelevant: <code class="language-plaintext highlighter-rouge">deep</code> (p=0.784), <code class="language-plaintext highlighter-rouge">deep-64</code> (p=0.589), <code class="language-plaintext highlighter-rouge">unicode</code> (p=0.108 to 0.781 across modes), <code class="language-plaintext highlighter-rouge">surrogate-pair/noncanonical</code> (p=0.058), <code class="language-plaintext highlighter-rouge">long-string/canonical</code> (p=0.818), and <code class="language-plaintext highlighter-rouge">rfc-key-sorting/canonical</code> (p=0.240). These are the exact workloads where no performance difference is expected.</p>

<p>Every number-dense workload reaches p=0.002 (the minimum achievable p-value with n=6 using a permutation test). The Burger-Dybvig wins on string-dominant workloads also reach significance (<code class="language-plaintext highlighter-rouge">control-escapes</code> at p=0.002, 6% slower for Schubfach), confirming these are real effects from binary layout or instruction cache differences, not measurement noise.</p>

<p>The CLI end-to-end results (9 runs per comparison, permutation test with 3,000 resamples) provide independent corroboration. Five of 28 CLI comparisons reach significance at p &lt; 0.05: <code class="language-plaintext highlighter-rouge">canonicalize/array-2048</code> at p=0.0007 (1.32x, d=-3.14), <code class="language-plaintext highlighter-rouge">canonicalize/nested-mixed</code> at p=0.0017 (1.36x, d=-2.01), <code class="language-plaintext highlighter-rouge">canonicalize/numeric-boundary</code> at p=0.036 (1.22x, d=-1.08), <code class="language-plaintext highlighter-rouge">verify/array-2048</code> at p=0.0007 (1.30x, d=-3.97), and <code class="language-plaintext highlighter-rouge">verify/array-256</code> at p=0.038 (1.22x, d=-1.09). All five favor Schubfach. The remaining 23 comparisons do not reach significance because process startup noise dominates at the 1-7ms timescales of CLI execution.</p>

<h2 id="the-decision">The Decision</h2>

<p>The conformance gate is clean. The performance improvement is statistically significant across every number-dense workload. The implementation is a drop-in replacement: same <code class="language-plaintext highlighter-rouge">FormatDouble</code> signature, same error types, same output bytes for every tested input.</p>

<p>Burger-Dybvig was the right choice for the initial implementation. It was straightforward to verify against the <a href="https://dl.acm.org/doi/10.1145/249069.231397">original paper</a> (Burger and Dybvig, PLDI 1996). It was straightforward to debug when the oracle vectors caught errors during initial development. It was straightforward to reason about when modifying the tie-breaking policy for ECMA-262 compliance. It served its purpose: it proved the approach was viable and produced correct output while the rest of the pipeline was built around it.</p>

<p>Schubfach replaces Burger-Dybvig in json-canon. The change ships as a minor version. The API does not change. The output does not change. The canonical bytes for every input remain identical. What changes is the cost of producing them: fewer allocations, less memory, higher throughput on number-dense workloads, and the elimination of <code class="language-plaintext highlighter-rouge">math/big</code> from the formatting path’s runtime dependency graph.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-05</td>
      <td>Initial publication.</td>
    </tr>
  </tbody>
</table>

<hr />

<p><em>The implementation lives in the <a href="https://github.com/lattice-substrate/json-canon/tree/main/jcsfloat">jcsfloat</a> package of <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library written in Go.</em></p>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="algorithms" /><category term="ieee754" /><category term="performance" /><summary type="html"><![CDATA[Replacing the Burger-Dybvig multiprecision float formatter with the Schubfach fixed-width algorithm: same ECMA-262 conformance contract, same output bytes, fewer allocations, higher throughput on number-dense workloads. Validated against 286,362 oracle test vectors.]]></summary></entry><entry><title type="html">IEEE 754 Compliance Does Not Mean Platform Independence</title><link href="https://lattice-substrate.github.io/blog/2026/03/04/fma-go-floating-point-determinism/" rel="alternate" type="text/html" title="IEEE 754 Compliance Does Not Mean Platform Independence" /><published>2026-03-04T00:00:00+00:00</published><updated>2026-03-04T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/03/04/fma-go-floating-point-determinism</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/03/04/fma-go-floating-point-determinism/"><![CDATA[<p>In December 2025, the DoltHub team found that their Go database was producing different query plans on ARM Macs and x86 Windows machines. The trace led to a single expression in the query planner’s cost model:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="n">lBest</span><span class="o">*</span><span class="n">seqIOCostFactor</span> <span class="o">+</span> <span class="n">selfJoinCard</span><span class="o">*</span><span class="p">(</span><span class="n">randIOCostFactor</span><span class="o">+</span><span class="n">seqIOCostFactor</span><span class="p">),</span> <span class="no">nil</span>
</code></pre></div></div>

<p>On ARM, the Go compiler emitted a Fused-Multiply-Add instruction for this expression. On x86, it did not. The FMA rounded once instead of twice, producing a result that differed by one unit in the last place. That was enough to flip a less-than comparison between two nearly identical plan costs and select a different join order.</p>

<p>The values: <code class="language-plaintext highlighter-rouge">3.09928472e+06</code> on one platform, <code class="language-plaintext highlighter-rouge">3.0992847200000007e+06</code> on the other. Both IEEE 754 compliant. Both correct. Different bits.</p>

<p>The DoltHub team <a href="https://www.dolthub.com/blog/2025-12-19-golang-ieee-strictness/">documented the discovery and the fix</a> on their blog. The fix is interesting because it reveals a deliberate design choice in the Go compiler, and that design choice has implications for any Go program whose correctness depends on identical floating-point output across platforms.</p>

<h2 id="fma-at-the-hardware-level">FMA at the Hardware Level</h2>

<p>A Fused-Multiply-Add computes <code class="language-plaintext highlighter-rouge">a * b + c</code> as a single operation with a single rounding step. Without FMA, the CPU computes <code class="language-plaintext highlighter-rouge">a * b</code>, rounds the result to the destination precision, adds <code class="language-plaintext highlighter-rouge">c</code>, and rounds again. Two operations, two roundings. FMA eliminates the intermediate rounding. The result is generally <em>more</em> accurate, closer to the true mathematical value, because only one rounding error is introduced instead of two. But when the intermediate rounding would have rounded in a different direction than the final rounding, the FMA and non-FMA paths produce results that differ by one ULP.</p>

<p>IEEE 754 Section 5.4.1 defines <code class="language-plaintext highlighter-rouge">fusedMultiplyAdd</code> as a sanctioned operation. As Doug Priest noted in his appendix to Goldberg’s “What Every Computer Scientist Should Know About Floating-Point Arithmetic,” the standard requires correct rounding to the destination precision but does not require that intermediate precision be determined by the programmer’s source code.</p>

<p>The hardware landscape: ARMv8 chips universally support FMA (<code class="language-plaintext highlighter-rouge">FMADD</code>/<code class="language-plaintext highlighter-rouge">FMSUB</code>). On x86, FMA requires the FMA3 extension, available since Haswell (Intel, 2013) and Piledriver (AMD, 2012), but absent on older and some low-power chips. A compiler targeting ARM can emit FMA for any multiply-add expression. The same compiler targeting older x86 cannot. The same source code, the same compiler version, the same optimization level. Different instructions, different rounding, different bits.</p>

<h2 id="gos-compiler-policy">Go’s Compiler Policy</h2>

<p>Go addressed FMA in <a href="https://github.com/golang/go/issues/17895">issue #17895</a>, accepted for Go 1.9. The consensus:</p>

<blockquote>
  <p>A <code class="language-plaintext highlighter-rouge">float64</code> conversion should be an explicit signal that a rounded <code class="language-plaintext highlighter-rouge">float64</code> should be materialized.</p>
</blockquote>

<p>The Go compiler is free to fuse <code class="language-plaintext highlighter-rouge">a*b + c</code> into a single FMA instruction whenever the target hardware supports it. The opt-out mechanism is an explicit <code class="language-plaintext highlighter-rouge">float64()</code> cast, which forces the compiler to materialize a rounded intermediate result:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// FMA allowed -- compiler may fuse on ARM</span>
<span class="n">result</span> <span class="o">:=</span> <span class="n">a</span><span class="o">*</span><span class="n">b</span> <span class="o">+</span> <span class="n">c</span>

<span class="c">// FMA prevented -- explicit rounding after multiply</span>
<span class="n">result</span> <span class="o">:=</span> <span class="kt">float64</span><span class="p">(</span><span class="n">a</span><span class="o">*</span><span class="n">b</span><span class="p">)</span> <span class="o">+</span> <span class="n">c</span>
</code></pre></div></div>

<p>The DoltHub fix applied this directly:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">return</span> <span class="kt">float64</span><span class="p">(</span><span class="n">lBest</span><span class="o">*</span><span class="n">seqIOCostFactor</span><span class="p">)</span> <span class="o">+</span>
    <span class="kt">float64</span><span class="p">(</span><span class="n">selfJoinCard</span><span class="o">*</span><span class="p">(</span><span class="n">randIOCostFactor</span><span class="o">+</span><span class="n">seqIOCostFactor</span><span class="p">)),</span> <span class="no">nil</span>
</code></pre></div></div>

<p>This is a clean solution for expressions where you can identify and annotate the vulnerable multiply-add patterns. For a query planner, that may be a handful of cost-calculation expressions. For a digit-generation algorithm that involves extended sequences of floating-point arithmetic, the question becomes harder: how do you ensure that <em>every</em> expression in the pipeline is either not fusible or explicitly guarded?</p>

<h2 id="standard-library-formatters-as-implementation-details">Standard Library Formatters as Implementation Details</h2>

<p>In January 2019, Anders Rundgren, the author of RFC 8785, filed <a href="https://github.com/golang/go/issues/29491">Go issue #29491</a> against <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code>. His reference JCS implementation was producing incorrect rounding on Windows/amd64 for specific values:</p>

<table>
  <thead>
    <tr>
      <th>IEEE 754 Hex</th>
      <th><code class="language-plaintext highlighter-rouge">FormatFloat</code> Returned</th>
      <th>Correct Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">439babe4b56e8a39</code></td>
      <td><code class="language-plaintext highlighter-rouge">498484681984085560</code></td>
      <td><code class="language-plaintext highlighter-rouge">498484681984085570</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">c4dee27bdef22651</code></td>
      <td><code class="language-plaintext highlighter-rouge">-5.8339553793802236e+23</code></td>
      <td><code class="language-plaintext highlighter-rouge">-5.8339553793802237e+23</code></td>
    </tr>
  </tbody>
</table>

<p>The root cause was a bug in <code class="language-plaintext highlighter-rouge">strconv</code>’s <code class="language-plaintext highlighter-rouge">roundShortest</code> function. The fix shipped in Go 1.13. The RFC author’s own reference implementation, broken by the standard library it delegated to.</p>

<p>This is not an indictment of <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code>. It is a high-quality implementation. But it is a general-purpose formatting function whose underlying algorithm has changed over Go’s history: Grisu3 with exact-arithmetic fallback through Go 1.16, Ryu from <a href="https://go.dev/doc/go1.17">Go 1.17</a>, and <a href="https://go.googlesource.com/go/+/refs/tags/go1.26.0/src/internal/strconv/ftoadbox.go">Dragonbox from Go 1.26</a>. Each transition preserved round-trip correctness. None contractually guaranteed digit-sequence stability. The <code class="language-plaintext highlighter-rouge">strconv</code> documentation guarantees a shortest round-trip representation. It does not guarantee which valid shortest representation it will choose when two are equally correct, and it does not guarantee that the choice will remain stable across releases.</p>

<p>For a general-purpose formatting function, this is fine. Any shortest round-trip string is equally useful. For a canonicalization scheme, “valid but different” is a conformance failure, because the specification requires one specific digit sequence for each IEEE 754 bit pattern.</p>

<h2 id="a-common-pattern-in-jcs-implementations">A Common Pattern in JCS Implementations</h2>

<p><a href="https://github.com/gowebpki/jcs">gowebpki/jcs</a>, the maintained Go fork of Anders Rundgren’s RFC 8785 reference implementation, takes the obvious approach to number formatting: delegate to the standard library.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">NumberToJSON</span><span class="p">(</span><span class="n">ieeeF64</span> <span class="kt">float64</span><span class="p">)</span> <span class="p">(</span><span class="n">res</span> <span class="kt">string</span><span class="p">,</span> <span class="n">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">ieeeU64</span> <span class="o">:=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">ieeeF64</span><span class="p">)</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">ieeeU64</span> <span class="o">&amp;</span> <span class="n">invalidPattern</span><span class="p">)</span> <span class="o">==</span> <span class="n">invalidPattern</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">"null"</span><span class="p">,</span> <span class="n">errors</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="s">"Invalid JSON number: "</span> <span class="o">+</span>
            <span class="n">strconv</span><span class="o">.</span><span class="n">FormatUint</span><span class="p">(</span><span class="n">ieeeU64</span><span class="p">,</span> <span class="m">16</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">ieeeF64</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">"0"</span><span class="p">,</span> <span class="no">nil</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="n">sign</span> <span class="kt">string</span> <span class="o">=</span> <span class="s">""</span>
    <span class="k">if</span> <span class="n">ieeeF64</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="n">ieeeF64</span> <span class="o">=</span> <span class="o">-</span><span class="n">ieeeF64</span>
        <span class="n">sign</span> <span class="o">=</span> <span class="s">"-"</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="n">format</span> <span class="kt">byte</span> <span class="o">=</span> <span class="sc">'e'</span>
    <span class="k">if</span> <span class="n">ieeeF64</span> <span class="o">&lt;</span> <span class="m">1e+21</span> <span class="o">&amp;&amp;</span> <span class="n">ieeeF64</span> <span class="o">&gt;=</span> <span class="m">1e-6</span> <span class="p">{</span>
        <span class="n">format</span> <span class="o">=</span> <span class="sc">'f'</span>
    <span class="p">}</span>

    <span class="c">// The following should (in "theory") do the trick:</span>
    <span class="n">es6Formatted</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">FormatFloat</span><span class="p">(</span><span class="n">ieeeF64</span><span class="p">,</span> <span class="n">format</span><span class="p">,</span> <span class="o">-</span><span class="m">1</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>

    <span class="n">exponent</span> <span class="o">:=</span> <span class="n">strings</span><span class="o">.</span><span class="n">IndexByte</span><span class="p">(</span><span class="n">es6Formatted</span><span class="p">,</span> <span class="sc">'e'</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">exponent</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">es6Formatted</span><span class="p">[</span><span class="n">exponent</span><span class="o">+</span><span class="m">2</span><span class="p">]</span> <span class="o">==</span> <span class="sc">'0'</span> <span class="p">{</span>
            <span class="n">es6Formatted</span> <span class="o">=</span> <span class="n">es6Formatted</span><span class="p">[</span><span class="o">:</span><span class="n">exponent</span><span class="o">+</span><span class="m">2</span><span class="p">]</span> <span class="o">+</span>
                <span class="n">es6Formatted</span><span class="p">[</span><span class="n">exponent</span><span class="o">+</span><span class="m">3</span><span class="o">:</span><span class="p">]</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">sign</span> <span class="o">+</span> <span class="n">es6Formatted</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is a reasonable design. The function handles the special cases (NaN, infinity, negative zero, sign extraction, ECMA-262 format selection, exponent normalization) and delegates the hard part, digit generation, to <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code>. This is the pattern most JCS implementations follow, in Go and in other languages, because digit generation is genuinely difficult and the standard library already does it.</p>

<p>The coupling this creates is straightforward: if <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code> changes its output for a given input, the canonical output changes. Whether that matters depends on requirements. For applications where JCS output is compared within a single Go version on a single architecture, it may not matter at all. For applications where canonical output must be identical across Go versions, across platforms, or across language implementations, the coupling is the mechanism by which platform differences propagate into the canonical output.</p>

<h2 id="approaches-to-platform-independent-digit-generation">Approaches to Platform-Independent Digit Generation</h2>

<p>Two approaches eliminate platform dependence in the digit-generation pipeline. They make different tradeoffs.</p>

<h3 id="arbitrary-precision-integer-arithmetic">Arbitrary-Precision Integer Arithmetic</h3>

<p>The Burger-Dybvig algorithm represents the float value and its rounding boundaries as ratios of arbitrary-precision integers (<code class="language-plaintext highlighter-rouge">math/big.Int</code> in Go). After the initial <code class="language-plaintext highlighter-rouge">math.Float64bits</code> bit-cast extracts the raw IEEE 754 pattern, every subsequent operation is exact integer arithmetic:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Digit extraction: multiply R by 10, divide by S</span>
<span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">bigTen</span><span class="p">)</span>
<span class="n">d</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span>
<span class="n">d</span><span class="o">.</span><span class="n">DivMod</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">big.Int.Mul</code>, <code class="language-plaintext highlighter-rouge">big.Int.DivMod</code>, <code class="language-plaintext highlighter-rouge">big.Int.Cmp</code>: these are integer operations executed on the CPU’s integer ALU. The Go compiler cannot emit FMA instructions for them because FMA is a floating-point instruction. The guarantee holds not because the code is carefully written to avoid fusible expressions, but because the types involved make fusion inapplicable.</p>

<p>The single floating-point operation in the pipeline is a <code class="language-plaintext highlighter-rouge">log10</code> estimate used for initial decimal scaling. This estimate is allowed to be wrong. Two integer fixup passes correct it using exact <code class="language-plaintext highlighter-rouge">big.Int.Cmp</code> comparisons. An FMA-affected <code class="language-plaintext highlighter-rouge">log10</code> estimate that is off by one is corrected the same way as any other off-by-one estimate.</p>

<p>The tradeoff is performance. <code class="language-plaintext highlighter-rouge">math/big</code> operations allocate heap memory and are substantially slower than fixed-width integer arithmetic. For a canonicalization library where the output contract is more important than throughput, this is an acceptable cost. For a high-throughput formatter, it may not be.</p>

<p><a href="https://lattice-substrate.github.io/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig/">Shortest Round-Trip: Implementing IEEE 754 to Decimal Conversion in Go</a> covers the full Burger-Dybvig implementation. The determinism claim is backed by an <a href="https://lattice-substrate.github.io/blog/2026/02/24/proving-determinism-evidence-release/">offline replay harness</a> that runs 60 independent executions across 12 Linux environments on both x86_64 and arm64, comparing SHA-256 digests of canonical output.</p>

<h3 id="fixed-width-integer-algorithms">Fixed-Width Integer Algorithms</h3>

<p>Ryu and Schubfach (the algorithm underlying Dragonbox) avoid arbitrary-precision arithmetic entirely. They use fixed-width 64-bit and 128-bit integer operations with precomputed lookup tables. Their core paths are substantially faster than Burger-Dybvig.</p>

<p>These core paths are mostly integer math. A Ryu implementation’s critical loop looks roughly like this:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// Ryu core: fixed-width 128-bit multiply against precomputed table</span>
<span class="n">vr</span> <span class="o">:=</span> <span class="n">mulShift64</span><span class="p">(</span><span class="n">m2</span><span class="p">,</span> <span class="n">table</span><span class="p">[</span><span class="n">q</span><span class="p">],</span> <span class="n">j</span><span class="p">)</span>  <span class="c">// uint64 multiply + shift</span>
<span class="n">vp</span> <span class="o">:=</span> <span class="n">mulShift64</span><span class="p">(</span><span class="n">m2</span><span class="o">+</span><span class="m">1</span><span class="p">,</span> <span class="n">table</span><span class="p">[</span><span class="n">q</span><span class="p">],</span> <span class="n">j</span><span class="p">)</span>
<span class="n">vm</span> <span class="o">:=</span> <span class="n">mulShift64</span><span class="p">(</span><span class="n">m2</span><span class="o">-</span><span class="m">1</span><span class="p">,</span> <span class="n">table</span><span class="p">[</span><span class="n">q</span><span class="p">],</span> <span class="n">j</span><span class="p">)</span>
<span class="c">// ... digit extraction from vr, vp, vm using integer division</span>
</code></pre></div></div>

<p>These are integer multiplications and shifts, not floating-point multiply-adds. A careful Go implementation of Ryu or Schubfach would likely be immune to FMA for the same structural reason as Burger-Dybvig: the critical operations use integer types that the compiler cannot fuse.</p>

<p>The risk is at the edges. A Ryu port might use a floating-point <code class="language-plaintext highlighter-rouge">log10</code> estimator, or a helper function that computes an exponent approximation through <code class="language-plaintext highlighter-rouge">float64</code> arithmetic. These expressions could be fusible. Where Burger-Dybvig with <code class="language-plaintext highlighter-rouge">math/big</code> makes FMA inapplicable by type across the entire pipeline, a fixed-width implementation needs verification that:</p>

<ul>
  <li>No floating-point helper functions (log10 estimators, exponent approximations) use expressions the compiler could fuse.</li>
  <li>No intermediate value is stored in a <code class="language-plaintext highlighter-rouge">float64</code> where FMA could change the rounding.</li>
  <li>The lookup table generation does not depend on platform-specific floating-point behavior.</li>
  <li>These properties hold across Go compiler versions, as the compiler’s fusion heuristics evolve.</li>
</ul>

<p>This is not a fundamental obstacle. It is ongoing maintenance work, analogous to the <code class="language-plaintext highlighter-rouge">float64()</code> cast discipline that the DoltHub team applied to their cost model. For a project where the performance difference between fixed-width and arbitrary-precision arithmetic matters, the audit cost is well worth paying.</p>

<h2 id="the-broader-observation">The Broader Observation</h2>

<p>IEEE 754 compliance guarantees that each result is correctly rounded to the destination precision. It does not guarantee that two IEEE 754 compliant implementations produce identical results for the same source expression, because the standard permits operations like FMA that change how many roundings occur.</p>

<p>For most software, this does not matter. A query planner that picks a different join order on ARM versus x86 is a correctness problem only because it affects deterministic testing. The alternative plan may be equally efficient. A numerical simulation that differs by one ULP across platforms is within tolerance for almost all applications. FMA is, in most contexts, a net benefit: faster and more accurate.</p>

<p>For software whose correctness property is byte-identical output across platforms (canonicalization schemes, content-addressed storage, reproducible builds), the platform independence of the digit-generation pipeline is an architectural requirement, not a detail. Delegating to <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code> binds the output to the standard library’s current algorithm, its FMA exposure on the current platform, and its stability guarantees, which are round-trip correctness for a shortest representation, not digit-sequence stability. The approaches to owning the pipeline differ in how they achieve independence and what they trade for it, but the requirement itself is a consequence of IEEE 754’s design: compliance governs accuracy, not identity.</p>

<p>The Burger-Dybvig approach discussed above is the one used in <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library written in Go.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Restored Dragonbox/Go 1.26 claim after verification against source tree at <code class="language-plaintext highlighter-rouge">go1.26.0</code> tag</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Tightened FMA hardware section; restructured stdlib section to lead with bug report; corrected Ryu version (Go 1.17, not 1.14); removed Dragonbox/Go 1.26 claim (later found to be incorrect, see above); added Ryu code sketch to balance approaches section; added cross-architecture evidence link</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Shortened title; removed series references (standalone article); corrected x86 FMA/AVX2 distinction; consolidated redundant links</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Complete rewrite: restructured as subject-oriented technical article</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="ieee754" /><category term="determinism" /><category term="floating-point" /><summary type="html"><![CDATA[Fused-Multiply-Add instructions produce IEEE 754 compliant results that differ across platforms. What this means for Go programs that depend on identical floating-point output, and what the available approaches to platform-independent digit generation look like.]]></summary></entry><entry><title type="html">Shortest Round-Trip: Implementing IEEE 754 to Decimal Conversion in Go</title><link href="https://lattice-substrate.github.io/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig/" rel="alternate" type="text/html" title="Shortest Round-Trip: Implementing IEEE 754 to Decimal Conversion in Go" /><published>2026-02-27T00:00:00+00:00</published><updated>2026-02-27T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/02/27/shortest-roundtrip-ieee754-burger-dybvig/"><![CDATA[<p>Every programmer has seen this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0.1 + 0.2 = 0.30000000000000004
</code></pre></div></div>

<p>The joke is that floating-point arithmetic is broken. It isn’t. IEEE 754 is doing exactly what it specifies. The problem surfaces when you need to <em>serialize</em> these values to text, and when two different systems need to produce <em>exactly the same text</em> for the same value.</p>

<p>This is what <a href="https://www.rfc-editor.org/rfc/rfc8785">RFC 8785</a> (JSON Canonicalization Scheme) requires: byte-deterministic JSON output. And the hardest part of that requirement is number formatting. You need the <em>shortest</em> decimal string that, when parsed back, recovers the original IEEE 754 bits. You need to agree on tie-breaking when two representations are equally short. And you need to match the exact output format specified by <a href="https://tc39.es/ecma262/#sec-numeric-types-number-tostring">ECMA-262 Number serialization</a>, because that’s what RFC 8785 mandates.</p>

<p>Go’s <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code> is a high-quality shortest-round-trip formatter, but it is not an ECMA-262 conformance contract. So I implemented the Burger-Dybvig algorithm from scratch in Go, validated against pinned oracle test vectors. This article walks through the entire implementation.</p>

<h2 id="ieee-754-anatomy-64-bits-of-structure">IEEE 754 Anatomy: 64 Bits of Structure</h2>

<p>Before generating digits, you need to understand the raw material. An IEEE 754 double-precision float is 64 bits:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[1 bit: sign] [11 bits: biased exponent] [52 bits: mantissa]
</code></pre></div></div>

<p>The value it represents (for normal numbers) is:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(-1)^sign × 1.mantissa × 2^(exponent - 1023)
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">1.mantissa</code> is key: normal numbers have an implicit leading 1 bit, giving you 53 bits of precision total. Subnormal numbers (biased exponent = 0) lose the implicit bit, giving <code class="language-plaintext highlighter-rouge">0.mantissa × 2^(-1022)</code>.</p>

<p>Here’s how the implementation extracts these parts:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">decodeFloatParts</span><span class="p">(</span><span class="n">f</span> <span class="kt">float64</span><span class="p">)</span> <span class="n">floatParts</span> <span class="p">{</span>
    <span class="n">bits</span> <span class="o">:=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
    <span class="n">mantissa</span> <span class="o">:=</span> <span class="n">bits</span> <span class="o">&amp;</span> <span class="p">((</span><span class="kt">uint64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="m">52</span><span class="p">)</span> <span class="o">-</span> <span class="m">1</span><span class="p">)</span>
    <span class="n">expBits</span> <span class="o">:=</span> <span class="n">exponentBits</span><span class="p">(</span><span class="n">bits</span><span class="p">)</span>
    <span class="n">biasedExp</span> <span class="o">:=</span> <span class="kt">int</span><span class="p">(</span><span class="n">expBits</span><span class="p">)</span>

    <span class="n">fMant</span> <span class="o">:=</span> <span class="n">mantissa</span>
    <span class="n">fExp</span> <span class="o">:=</span> <span class="m">1</span> <span class="o">-</span> <span class="m">1023</span> <span class="o">-</span> <span class="m">52</span>
    <span class="k">if</span> <span class="n">biasedExp</span> <span class="o">!=</span> <span class="m">0</span> <span class="p">{</span>
        <span class="n">fMant</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="m">52</span><span class="p">)</span> <span class="o">|</span> <span class="n">mantissa</span>
        <span class="n">fExp</span> <span class="o">=</span> <span class="n">biasedExp</span> <span class="o">-</span> <span class="m">1023</span> <span class="o">-</span> <span class="m">52</span>
    <span class="p">}</span>

    <span class="n">lowerBoundary</span> <span class="o">:=</span> <span class="n">biasedExp</span> <span class="o">&gt;</span> <span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="n">mantissa</span> <span class="o">==</span> <span class="m">0</span>

    <span class="k">return</span> <span class="n">floatParts</span><span class="p">{</span>
        <span class="n">mantissa</span><span class="o">:</span>      <span class="n">mantissa</span><span class="p">,</span>
        <span class="n">biasedExp</span><span class="o">:</span>     <span class="n">biasedExp</span><span class="p">,</span>
        <span class="n">fMant</span><span class="o">:</span>         <span class="n">fMant</span><span class="p">,</span>
        <span class="n">fExp</span><span class="o">:</span>          <span class="n">fExp</span><span class="p">,</span>
        <span class="n">lowerBoundary</span><span class="o">:</span> <span class="n">lowerBoundary</span><span class="p">,</span>
        <span class="n">isEven</span><span class="o">:</span>        <span class="n">fMant</span><span class="o">%</span><span class="m">2</span> <span class="o">==</span> <span class="m">0</span><span class="p">,</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two things to note here. First, the subnormal branch: when <code class="language-plaintext highlighter-rouge">biasedExp == 0</code>, there’s no implicit leading 1, and the effective exponent is fixed at <code class="language-plaintext highlighter-rouge">2^(-1074)</code> (the smallest representable power). Second, the <code class="language-plaintext highlighter-rouge">lowerBoundary</code> flag: when the mantissa is zero and the exponent is above the minimum normal range, the float sits at a power-of-two boundary where the gap to the <em>lower</em> adjacent representable is half the gap to the <em>upper</em> adjacent. This asymmetry matters for the algorithm.</p>

<p>The exponent extraction itself works on the raw bit pattern:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">exponentBits</span><span class="p">(</span><span class="n">bits</span> <span class="kt">uint64</span><span class="p">)</span> <span class="kt">uint16</span> <span class="p">{</span>
    <span class="n">hi</span> <span class="o">:=</span> <span class="kt">byte</span><span class="p">((</span><span class="n">bits</span> <span class="o">&gt;&gt;</span> <span class="m">56</span><span class="p">)</span> <span class="o">&amp;</span> <span class="m">0xFF</span><span class="p">)</span>
    <span class="n">lo</span> <span class="o">:=</span> <span class="kt">byte</span><span class="p">((</span><span class="n">bits</span> <span class="o">&gt;&gt;</span> <span class="m">48</span><span class="p">)</span> <span class="o">&amp;</span> <span class="m">0xFF</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">(</span><span class="kt">uint16</span><span class="p">(</span><span class="n">hi</span><span class="o">&amp;</span><span class="m">0x7F</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="m">4</span><span class="p">)</span> <span class="o">|</span> <span class="kt">uint16</span><span class="p">(</span><span class="n">lo</span><span class="o">&gt;&gt;</span><span class="m">4</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This reconstructs the 11-bit biased exponent by extracting the relevant bytes and masking away the sign bit.</p>

<h2 id="the-core-insight-why-you-need-multiprecision-arithmetic">The Core Insight: Why You Need Multiprecision Arithmetic</h2>

<p>The Burger-Dybvig algorithm answers this question: what is the shortest decimal string <code class="language-plaintext highlighter-rouge">d</code> such that <code class="language-plaintext highlighter-rouge">parse(d) == f</code> for our original float <code class="language-plaintext highlighter-rouge">f</code>?</p>

<p>To answer it, you need to compute <em>exact</em> boundaries. Every float <code class="language-plaintext highlighter-rouge">f</code> has two adjacent representable values. The “shortest round-trip” string must map back to <code class="language-plaintext highlighter-rouge">f</code> and not to either neighbor. This means computing the midpoints between <code class="language-plaintext highlighter-rouge">f</code> and its neighbors with <em>exact</em> arithmetic, not floating-point arithmetic, which would introduce the very imprecision you’re trying to eliminate.</p>

<p>The algorithm represents the value and its boundaries as ratios of big integers. For a float with fractional mantissa <code class="language-plaintext highlighter-rouge">fMant</code> and exponent <code class="language-plaintext highlighter-rouge">fExp</code>, the value is <code class="language-plaintext highlighter-rouge">fMant × 2^fExp</code>. The boundaries M- and M+ define the interval within which any decimal representation will round back to <code class="language-plaintext highlighter-rouge">f</code>.</p>

<h2 id="state-initialization-r-s-m-m-">State Initialization: R, S, M+, M-</h2>

<p>The algorithm works with four multiprecision integers:</p>

<ul>
  <li><strong>R</strong> (remainder): represents the current value, scaled into the digit-extraction space</li>
  <li><strong>S</strong> (scale): the denominator; digits are extracted by dividing R by S</li>
  <li><strong>M+</strong> (upper margin): how far R can grow before rounding to the next float</li>
  <li><strong>M-</strong> (lower margin): how far R can shrink before rounding to the previous float</li>
</ul>

<p>Initialization depends on the exponent sign:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">initScaledPositiveExp</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">parts</span> <span class="n">floatParts</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">parts</span><span class="o">.</span><span class="n">lowerBoundary</span> <span class="p">{</span>
        <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">SetUint64</span><span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">fMant</span><span class="p">)</span>
        <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">1</span><span class="p">)</span>  <span class="c">// r = fMant × 2^(fExp+1)</span>
        <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">2</span><span class="p">)</span>               <span class="c">// s = 2</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
        <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="p">)</span> <span class="c">// m+ = 2^fExp</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">)</span>     <span class="c">// m- = m+</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="c">// Lower boundary: asymmetric margins</span>
    <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">SetUint64</span><span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">fMant</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">2</span><span class="p">)</span>       <span class="c">// r = fMant × 2^(fExp+2)</span>
    <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">4</span><span class="p">)</span>                    <span class="c">// s = 4</span>
    <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">1</span><span class="p">)</span>   <span class="c">// m+ = 2^(fExp+1)</span>
    <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="p">,</span> <span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="p">)</span>    <span class="c">// m- = 2^fExp (half of m+)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">lowerBoundary</code> case (mantissa is zero, exponent above minimum) requires special handling. At a power-of-two boundary, the float sits at the <em>top</em> of one binade and the <em>bottom</em> of the next. The gap to the lower neighbor is half the gap to the upper neighbor. The algorithm accounts for this by doubling all values (multiplying R and S by 2) and setting <code class="language-plaintext highlighter-rouge">m+ = 2 × m-</code>.</p>

<p>For negative exponents, the roles are similar but the scaling direction reverses. Instead of left-shifting R, we left-shift S:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">initScaledNegativeExp</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">parts</span> <span class="n">floatParts</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">parts</span><span class="o">.</span><span class="n">lowerBoundary</span> <span class="p">{</span>
        <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">SetUint64</span><span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">fMant</span><span class="p">)</span>
        <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>               <span class="c">// r = fMant × 2</span>
        <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
        <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="o">-</span><span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">1</span><span class="p">)</span>   <span class="c">// s = 2^(-fExp+1)</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>            <span class="c">// m+ = 1</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>           <span class="c">// m- = 1</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">SetUint64</span><span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">fMant</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="m">2</span><span class="p">)</span>                   <span class="c">// r = fMant × 4</span>
    <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="n">lshByInt</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="o">-</span><span class="n">parts</span><span class="o">.</span><span class="n">fExp</span><span class="o">+</span><span class="m">2</span><span class="p">)</span>       <span class="c">// s = 2^(-fExp+2)</span>
    <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">2</span><span class="p">)</span>                <span class="c">// m+ = 2</span>
    <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">SetInt64</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>               <span class="c">// m- = 1</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="power-of-10-scaling">Power-of-10 Scaling</h2>

<p>After initialization, R, S, M+, and M- are all in base-2 space. To extract decimal digits, we need to scale them so that the first digit comes from <code class="language-plaintext highlighter-rouge">floor(R/S)</code>. This requires estimating <code class="language-plaintext highlighter-rouge">k ≈ ceil(log10(f))</code> and scaling accordingly:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">scaleByPower10</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">k</span> <span class="kt">int</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">k</span> <span class="o">&gt;</span> <span class="m">0</span><span class="o">:</span>
        <span class="n">p</span> <span class="o">:=</span> <span class="n">pow10Big</span><span class="p">(</span><span class="n">k</span><span class="p">)</span>
        <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>           <span class="c">// Scale denominator up</span>
    <span class="k">case</span> <span class="n">k</span> <span class="o">&lt;</span> <span class="m">0</span><span class="o">:</span>
        <span class="n">p</span> <span class="o">:=</span> <span class="n">pow10Big</span><span class="p">(</span><span class="o">-</span><span class="n">k</span><span class="p">)</span>
        <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>           <span class="c">// Scale numerator up</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>   <span class="c">// Scale margins too</span>
        <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="p">,</span> <span class="n">p</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">k</code> estimate uses floating-point logarithms and is allowed to be off by one. Two fixup passes correct any estimation error:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">applyHighFixup</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
    <span class="n">high</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">cmpHigh</span><span class="p">(</span><span class="n">high</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">isEven</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">bigTen</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">n</span> <span class="o">+</span> <span class="m">1</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">n</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">R + M+</code> already exceeds <code class="language-plaintext highlighter-rouge">S</code> after initial scaling, we need one more decimal position; multiply S by 10 and increment the exponent count. The low fixup works in the opposite direction, looping while <code class="language-plaintext highlighter-rouge">10R</code> and <code class="language-plaintext highlighter-rouge">10(R + M+)</code> are both less than S.</p>

<p>Computing <code class="language-plaintext highlighter-rouge">pow10Big</code> efficiently matters because these are big integer multiplications. The implementation pre-computes 700 powers of 10 at init time:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">pow10Cache</span> <span class="p">[</span><span class="m">700</span><span class="p">]</span><span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span>

<span class="k">func</span> <span class="n">init</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">pow10Cache</span><span class="p">[</span><span class="m">0</span><span class="p">]</span> <span class="o">=</span> <span class="n">big</span><span class="o">.</span><span class="n">NewInt</span><span class="p">(</span><span class="m">1</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">1</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">pow10Cache</span><span class="p">);</span> <span class="n">i</span><span class="o">++</span> <span class="p">{</span>
        <span class="n">pow10Cache</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Mul</span><span class="p">(</span><span class="n">pow10Cache</span><span class="p">[</span><span class="n">i</span><span class="o">-</span><span class="m">1</span><span class="p">],</span> <span class="n">bigTen</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">pow10Big</span><span class="p">(</span><span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">n</span> <span class="o">&gt;=</span> <span class="m">0</span> <span class="o">&amp;&amp;</span> <span class="n">n</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">pow10Cache</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Set</span><span class="p">(</span><span class="n">pow10Cache</span><span class="p">[</span><span class="n">n</span><span class="p">])</span>  <span class="c">// Defensive copy</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Exp</span><span class="p">(</span><span class="n">bigTen</span><span class="p">,</span> <span class="n">big</span><span class="o">.</span><span class="n">NewInt</span><span class="p">(</span><span class="kt">int64</span><span class="p">(</span><span class="n">n</span><span class="p">)),</span> <span class="no">nil</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The defensive copy is critical. Without it, callers that mutate the returned <code class="language-plaintext highlighter-rouge">*big.Int</code> would corrupt the cache. IEEE 754 binary64 ranges from approximately 10^-324 to 10^308, so the 700-entry cache covers all practical <code class="language-plaintext highlighter-rouge">k</code> values.</p>

<h2 id="digit-generation-the-main-loop">Digit Generation: The Main Loop</h2>

<p>With scaling complete, digit extraction is a simple loop. Multiply R, M+, and M- by 10, then divide R by S to get one decimal digit. Repeat until termination:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">extractDigits</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">int</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">var</span> <span class="n">digitBuf</span> <span class="p">[</span><span class="m">30</span><span class="p">]</span><span class="kt">byte</span>
    <span class="n">dIdx</span> <span class="o">:=</span> <span class="m">0</span>
    <span class="n">quot</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span>
    <span class="n">rem</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span>

    <span class="k">for</span> <span class="p">{</span>
        <span class="n">scaleDigitState</span><span class="p">(</span><span class="n">state</span><span class="p">)</span>                              <span class="c">// R, M+, M- *= 10</span>
        <span class="n">d</span> <span class="o">:=</span> <span class="n">divideAndRemainder</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="n">quot</span><span class="p">,</span> <span class="n">rem</span><span class="p">)</span>           <span class="c">// d = R / S; R = R mod S</span>

        <span class="n">tc1</span><span class="p">,</span> <span class="n">tc2</span> <span class="o">:=</span> <span class="n">terminationConditions</span><span class="p">(</span><span class="n">state</span><span class="p">,</span> <span class="n">isEven</span><span class="p">)</span>
        <span class="k">if</span> <span class="o">!</span><span class="n">tc1</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">tc2</span> <span class="p">{</span>
            <span class="n">digitBuf</span><span class="p">[</span><span class="n">dIdx</span><span class="p">]</span> <span class="o">=</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>
            <span class="n">dIdx</span><span class="o">++</span>
            <span class="k">continue</span>
        <span class="p">}</span>

        <span class="n">digitBuf</span><span class="p">[</span><span class="n">dIdx</span><span class="p">]</span> <span class="o">=</span> <span class="n">finalDigit</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">tc1</span><span class="p">,</span> <span class="n">tc2</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">)</span>
        <span class="n">dIdx</span><span class="o">++</span>
        <span class="k">break</span>
    <span class="p">}</span>

    <span class="n">n</span> <span class="o">=</span> <span class="n">normalizeDigitBuffer</span><span class="p">(</span><span class="n">digitBuf</span><span class="p">[</span><span class="o">:</span><span class="p">],</span> <span class="n">dIdx</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">dIdx</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">return</span> <span class="kt">string</span><span class="p">(</span><span class="n">digitBuf</span><span class="p">[</span><span class="o">:</span><span class="n">dIdx</span><span class="p">]),</span> <span class="n">n</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The 30-byte buffer is generous. IEEE 754 binary64 produces at most 17 significant digits in shortest form, with carry propagation adding at most one more.</p>

<p>The two termination conditions test whether we’ve reached a boundary:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">terminationConditions</span><span class="p">(</span><span class="n">state</span> <span class="o">*</span><span class="n">digitState</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">tc1</span> <span class="o">:=</span> <span class="n">cmpRoundDown</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">mMinus</span><span class="p">,</span> <span class="n">isEven</span><span class="p">)</span>
    <span class="n">high</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Add</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">r</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">mPlus</span><span class="p">)</span>
    <span class="n">tc2</span> <span class="o">:=</span> <span class="n">cmpHigh</span><span class="p">(</span><span class="n">high</span><span class="p">,</span> <span class="n">state</span><span class="o">.</span><span class="n">s</span><span class="p">,</span> <span class="n">isEven</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">tc1</span><span class="p">,</span> <span class="n">tc2</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li><strong>tc1</strong> (round down): the remainder R is small enough that rounding down to digit <code class="language-plaintext highlighter-rouge">d</code> would still land in the correct interval</li>
  <li><strong>tc2</strong> (round up): the remainder R plus the upper margin M+ meets or exceeds S, meaning rounding up to <code class="language-plaintext highlighter-rouge">d+1</code> is within range</li>
</ul>

<p>When neither condition fires, we aren’t yet at the shortest representation; emit the digit and continue. When at least one fires, this is the last digit.</p>

<h2 id="even-digit-tie-breaking">Even-Digit Tie-Breaking</h2>

<p>The most subtle part of the algorithm is what happens when both termination conditions fire simultaneously: the value sits at the exact midpoint between two representations. ECMA-262 Note 2 mandates <em>even-digit</em> tie-breaking (banker’s rounding):</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">finalDigit</span><span class="p">(</span><span class="n">d</span> <span class="kt">int</span><span class="p">,</span> <span class="n">tc1</span><span class="p">,</span> <span class="n">tc2</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">r</span><span class="p">,</span> <span class="n">s</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span> <span class="kt">byte</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">tc1</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">tc2</span><span class="o">:</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>      <span class="c">// Only round-down applies: use d</span>
    <span class="k">case</span> <span class="o">!</span><span class="n">tc1</span> <span class="o">&amp;&amp;</span> <span class="n">tc2</span><span class="o">:</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>  <span class="c">// Only round-up applies: use d+1</span>
    <span class="k">default</span><span class="o">:</span>
        <span class="k">return</span> <span class="n">midpointDigit</span><span class="p">(</span><span class="n">d</span><span class="p">,</span> <span class="n">r</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span>  <span class="c">// Both apply: tie-break</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">midpointDigit</span><span class="p">(</span><span class="n">d</span> <span class="kt">int</span><span class="p">,</span> <span class="n">r</span><span class="p">,</span> <span class="n">s</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span> <span class="kt">byte</span> <span class="p">{</span>
    <span class="n">twoR</span> <span class="o">:=</span> <span class="nb">new</span><span class="p">(</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">)</span><span class="o">.</span><span class="n">Lsh</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="m">1</span><span class="p">)</span>
    <span class="n">cmp</span> <span class="o">:=</span> <span class="n">twoR</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">s</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">cmp</span> <span class="o">&lt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>      <span class="c">// Closer to lower: round down</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">cmp</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>  <span class="c">// Closer to upper: round up</span>
    <span class="p">}</span>
    <span class="c">// Exactly at midpoint: even-digit tie-breaking</span>
    <span class="k">if</span> <span class="n">d</span><span class="o">%</span><span class="m">2</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span><span class="p">)</span>      <span class="c">// d is even: keep it</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="kt">byte</span><span class="p">(</span><span class="sc">'0'</span> <span class="o">+</span> <span class="n">d</span> <span class="o">+</span> <span class="m">1</span><span class="p">)</span>      <span class="c">// d is odd: round up to even</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The midpoint test compares <code class="language-plaintext highlighter-rouge">2R</code> against <code class="language-plaintext highlighter-rouge">S</code>. If <code class="language-plaintext highlighter-rouge">2R &lt; S</code>, the remainder is less than half the scale, so round down. If <code class="language-plaintext highlighter-rouge">2R &gt; S</code>, round up. When <code class="language-plaintext highlighter-rouge">2R == S</code> exactly, the algorithm breaks the tie by choosing the even digit.</p>

<p>This even-digit preference also influences the boundary comparisons themselves. The <code class="language-plaintext highlighter-rouge">cmpRoundDown</code>, <code class="language-plaintext highlighter-rouge">cmpHigh</code>, and <code class="language-plaintext highlighter-rouge">cmpLow</code> functions use different comparison operators depending on whether the mantissa is even or odd:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">cmpRoundDown</span><span class="p">(</span><span class="n">lhs</span><span class="p">,</span> <span class="n">rhs</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">isEven</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="m">0</span>   <span class="c">// Inclusive: allow rounding to even at boundary</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&lt;</span> <span class="m">0</span>        <span class="c">// Strict: odd mantissa</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">cmpHigh</span><span class="p">(</span><span class="n">lhs</span><span class="p">,</span> <span class="n">rhs</span> <span class="o">*</span><span class="n">big</span><span class="o">.</span><span class="n">Int</span><span class="p">,</span> <span class="n">isEven</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">isEven</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="m">0</span>   <span class="c">// Inclusive</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">lhs</span><span class="o">.</span><span class="n">Cmp</span><span class="p">(</span><span class="n">rhs</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span>        <span class="c">// Strict</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This asymmetry is the difference between ECMA-262 compliance and “close enough.” When the mantissa is even, the boundary comparisons are inclusive, allowing termination at exact boundary values and creating the conditions for even-digit tie-breaking. When odd, strict comparisons ensure the algorithm doesn’t terminate prematurely.</p>

<h2 id="carry-propagation">Carry Propagation</h2>

<p>When <code class="language-plaintext highlighter-rouge">finalDigit</code> returns <code class="language-plaintext highlighter-rouge">d+1</code>, it may produce a 10 (the character after ‘9’). This overflow must propagate:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">normalizeDigitBuffer</span><span class="p">(</span><span class="n">digitBuf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">,</span> <span class="n">dIdx</span> <span class="kt">int</span><span class="p">,</span> <span class="n">dIdxPtr</span> <span class="o">*</span><span class="kt">int</span><span class="p">,</span> <span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
    <span class="c">// Propagate carries</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="n">dIdx</span> <span class="o">-</span> <span class="m">1</span><span class="p">;</span> <span class="n">i</span> <span class="o">&gt;</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span><span class="o">--</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">digitBuf</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">&gt;</span> <span class="sc">'9'</span> <span class="p">{</span>
            <span class="n">digitBuf</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">=</span> <span class="sc">'0'</span>
            <span class="n">digitBuf</span><span class="p">[</span><span class="n">i</span><span class="o">-</span><span class="m">1</span><span class="p">]</span><span class="o">++</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="c">// Handle carry out of the first digit</span>
    <span class="k">if</span> <span class="n">dIdx</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="o">&amp;&amp;</span> <span class="n">digitBuf</span><span class="p">[</span><span class="m">0</span><span class="p">]</span> <span class="o">&gt;</span> <span class="sc">'9'</span> <span class="p">{</span>
        <span class="nb">copy</span><span class="p">(</span><span class="n">digitBuf</span><span class="p">[</span><span class="m">1</span><span class="o">:</span><span class="n">dIdx</span><span class="o">+</span><span class="m">1</span><span class="p">],</span> <span class="n">digitBuf</span><span class="p">[</span><span class="m">0</span><span class="o">:</span><span class="n">dIdx</span><span class="p">])</span>
        <span class="n">digitBuf</span><span class="p">[</span><span class="m">0</span><span class="p">]</span> <span class="o">=</span> <span class="sc">'1'</span>
        <span class="n">digitBuf</span><span class="p">[</span><span class="m">1</span><span class="p">]</span> <span class="o">=</span> <span class="sc">'0'</span>
        <span class="n">dIdx</span><span class="o">++</span>
        <span class="n">n</span><span class="o">++</span>    <span class="c">// One more integer digit</span>
    <span class="p">}</span>

    <span class="c">// Strip trailing zeros</span>
    <span class="k">for</span> <span class="n">dIdx</span> <span class="o">&gt;</span> <span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="n">digitBuf</span><span class="p">[</span><span class="n">dIdx</span><span class="o">-</span><span class="m">1</span><span class="p">]</span> <span class="o">==</span> <span class="sc">'0'</span> <span class="p">{</span>
        <span class="n">dIdx</span><span class="o">--</span>
    <span class="p">}</span>
    <span class="o">*</span><span class="n">dIdxPtr</span> <span class="o">=</span> <span class="n">dIdx</span>
    <span class="k">return</span> <span class="n">n</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Consider a carry cascade: digits <code class="language-plaintext highlighter-rouge">[9, 9, 10]</code> become <code class="language-plaintext highlighter-rouge">[9, 10, 0]</code> then <code class="language-plaintext highlighter-rouge">[10, 0, 0]</code>, and finally the carry-out case shifts everything right to produce <code class="language-plaintext highlighter-rouge">[1, 0, 0, 0]</code> with an incremented exponent. Trailing zeros are then stripped since the algorithm produces shortest representations.</p>

<h2 id="ecma-262-output-formatting">ECMA-262 Output Formatting</h2>

<p>The Burger-Dybvig algorithm produces a digit string and an exponent <code class="language-plaintext highlighter-rouge">n</code>. The ECMA-262 specification (§6.1.6.1.20) defines four formatting branches based on <code class="language-plaintext highlighter-rouge">n</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">formatECMA</span><span class="p">(</span><span class="n">negative</span> <span class="kt">bool</span><span class="p">,</span> <span class="n">digits</span> <span class="kt">string</span><span class="p">,</span> <span class="n">n</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
    <span class="n">k</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="n">digits</span><span class="p">)</span>

    <span class="k">var</span> <span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span>
    <span class="k">if</span> <span class="n">negative</span> <span class="p">{</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="sc">'-'</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="k">switch</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">k</span> <span class="o">&lt;=</span> <span class="n">n</span> <span class="o">&amp;&amp;</span> <span class="n">n</span> <span class="o">&lt;=</span> <span class="m">21</span><span class="o">:</span>
        <span class="c">// Integer fixed: "12300" (digits + trailing zeros)</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="n">appendIntegerFixed</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">digits</span><span class="p">,</span> <span class="n">k</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">case</span> <span class="m">0</span> <span class="o">&lt;</span> <span class="n">n</span> <span class="o">&amp;&amp;</span> <span class="n">n</span> <span class="o">&lt;=</span> <span class="m">21</span><span class="o">:</span>
        <span class="c">// Fraction fixed: "12.345" (decimal point within digits)</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="n">appendFractionFixed</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">digits</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">case</span> <span class="o">-</span><span class="m">6</span> <span class="o">&lt;</span> <span class="n">n</span> <span class="o">&amp;&amp;</span> <span class="n">n</span> <span class="o">&lt;=</span> <span class="m">0</span><span class="o">:</span>
        <span class="c">// Small fraction: "0.00123" (leading zeros after decimal point)</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="n">appendSmallFraction</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">digits</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="k">default</span><span class="o">:</span>
        <span class="c">// Exponential: "1.23e+20" or "1e-7"</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="n">appendExponential</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">digits</span><span class="p">,</span> <span class="n">k</span><span class="p">,</span> <span class="n">n</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="kt">string</span><span class="p">(</span><span class="n">buf</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The boundary constants come directly from the ECMA-262 specification:</p>

<table>
  <thead>
    <tr>
      <th>Branch</th>
      <th>Condition</th>
      <th>Example Input</th>
      <th>Output</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Integer fixed</td>
      <td><code class="language-plaintext highlighter-rouge">k ≤ n ≤ 21</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e20</code></td>
      <td><code class="language-plaintext highlighter-rouge">100000000000000000000</code></td>
    </tr>
    <tr>
      <td>Fraction fixed</td>
      <td><code class="language-plaintext highlighter-rouge">0 &lt; n ≤ 21, n &lt; k</code></td>
      <td><code class="language-plaintext highlighter-rouge">1.5</code></td>
      <td><code class="language-plaintext highlighter-rouge">1.5</code></td>
    </tr>
    <tr>
      <td>Small fraction</td>
      <td><code class="language-plaintext highlighter-rouge">-6 &lt; n ≤ 0</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e-6</code></td>
      <td><code class="language-plaintext highlighter-rouge">0.000001</code></td>
    </tr>
    <tr>
      <td>Exponential</td>
      <td>otherwise</td>
      <td><code class="language-plaintext highlighter-rouge">1e21</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e+21</code></td>
    </tr>
  </tbody>
</table>

<p>The boundaries are exact. A value of exactly 10^21 uses exponential notation (<code class="language-plaintext highlighter-rouge">1e+21</code>), while 999999999999999900000 (just below 10^21) uses integer fixed notation. A value of exactly 10^-6 uses small fraction notation (<code class="language-plaintext highlighter-rouge">0.000001</code>), while anything below that switches to exponential.</p>

<p>These boundary behaviors can be verified against specific bit patterns:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0x444b1ae4d6e2ef50 → "1e+21"                        (exponential)
0x444b1ae4d6e2ef4f → "999999999999999900000"         (integer fixed)
0x3eb0c6f7a0b5ed8d → "0.000001"                      (small fraction)
0x3eb0c6f7a0b5ed8c → "9.999999999999997e-7"          (exponential)
</code></pre></div></div>

<h2 id="why-strconvformatfloat-is-insufficient">Why strconv.FormatFloat Is Insufficient</h2>

<p>Go’s <code class="language-plaintext highlighter-rouge">strconv.FormatFloat</code> is the standard library’s number-to-string conversion. With format <code class="language-plaintext highlighter-rouge">'e'</code>, <code class="language-plaintext highlighter-rouge">'f'</code>, or <code class="language-plaintext highlighter-rouge">'g'</code> and precision <code class="language-plaintext highlighter-rouge">-1</code>, it produces shortest-round-trip representations. The issue for JCS is not quality; the issue is contract mismatch. RFC 8785 requires ECMAScript-compatible rendering rules, and <code class="language-plaintext highlighter-rouge">FormatFloat</code> does not expose that contract.</p>

<p>The gaps are concrete.</p>

<p><strong>Format-policy divergence.</strong> ECMA-262 and <code class="language-plaintext highlighter-rouge">FormatFloat</code> make different notation choices at key boundaries. Examples:</p>

<table>
  <thead>
    <tr>
      <th>Value</th>
      <th>ECMA-262 / RFC 8785 expected</th>
      <th><code class="language-plaintext highlighter-rouge">FormatFloat(v, 'g', -1, 64)</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1e20</code></td>
      <td><code class="language-plaintext highlighter-rouge">100000000000000000000</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e+20</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1e-6</code></td>
      <td><code class="language-plaintext highlighter-rouge">0.000001</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e-06</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1000000</code></td>
      <td><code class="language-plaintext highlighter-rouge">1000000</code></td>
      <td><code class="language-plaintext highlighter-rouge">1e+06</code></td>
    </tr>
  </tbody>
</table>

<p>These are all valid shortest representations, but only one side matches the JCS contract (see RFC 8785’s ECMAScript-compatible serialization examples in Appendix B).</p>

<p>These three examples are sufficient to establish the core point: ECMA-262 rendering switches between multiple notation branches (fixed integer, fixed fraction, small fraction, exponential) at exact exponent thresholds. No single <code class="language-plaintext highlighter-rouge">FormatFloat</code> mode reproduces all of them. The <code class="language-plaintext highlighter-rouge">'e'</code> and <code class="language-plaintext highlighter-rouge">'f'</code> modes each use one fixed notation. The <code class="language-plaintext highlighter-rouge">'g'</code> mode switches between <code class="language-plaintext highlighter-rouge">'e'</code> and <code class="language-plaintext highlighter-rouge">'f'</code> based on exponent magnitude, but its switching thresholds and formatting details do not match ECMA-262’s. Once boundary cases disagree, conformance requires a dedicated ECMA-262 formatting layer.</p>

<p><strong>Upstream algorithm drift risk.</strong> Go’s shortest-mode internals are not static over time. Through Go 1.16, <code class="language-plaintext highlighter-rouge">strconv</code> used Grisu3 with an exact-arithmetic fallback. <a href="https://go.dev/doc/go1.17">Go 1.17</a> replaced this with Ryu. <a href="https://go.googlesource.com/go/+/refs/tags/go1.26.0/src/internal/strconv/ftoadbox.go">Go 1.26</a> replaced Ryu with Dragonbox for shortest-mode formatting. Each transition preserved round-trip correctness and improved performance, but the <code class="language-plaintext highlighter-rouge">strconv</code> documentation does not guarantee that digit sequences will remain stable across releases. Three algorithm replacements in ten releases is normal for a standard library, but it is exactly why canonicalization code should not outsource its normative behavior to implementation details of an external runtime.</p>

<p>Building from scratch eliminates these issues. The Burger-Dybvig path always uses exact multiprecision arithmetic, applies explicit midpoint handling, and formats according to ECMA-262 branch rules. The cost is performance, but for canonicalization, correctness is the constraint.</p>

<h2 id="special-values">Special Values</h2>

<p>Three special cases are handled before the algorithm runs:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">FormatDouble</span><span class="p">(</span><span class="n">f</span> <span class="kt">float64</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsNaN</span><span class="p">(</span><span class="n">f</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidGrammar</span><span class="p">,</span> <span class="o">-</span><span class="m">1</span><span class="p">,</span>
            <span class="s">"NaN is not representable in JSON"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">f</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">"0"</span><span class="p">,</span> <span class="no">nil</span>    <span class="c">// Both +0 and -0 produce "0"</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsInf</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="s">""</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidGrammar</span><span class="p">,</span> <span class="o">-</span><span class="m">1</span><span class="p">,</span>
            <span class="s">"Infinity is not representable in JSON"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="c">// ... proceed with Burger-Dybvig</span>
<span class="p">}</span>
</code></pre></div></div>

<p>NaN and Infinity are errors; JSON has no representation for them. Negative zero is normalized to <code class="language-plaintext highlighter-rouge">"0"</code> because IEEE 754’s -0 and +0 are <em>mathematically</em> equal, and canonical JSON should not distinguish them. (Note: lexical <code class="language-plaintext highlighter-rouge">-0</code> in JSON <em>input</em> is a separate concern, rejected at parse time as a policy violation; the two requirements are independent.)</p>

<h2 id="validation-pinned-oracle-vectors">Validation: Pinned Oracle Vectors</h2>

<p>The implementation is validated against two pinned oracle datasets:</p>

<ul>
  <li><strong>golden_vectors.csv</strong>: boundary values, powers of 10, subnormals, and edge cases</li>
  <li><strong>golden_stress_vectors.csv</strong>: tie-breaking and carry propagation scenarios</li>
</ul>

<p>Each CSV row contains a 16-character hex encoding of the IEEE 754 bits and the expected output string:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0000000000000000,0
0000000000000001,5e-324
3ff0000000000000,1
3eb0c6f7a0b5ed8d,0.000001
</code></pre></div></div>

<p>The test function validates three properties per dataset:</p>

<ol>
  <li><strong>Semantic correctness</strong>: <code class="language-plaintext highlighter-rouge">FormatDouble(bits) == expected</code> for every row</li>
  <li><strong>Cardinality</strong>: The exact row count matches expectations (catches truncation)</li>
  <li><strong>Integrity</strong>: SHA-256 of the entire file matches a pinned hash (catches corruption)</li>
</ol>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">TestGoldenOracle</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">verifyOracle</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="s">"testdata/golden_vectors.csv"</span><span class="p">,</span> <span class="m">54445</span><span class="p">,</span>
        <span class="s">"593bdecbe0dccbc182bc3baf570b716887db25739fc61b7808764ecb966d5636"</span><span class="p">)</span>
<span class="p">}</span>

<span class="k">func</span> <span class="n">TestStressOracle</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">verifyOracle</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="s">"testdata/golden_stress_vectors.csv"</span><span class="p">,</span> <span class="m">231917</span><span class="p">,</span>
        <span class="s">"287d21ac87e5665550f1baf86038302a0afc67a74a020dffb872f1a93b26d410"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The SHA-256 checksums pin the test data against silent modification. If someone edits a single byte in either file, the test fails.</p>

<h2 id="round-trip-and-fuzz-testing">Round-Trip and Fuzz Testing</h2>

<p>Beyond oracle vectors, the implementation uses two additional validation strategies.</p>

<p><strong>Round-trip testing</strong> verifies the shortest representation property directly: format a value, parse it back with <code class="language-plaintext highlighter-rouge">strconv.ParseFloat</code>, and confirm the original bits are recovered:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cases</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">float64</span><span class="p">{</span><span class="m">5e-324</span><span class="p">,</span> <span class="m">1e-7</span><span class="p">,</span> <span class="m">1e-6</span><span class="p">,</span> <span class="m">0.1</span><span class="p">,</span> <span class="m">0.2</span><span class="p">,</span> <span class="m">1.1</span><span class="p">,</span> <span class="m">1</span><span class="p">,</span> <span class="m">2</span><span class="p">,</span> <span class="m">1e20</span><span class="p">,</span> <span class="m">1e21</span><span class="p">,</span> <span class="n">math</span><span class="o">.</span><span class="n">MaxFloat64</span><span class="p">}</span>
<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">c</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">cases</span> <span class="p">{</span>
    <span class="n">formatted</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">c</span><span class="p">)</span>
    <span class="n">parsed</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">ParseFloat</span><span class="p">(</span><span class="n">formatted</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">parsed</span> <span class="o">!=</span> <span class="n">c</span> <span class="p">{</span>
        <span class="n">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"round-trip failed for %.17g: formatted %q, parsed back as %.17g"</span><span class="p">,</span>
            <span class="n">c</span><span class="p">,</span> <span class="n">formatted</span><span class="p">,</span> <span class="n">parsed</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Fuzz testing</strong> generates random 64-bit patterns, interprets them as IEEE 754 doubles, and verifies round-trip stability:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">FuzzFormatDoubleRoundTrip</span><span class="p">(</span><span class="n">f</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">F</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">f</span><span class="o">.</span><span class="n">Fuzz</span><span class="p">(</span><span class="k">func</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">,</span> <span class="n">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">data</span><span class="p">)</span> <span class="o">&lt;</span> <span class="m">8</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="n">bits</span> <span class="o">:=</span> <span class="n">binary</span><span class="o">.</span><span class="n">BigEndian</span><span class="o">.</span><span class="n">Uint64</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="o">:</span><span class="m">8</span><span class="p">])</span>
        <span class="n">fval</span> <span class="o">:=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64frombits</span><span class="p">(</span><span class="n">bits</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsNaN</span><span class="p">(</span><span class="n">fval</span><span class="p">)</span> <span class="o">||</span> <span class="n">math</span><span class="o">.</span><span class="n">IsInf</span><span class="p">(</span><span class="n">fval</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">fval</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">err</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span> <span class="n">t</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="s">"expected error for non-finite"</span><span class="p">)</span> <span class="p">}</span>
            <span class="k">return</span>
        <span class="p">}</span>

        <span class="n">s</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">fval</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span> <span class="n">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"unexpected error: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span> <span class="p">}</span>

        <span class="n">parsed</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">ParseFloat</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">fval</span> <span class="o">==</span> <span class="m">0</span> <span class="p">{</span>
            <span class="k">if</span> <span class="n">parsed</span> <span class="o">!=</span> <span class="m">0</span> <span class="p">{</span> <span class="n">t</span><span class="o">.</span><span class="n">Fatal</span><span class="p">(</span><span class="s">"zero round-trip failed"</span><span class="p">)</span> <span class="p">}</span>
            <span class="k">return</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">parsed</span><span class="p">)</span> <span class="o">!=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">fval</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"round-trip failed: bits=%016x → %q → bits=%016x"</span><span class="p">,</span>
                <span class="n">bits</span><span class="p">,</span> <span class="n">s</span><span class="p">,</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">parsed</span><span class="p">))</span>
        <span class="p">}</span>
    <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Note the special case for zero: IEEE 754 -0 formats as <code class="language-plaintext highlighter-rouge">"0"</code>, which parses back as +0. The bit patterns differ (0x8000000000000000 vs 0x0000000000000000), but mathematically the values are equal, so the round-trip comparison uses <code class="language-plaintext highlighter-rouge">== 0</code> instead of bit equality.</p>

<p><strong>Idempotency testing</strong> verifies that format → parse → format produces the same string, which is a consequence of correct tie-breaking:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="kt">uint64</span><span class="p">(</span><span class="m">1</span><span class="p">);</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="m">5000</span><span class="p">;</span> <span class="n">i</span> <span class="o">+=</span> <span class="m">97</span> <span class="p">{</span>
    <span class="n">v</span> <span class="o">:=</span> <span class="n">math</span><span class="o">.</span><span class="n">Float64frombits</span><span class="p">(</span><span class="n">i</span> <span class="o">*</span> <span class="m">0x9e3779b97f4a7c15</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsNaN</span><span class="p">(</span><span class="n">v</span><span class="p">)</span> <span class="o">||</span> <span class="n">math</span><span class="o">.</span><span class="n">IsInf</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span> <span class="p">{</span> <span class="k">continue</span> <span class="p">}</span>

    <span class="n">f1</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">v</span><span class="p">)</span>
    <span class="n">parsed</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">ParseFloat</span><span class="p">(</span><span class="n">f1</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>
    <span class="n">f2</span><span class="p">,</span> <span class="n">_</span> <span class="o">:=</span> <span class="n">jcsfloat</span><span class="o">.</span><span class="n">FormatDouble</span><span class="p">(</span><span class="n">parsed</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">f1</span> <span class="o">!=</span> <span class="n">f2</span> <span class="p">{</span>
        <span class="n">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"idempotency failed for bits=%016x: %s != %s"</span><span class="p">,</span>
            <span class="n">math</span><span class="o">.</span><span class="n">Float64bits</span><span class="p">(</span><span class="n">v</span><span class="p">),</span> <span class="n">f1</span><span class="p">,</span> <span class="n">f2</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If tie-breaking were inconsistent, formatting a value at the exact midpoint could oscillate between two representations. Even-digit tie-breaking prevents this by always choosing the same direction at midpoints.</p>

<h2 id="the-complete-pipeline">The Complete Pipeline</h2>

<p>Putting it all together, the conversion from IEEE 754 bits to canonical decimal string follows this path:</p>

<ol>
  <li><strong>Decode</strong> the 64-bit pattern into mantissa, exponent, and boundary flags</li>
  <li><strong>Initialize</strong> R, S, M+, M- as big integers with appropriate scaling</li>
  <li><strong>Estimate</strong> the decimal exponent <code class="language-plaintext highlighter-rouge">k</code> using floating-point logarithms</li>
  <li><strong>Scale</strong> by 10^k using pre-computed powers from the 700-entry cache</li>
  <li><strong>Fix up</strong> the scaling if the estimate was off by one (high or low)</li>
  <li><strong>Extract digits</strong> one at a time by multiplying by 10 and dividing</li>
  <li><strong>Terminate</strong> when boundary conditions indicate the shortest representation</li>
  <li><strong>Tie-break</strong> using even-digit preference when at the exact midpoint</li>
  <li><strong>Propagate carries</strong> if rounding up causes overflow</li>
  <li><strong>Format</strong> using the appropriate ECMA-262 branch based on the exponent</li>
</ol>

<p>The entire implementation has zero external dependencies (only <code class="language-plaintext highlighter-rouge">math</code>, <code class="language-plaintext highlighter-rouge">math/big</code>, and the project’s own error package). It is deterministic, locale-independent, and produces identical output regardless of platform or Go runtime version.</p>

<p>The implementation lives in the <code class="language-plaintext highlighter-rouge">jcsfloat</code> package of <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Removed oracle vector counts from description, introduction, and validation section heading</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Restored Dragonbox/Go 1.26 claim after verification against source tree at <code class="language-plaintext highlighter-rouge">go1.26.0</code> tag (PR #75195 shows “Closed” on GitHub because Go merges through Gerrit; the change landed); previous removal was incorrect</td>
    </tr>
    <tr>
      <td>2026-03-04</td>
      <td>Removed Dragonbox/Go 1.26 claim based on GitHub PR status and release notes (later found to be incorrect, see above); corrected FormatFloat ‘g’ mode description; removed stale source line reference</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Removed line-count claims from introduction and conclusion</td>
    </tr>
    <tr>
      <td>2026-02-27</td>
      <td>Initial publication</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="algorithms" /><category term="ieee754" /><summary type="html"><![CDATA[The Burger-Dybvig algorithm in Go: finding the shortest decimal string that round-trips to the original IEEE 754 binary value, with ECMA-262 tie-breaking rules, validated against pinned oracle test vectors.]]></summary></entry><entry><title type="html">What Your JSON Parser Doesn’t Reject: Building a Strict RFC 8259 Parser in Go</title><link href="https://lattice-substrate.github.io/blog/2026/02/26/strict-rfc8259-json-parser/" rel="alternate" type="text/html" title="What Your JSON Parser Doesn’t Reject: Building a Strict RFC 8259 Parser in Go" /><published>2026-02-26T00:00:00+00:00</published><updated>2026-02-26T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/02/26/strict-rfc8259-json-parser</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/02/26/strict-rfc8259-json-parser/"><![CDATA[<p>Most JSON parsers are lenient by design. They accept input that RFC 8259 forbids, because lenient parsing is convenient and rarely causes visible problems. But when your downstream depends on deterministic processing (canonical signatures, content-addressed storage, reproducible builds), leniency is a defect. A value silently accepted by one parser and rejected by another breaks your contract.</p>

<p>I built a strict JSON parser in Go. It enforces every constraint in <a href="https://www.rfc-editor.org/rfc/rfc8259">RFC 8259</a> and adds the <a href="https://www.rfc-editor.org/rfc/rfc7493">RFC 7493</a> (I-JSON) restrictions that <a href="https://www.rfc-editor.org/rfc/rfc8785">RFC 8785</a> requires. This article walks through the design and implementation, focusing on the parts where strictness requires real work: surrogate pairs, noncharacter detection, duplicate keys after escape decoding, and resource bounds.</p>

<h2 id="the-gap-what-canonicalization-cannot-accept">The Gap: What Canonicalization Cannot Accept</h2>

<p>Go’s <code class="language-plaintext highlighter-rouge">encoding/json</code> is a solid general-purpose parser. For canonicalization, some of its documented behaviors are intentionally too permissive. On Go 1.25.6, representative examples look like this:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th><code class="language-plaintext highlighter-rouge">encoding/json</code> result</th>
      <th>Strict parser</th>
      <th>Source</th>
      <th>Why it matters</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"\uD800\u0041"</code></td>
      <td>Decodes as <code class="language-plaintext highlighter-rouge">"�A"</code> (invalid surrogate replaced)</td>
      <td>Rejected: lone surrogate</td>
      <td>RFC 7493 §2.1</td>
      <td>I-JSON forbids surrogates</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">"\uFDD0"</code></td>
      <td>Accepts noncharacter</td>
      <td>Rejected: noncharacter</td>
      <td>RFC 7493 §2.1</td>
      <td>I-JSON forbids noncharacters</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{"a":1,"a":2}</code></td>
      <td>Later key wins (<code class="language-plaintext highlighter-rouge">a=2</code>)</td>
      <td>Rejected: duplicate key</td>
      <td>RFC 7493 §2.3</td>
      <td>I-JSON requires unique names</td>
    </tr>
    <tr>
      <td>raw bytes <code class="language-plaintext highlighter-rouge">22 ff 22</code></td>
      <td>Decodes as <code class="language-plaintext highlighter-rouge">"�"</code></td>
      <td>Rejected: invalid UTF-8</td>
      <td>project policy</td>
      <td>silent byte substitution changes meaning</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">-0</code></td>
      <td>Accepts lexical <code class="language-plaintext highlighter-rouge">-0</code></td>
      <td>Rejected: negative zero token</td>
      <td>project policy</td>
      <td>lexical ambiguity at acceptance boundary</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">1e-400</code></td>
      <td>Parses to <code class="language-plaintext highlighter-rouge">0</code> with <code class="language-plaintext highlighter-rouge">err == nil</code> (Go 1.25.6)</td>
      <td>Rejected: underflow</td>
      <td>project policy</td>
      <td>non-zero token collapses to zero</td>
    </tr>
  </tbody>
</table>

<p>Rows labeled RFC 7493 are normative I-JSON requirements. Rows labeled project policy are stricter acceptance rules chosen to keep canonicalization fail-closed and deterministic.</p>

<p>These are not bugs in <code class="language-plaintext highlighter-rouge">encoding/json</code>; they are compatibility choices. The <a href="https://pkg.go.dev/encoding/json#Unmarshal">Go package documentation for <code class="language-plaintext highlighter-rouge">Unmarshal</code></a> explicitly states that invalid UTF-8 / invalid UTF-16 surrogates are replaced by U+FFFD, and duplicate object keys are processed in order with later values replacing earlier ones.</p>

<p>Minimal reproduction (Go 1.25.6), with explicit error checks:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">v</span> <span class="n">any</span>
<span class="n">err</span> <span class="o">:=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">`"\uD800\u0041"`</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">v</span><span class="p">)</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"case surrogate err=%v v=%#v</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span> <span class="c">// err=&lt;nil&gt;, v="�A"</span>

<span class="n">err</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">`{"a":1,"a":2}`</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">v</span><span class="p">)</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"case dup-key err=%v v=%#v</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span> <span class="c">// err=&lt;nil&gt;, later duplicate replaces earlier value</span>

<span class="n">err</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">Unmarshal</span><span class="p">([]</span><span class="kt">byte</span><span class="p">(</span><span class="s">"1e-400"</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">v</span><span class="p">)</span>
<span class="n">fmt</span><span class="o">.</span><span class="n">Printf</span><span class="p">(</span><span class="s">"case underflow err=%v v=%#v</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="n">err</span><span class="p">,</span> <span class="n">v</span><span class="p">)</span> <span class="c">// err=&lt;nil&gt;, v=0</span>
</code></pre></div></div>

<p>For application code, replacement and merge behavior is pragmatic. A canonicalization engine has a different contract: reject ambiguous or lossy inputs so every accepted value has a single deterministic canonical form. If two parsers disagree on interpretation, canonical output is undefined. Rejection is the safe outcome.</p>

<h2 id="parser-architecture">Parser Architecture</h2>

<p>The parser uses single-pass recursive descent with byte-level dispatch. The core structure is a <code class="language-plaintext highlighter-rouge">parser</code> struct that holds the input bytes, a cursor position, depth tracking, and configurable bounds:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">parser</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">data</span>             <span class="p">[]</span><span class="kt">byte</span>
    <span class="n">pos</span>              <span class="kt">int</span>
    <span class="n">depth</span>            <span class="kt">int</span>
    <span class="n">valueCount</span>       <span class="kt">int</span>
    <span class="n">maxDepth</span>         <span class="kt">int</span>
    <span class="n">maxValues</span>        <span class="kt">int</span>
    <span class="n">maxObjectMembers</span> <span class="kt">int</span>
    <span class="n">maxArrayElements</span> <span class="kt">int</span>
    <span class="n">maxStringBytes</span>   <span class="kt">int</span>
    <span class="n">maxNumberChars</span>   <span class="kt">int</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Value dispatch is a single <code class="language-plaintext highlighter-rouge">switch</code> on the first byte:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">parseValue</span><span class="p">()</span> <span class="p">(</span><span class="o">*</span><span class="n">Value</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">p</span><span class="o">.</span><span class="n">valueCount</span><span class="o">++</span>
    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">valueCount</span> <span class="o">&gt;</span> <span class="n">p</span><span class="o">.</span><span class="n">maxValues</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">newErrorf</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">BoundExceeded</span><span class="p">,</span>
            <span class="s">"value count %d exceeds maximum %d"</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">valueCount</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">maxValues</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="n">c</span><span class="p">,</span> <span class="n">ok</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">peek</span><span class="p">()</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">ok</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">newError</span><span class="p">(</span><span class="s">"unexpected end of input"</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="k">switch</span> <span class="n">c</span> <span class="p">{</span>
    <span class="k">case</span> <span class="sc">'{'</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseObject</span><span class="p">()</span>
    <span class="k">case</span> <span class="sc">'['</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseArray</span><span class="p">()</span>
    <span class="k">case</span> <span class="sc">'"'</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseString</span><span class="p">()</span>
    <span class="k">case</span> <span class="sc">'t'</span><span class="p">,</span> <span class="sc">'f'</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseBool</span><span class="p">()</span>
    <span class="k">case</span> <span class="sc">'n'</span><span class="o">:</span>  <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseNull</span><span class="p">()</span>
    <span class="k">default</span><span class="o">:</span>   <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">parseNumber</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>No lookahead beyond the first byte is needed for type discrimination. Every call to <code class="language-plaintext highlighter-rouge">parseValue</code> checks the bound counter first.</p>

<h2 id="utf-8-validation-upfront-and-incremental">UTF-8 Validation: Upfront and Incremental</h2>

<p>The parser validates UTF-8 twice, for different reasons.</p>

<p><strong>Upfront bulk validation</strong> runs before any parsing begins:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">!</span><span class="n">utf8</span><span class="o">.</span><span class="n">Valid</span><span class="p">(</span><span class="n">data</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidUTF8</span><span class="p">,</span> <span class="n">firstInvalidUTF8Offset</span><span class="p">(</span><span class="n">data</span><span class="p">),</span>
        <span class="s">"input is not valid UTF-8"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This catches structural UTF-8 violations: continuation bytes without start bytes, truncated multibyte sequences, overlong encodings, and raw UTF-8 encodings of surrogate code points. The <code class="language-plaintext highlighter-rouge">firstInvalidUTF8Offset</code> function locates the exact byte offset of the first violation by scanning rune-by-rune until <code class="language-plaintext highlighter-rouge">utf8.DecodeRune</code> returns a replacement character:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">firstInvalidUTF8Offset</span><span class="p">(</span><span class="n">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">data</span><span class="p">);</span> <span class="p">{</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">size</span> <span class="o">:=</span> <span class="n">utf8</span><span class="o">.</span><span class="n">DecodeRune</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="o">:</span><span class="p">])</span>
        <span class="k">if</span> <span class="n">size</span> <span class="o">==</span> <span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="n">data</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">&gt;=</span> <span class="m">0x80</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">i</span>
        <span class="p">}</span>
        <span class="n">i</span> <span class="o">+=</span> <span class="n">size</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="m">0</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Incremental validation</strong> happens during string parsing. After verifying the input is well-formed UTF-8, the string parser still decodes each rune individually to validate <em>semantic</em> constraints: noncharacter rejection and surrogate detection that apply to decoded values, not raw bytes.</p>

<p>Why both? The upfront check is fast (Go’s <code class="language-plaintext highlighter-rouge">utf8.Valid</code> is optimized) and gives clear error reporting for malformed input before the parser’s position tracking complicates offset calculation. The incremental check applies rules that operate on decoded code points, not byte patterns.</p>

<h2 id="number-grammar-four-layers-of-enforcement">Number Grammar: Four Layers of Enforcement</h2>

<p>RFC 8259 §6 defines a specific number grammar. The parser enforces it in four phases.</p>

<h3 id="leading-zero-rejection">Leading Zero Rejection</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">scanZeroIntegerPart</span><span class="p">()</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span> <span class="p">{</span>
    <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">isDigit</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">])</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">newError</span><span class="p">(</span><span class="s">"leading zero in number"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">01</code>, <code class="language-plaintext highlighter-rouge">007</code>, <code class="language-plaintext highlighter-rouge">00.5</code>: all rejected. A zero integer part must be exactly <code class="language-plaintext highlighter-rouge">0</code>, followed by a non-digit (or end of number). RFC 8259 §6 specifies: “Leading zeros are not allowed.”</p>

<h3 id="missing-fraction-digits">Missing Fraction Digits</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">scanFractionPart</span><span class="p">(</span><span class="n">numStart</span> <span class="kt">int</span><span class="p">)</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">||</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">]</span> <span class="o">!=</span> <span class="sc">'.'</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span>
    <span class="p">}</span>
    <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>

    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">||</span> <span class="o">!</span><span class="n">isDigit</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">])</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">p</span><span class="o">.</span><span class="n">newError</span><span class="p">(</span><span class="s">"expected digit after decimal point"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="c">// ... scan remaining digits</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">1.</code> and <code class="language-plaintext highlighter-rouge">1.e5</code> are rejected; the grammar requires at least one digit after the decimal point.</p>

<h3 id="lexical-negative-zero">Lexical Negative Zero</h3>

<p>This is a project policy enforcement, not a grammar rule. The value <code class="language-plaintext highlighter-rouge">-0</code> is mathematically zero, but the <em>lexical</em> token <code class="language-plaintext highlighter-rouge">-0</code> is ambiguous:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// PROF-NEGZ-001: lexical negative zero</span>
<span class="k">if</span> <span class="n">strings</span><span class="o">.</span><span class="n">HasPrefix</span><span class="p">(</span><span class="n">raw</span><span class="p">,</span> <span class="s">"-"</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">tokenRepresentsZero</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">NumberNegZero</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span>
        <span class="s">"negative zero token is not allowed"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This rejects <code class="language-plaintext highlighter-rouge">-0</code>, <code class="language-plaintext highlighter-rouge">-0.0</code>, <code class="language-plaintext highlighter-rouge">-0e0</code>, <code class="language-plaintext highlighter-rouge">-0.0e+0</code>, and every other way to spell negative zero in JSON number syntax. The <code class="language-plaintext highlighter-rouge">tokenRepresentsZero</code> function checks whether the mantissa portion of the number token contains any non-zero digit, ignoring the exponent entirely.</p>

<p>Note: this is a <em>different</em> requirement from serialization. At serialization time, the IEEE 754 bit pattern for -0 outputs as <code class="language-plaintext highlighter-rouge">"0"</code>. At parse time, the lexical token <code class="language-plaintext highlighter-rouge">-0</code> is rejected. The two requirements are independent: one governs input, the other governs output.</p>

<h3 id="overflow-and-underflow">Overflow and Underflow</h3>

<p>After grammar validation, the number string is parsed with <code class="language-plaintext highlighter-rouge">strconv.ParseFloat</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">f</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">strconv</span><span class="o">.</span><span class="n">ParseFloat</span><span class="p">(</span><span class="n">raw</span><span class="p">,</span> <span class="m">64</span><span class="p">)</span>

<span class="c">// Overflow: value exceeds IEEE 754 range</span>
<span class="k">if</span> <span class="n">math</span><span class="o">.</span><span class="n">IsNaN</span><span class="p">(</span><span class="n">f</span><span class="p">)</span> <span class="o">||</span> <span class="n">math</span><span class="o">.</span><span class="n">IsInf</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">NumberOverflow</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span>
        <span class="s">"number overflows IEEE 754 double"</span><span class="p">)</span>
<span class="p">}</span>

<span class="c">// Underflow: non-zero token rounds to zero</span>
<span class="k">if</span> <span class="n">f</span> <span class="o">==</span> <span class="m">0</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">tokenRepresentsZero</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">NumberUnderflow</span><span class="p">,</span> <span class="n">start</span><span class="p">,</span>
        <span class="s">"non-zero number underflows to IEEE 754 zero"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">1e999999</code> overflows to infinity; rejected. <code class="language-plaintext highlighter-rouge">1e-400</code> is a non-zero number that underflows to zero in IEEE 754; also rejected. The underflow check uses the same <code class="language-plaintext highlighter-rouge">tokenRepresentsZero</code> function: if the <em>token</em> has non-zero digits but <code class="language-plaintext highlighter-rouge">ParseFloat</code> returns zero, the precision was lost.</p>

<h2 id="string-parsing-two-paths">String Parsing: Two Paths</h2>

<p>The string parser is a linear scan with two code paths: escape sequences and raw UTF-8 bytes.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">parseString</span><span class="p">()</span> <span class="p">(</span><span class="o">*</span><span class="n">Value</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">expect</span><span class="p">(</span><span class="sc">'"'</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span>
    <span class="k">for</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">newError</span><span class="p">(</span><span class="s">"unterminated string"</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="n">b</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">]</span>
        <span class="k">if</span> <span class="n">b</span> <span class="o">==</span> <span class="sc">'"'</span> <span class="p">{</span>
            <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
            <span class="k">return</span> <span class="o">&amp;</span><span class="n">Value</span><span class="p">{</span><span class="n">Kind</span><span class="o">:</span> <span class="n">KindString</span><span class="p">,</span> <span class="n">Str</span><span class="o">:</span> <span class="kt">string</span><span class="p">(</span><span class="n">buf</span><span class="p">)},</span> <span class="no">nil</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">b</span> <span class="o">==</span> <span class="sc">'\\'</span> <span class="p">{</span>
            <span class="c">// Escape sequence path</span>
            <span class="n">escapeStart</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>
            <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
            <span class="n">r</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">parseEscape</span><span class="p">(</span><span class="n">escapeStart</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span> <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span> <span class="p">}</span>
            <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">validateStringRune</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="n">escapeStart</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span> <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span> <span class="p">}</span>
            <span class="k">var</span> <span class="n">tmp</span> <span class="p">[</span><span class="m">4</span><span class="p">]</span><span class="kt">byte</span>
            <span class="n">n</span> <span class="o">:=</span> <span class="n">utf8</span><span class="o">.</span><span class="n">EncodeRune</span><span class="p">(</span><span class="n">tmp</span><span class="p">[</span><span class="o">:</span><span class="p">],</span> <span class="n">r</span><span class="p">)</span>
            <span class="n">buf</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">tmp</span><span class="p">[</span><span class="o">:</span><span class="n">n</span><span class="p">]</span><span class="o">...</span><span class="p">)</span>
            <span class="k">continue</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">b</span> <span class="o">&lt;</span> <span class="m">0x20</span> <span class="p">{</span>
            <span class="c">// PARSE-GRAM-004: reject unescaped control characters</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">newErrorf</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidGrammar</span><span class="p">,</span>
                <span class="s">"unescaped control character 0x%02X in string"</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="c">// Raw UTF-8 path</span>
        <span class="n">sourceOffset</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>
        <span class="n">r</span><span class="p">,</span> <span class="n">size</span> <span class="o">:=</span> <span class="n">utf8</span><span class="o">.</span><span class="n">DecodeRune</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">:</span><span class="p">])</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">validateStringRune</span><span class="p">(</span><span class="n">r</span><span class="p">,</span> <span class="n">sourceOffset</span><span class="p">);</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span> <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span> <span class="p">}</span>
        <span class="n">buf</span> <span class="o">=</span> <span class="nb">append</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">:</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">+</span><span class="n">size</span><span class="p">]</span><span class="o">...</span><span class="p">)</span>
        <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">+=</span> <span class="n">size</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Both paths call <code class="language-plaintext highlighter-rouge">validateStringRune</code> to check for noncharacters and surrogates. The escape path first decodes the escape to a rune, then validates the decoded value. The raw path decodes the rune from UTF-8 bytes, then validates. The decoded results are identical: <code class="language-plaintext highlighter-rouge">\u0041</code> and <code class="language-plaintext highlighter-rouge">A</code> both produce rune 0x41. This is critical for duplicate key detection later.</p>

<p>The escape dispatch is a straightforward table:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">escapedRune</span><span class="p">(</span><span class="n">b</span> <span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="kt">rune</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="n">b</span> <span class="p">{</span>
    <span class="k">case</span> <span class="sc">'"'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'"'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'\\'</span><span class="o">:</span> <span class="k">return</span> <span class="sc">'\\'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'/'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'/'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'b'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\b'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'f'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\f'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'n'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\n'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'r'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\r'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">case</span> <span class="sc">'t'</span><span class="o">:</span>  <span class="k">return</span> <span class="sc">'\t'</span><span class="p">,</span> <span class="no">true</span>
    <span class="k">default</span><span class="o">:</span>   <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="no">false</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>RFC 8259 §7 defines exactly these eight simple escapes plus <code class="language-plaintext highlighter-rouge">\uXXXX</code>. Anything else (<code class="language-plaintext highlighter-rouge">\x41</code>, <code class="language-plaintext highlighter-rouge">\U0041</code>, <code class="language-plaintext highlighter-rouge">\a</code>) is rejected with <code class="language-plaintext highlighter-rouge">InvalidGrammar</code>.</p>

<p>Control characters below U+0020 that appear unescaped are also rejected per §7: “All Unicode characters may be placed within the quotation marks, except for the characters that MUST be escaped: quotation mark, reverse solidus, and the control characters (U+0000 through U+001F).”</p>

<h2 id="surrogate-pairs-2-character-lookahead">Surrogate Pairs: 2-Character Lookahead</h2>

<p>UTF-16 surrogate pairs in JSON appear as <code class="language-plaintext highlighter-rouge">\uD800\uDC00</code>: two consecutive <code class="language-plaintext highlighter-rouge">\uXXXX</code> escapes where the first is a high surrogate (U+D800-U+DBFF) and the second is a low surrogate (U+DC00-U+DFFF). Together they encode a supplementary-plane character (U+10000 and above).</p>

<p>The parser must handle five cases:</p>

<ol>
  <li><strong>Not a surrogate</strong>: Return the rune as-is</li>
  <li><strong>Lone low surrogate</strong> (U+DC00-U+DFFF appearing first): Error</li>
  <li><strong>High surrogate with no following <code class="language-plaintext highlighter-rouge">\u</code></strong>: Error</li>
  <li><strong>High surrogate followed by non-low-surrogate</strong>: Error</li>
  <li><strong>Valid pair</strong>: Decode to supplementary-plane scalar</li>
</ol>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">parseUnicodeEscape</span><span class="p">(</span><span class="n">sourceOffset</span> <span class="kt">int</span><span class="p">)</span> <span class="p">(</span><span class="kt">rune</span><span class="p">,</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">r1</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">readHex4</span><span class="p">(</span><span class="n">sourceOffset</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="o">!</span><span class="n">utf16</span><span class="o">.</span><span class="n">IsSurrogate</span><span class="p">(</span><span class="n">r1</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">r1</span><span class="p">,</span> <span class="no">nil</span>                              <span class="c">// Case 1: not a surrogate</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="n">r1</span> <span class="o">&gt;=</span> <span class="m">0xDC00</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">LoneSurrogate</span><span class="p">,</span>  <span class="c">// Case 2: lone low surrogate</span>
            <span class="n">sourceOffset</span><span class="p">,</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"lone low surrogate U+%04X"</span><span class="p">,</span> <span class="n">r1</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="c">// Case 3: high surrogate must be followed by \uXXXX</span>
    <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">+</span><span class="m">1</span> <span class="o">&gt;=</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">||</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">]</span> <span class="o">!=</span> <span class="sc">'\\'</span> <span class="o">||</span> <span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">+</span><span class="m">1</span><span class="p">]</span> <span class="o">!=</span> <span class="sc">'u'</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">LoneSurrogate</span><span class="p">,</span> <span class="n">sourceOffset</span><span class="p">,</span>
            <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"lone high surrogate U+%04X (no following </span><span class="se">\\</span><span class="s">u)"</span><span class="p">,</span> <span class="n">r1</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="n">secondEscapeOffset</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>
    <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">+=</span> <span class="m">2</span>                                      <span class="c">// Skip past '\u'</span>

    <span class="n">r2</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">readHex4</span><span class="p">(</span><span class="n">secondEscapeOffset</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">err</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">r2</span> <span class="o">&lt;</span> <span class="m">0xDC00</span> <span class="o">||</span> <span class="n">r2</span> <span class="o">&gt;</span> <span class="m">0xDFFF</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">LoneSurrogate</span><span class="p">,</span>  <span class="c">// Case 4: not a low surrogate</span>
            <span class="n">secondEscapeOffset</span><span class="p">,</span>
            <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"high surrogate U+%04X followed by non-low-surrogate U+%04X"</span><span class="p">,</span> <span class="n">r1</span><span class="p">,</span> <span class="n">r2</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="c">// Case 5: valid pair</span>
    <span class="n">decoded</span> <span class="o">:=</span> <span class="n">utf16</span><span class="o">.</span><span class="n">DecodeRune</span><span class="p">(</span><span class="n">r1</span><span class="p">,</span> <span class="n">r2</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">decoded</span> <span class="o">==</span> <span class="n">unicode</span><span class="o">.</span><span class="n">ReplacementChar</span> <span class="p">{</span>
        <span class="k">return</span> <span class="m">0</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">LoneSurrogate</span><span class="p">,</span> <span class="n">sourceOffset</span><span class="p">,</span>
            <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"invalid surrogate pair U+%04X U+%04X"</span><span class="p">,</span> <span class="n">r1</span><span class="p">,</span> <span class="n">r2</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">decoded</span><span class="p">,</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The 2-character lookahead checks for <code class="language-plaintext highlighter-rouge">\u</code> without consuming input. This is the only lookahead in the parser beyond single-byte dispatch. If the two bytes aren’t <code class="language-plaintext highlighter-rouge">\u</code>, the high surrogate is lone and the error points to the <em>first</em> escape’s offset. If the second escape exists but decodes to a non-low surrogate (like <code class="language-plaintext highlighter-rouge">\uD800\u0041</code>), the error points to the <em>second</em> escape’s offset, because that’s where the violation occurs.</p>

<p>Compare with <code class="language-plaintext highlighter-rouge">encoding/json</code>: it replaces lone surrogates with U+FFFD (documented behavior in the <code class="language-plaintext highlighter-rouge">Unmarshal</code> docs). This is convenient for display pipelines, but it silently changes semantic content. A canonicalization engine cannot do this. Changing input bytes means the canonical output no longer represents the original data.</p>

<h2 id="noncharacter-rejection-66-forbidden-code-points">Noncharacter Rejection: 66 Forbidden Code Points</h2>

<p>RFC 7493 §2.1 forbids Unicode noncharacters in I-JSON text. There are exactly 66:</p>

<ul>
  <li>32 in the range U+FDD0 to U+FDEF</li>
  <li>34 at U+xFFFE and U+xFFFF for each of the 17 Unicode planes (0-16)</li>
</ul>

<p>The implementation uses a compact two-branch check:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">IsNoncharacter</span><span class="p">(</span><span class="n">r</span> <span class="kt">rune</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">r</span> <span class="o">&gt;=</span> <span class="m">0xFDD0</span> <span class="o">&amp;&amp;</span> <span class="n">r</span> <span class="o">&lt;=</span> <span class="m">0xFDEF</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">true</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">r</span><span class="o">&amp;</span><span class="m">0xFFFE</span> <span class="o">==</span> <span class="m">0xFFFE</span> <span class="o">&amp;&amp;</span> <span class="n">r</span> <span class="o">&lt;=</span> <span class="m">0x10FFFF</span> <span class="p">{</span>
        <span class="k">return</span> <span class="no">true</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="no">false</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The bitwise trick on the second branch is worth explaining. The mask <code class="language-plaintext highlighter-rouge">r &amp; 0xFFFE</code> zeros the lowest bit, so both U+xFFFE and U+xFFFF match the pattern <code class="language-plaintext highlighter-rouge">0xFFFE</code>. The bound <code class="language-plaintext highlighter-rouge">r &lt;= 0x10FFFF</code> limits this to planes 0 through 16. This catches U+FFFE, U+FFFF, U+1FFFE, U+1FFFF, all the way up to U+10FFFE and U+10FFFF, for 2 per plane times 17 planes = 34 code points.</p>

<p>The validation runs on <em>decoded</em> runes, not raw bytes. This matters because supplementary-plane noncharacters like U+1FFFE can appear as either raw UTF-8 bytes or as surrogate pair escapes (<code class="language-plaintext highlighter-rouge">\uD83F\uDFFE</code>). Both paths decode to the same rune and hit the same check.</p>

<h2 id="duplicate-key-detection-after-escape-decoding">Duplicate Key Detection: After Escape Decoding</h2>

<p>RFC 7493 §2.3 requires that JSON objects not contain duplicate member names. The subtle requirement is that <code class="language-plaintext highlighter-rouge">"\u0061"</code> and <code class="language-plaintext highlighter-rouge">"a"</code> are the <em>same key</em>; both decode to the string “a”.</p>

<p>The parser handles this by comparing <em>decoded</em> strings, not raw lexemes:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">parseObject</span><span class="p">()</span> <span class="p">(</span><span class="o">*</span><span class="n">Value</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="c">// ...</span>
    <span class="n">v</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">Value</span><span class="p">{</span><span class="n">Kind</span><span class="o">:</span> <span class="n">KindObject</span><span class="p">}</span>
    <span class="n">seen</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span><span class="p">)</span>

    <span class="k">for</span> <span class="p">{</span>
        <span class="n">keyStart</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>

        <span class="n">keyVal</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">parseString</span><span class="p">()</span>   <span class="c">// Decodes all escapes</span>
        <span class="k">if</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span> <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">err</span> <span class="p">}</span>
        <span class="n">key</span> <span class="o">:=</span> <span class="n">keyVal</span><span class="o">.</span><span class="n">Str</span>                <span class="c">// Decoded Unicode string</span>

        <span class="k">if</span> <span class="n">firstOff</span><span class="p">,</span> <span class="n">exists</span> <span class="o">:=</span> <span class="n">seen</span><span class="p">[</span><span class="n">key</span><span class="p">];</span> <span class="n">exists</span> <span class="p">{</span>
            <span class="k">return</span> <span class="no">nil</span><span class="p">,</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">DuplicateKey</span><span class="p">,</span> <span class="n">keyStart</span><span class="p">,</span>
                <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"duplicate object key %q (first at byte %d)"</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">firstOff</span><span class="p">))</span>
        <span class="p">}</span>
        <span class="n">seen</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">keyStart</span>

        <span class="c">// ... parse colon and value</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">seen</code> map uses the decoded string as the key. When the parser encounters <code class="language-plaintext highlighter-rouge">"\u0061"</code>, it decodes the escape to produce “a”, and the map lookup finds a match with a previously seen raw <code class="language-plaintext highlighter-rouge">"a"</code>. The error message includes the byte offset of <em>both</em> occurrences, enabling precise diagnostics.</p>

<p>This is a design choice that some parsers skip entirely (<code class="language-plaintext highlighter-rouge">encoding/json</code> processes duplicate keys in input order, with later values replacing or merging earlier ones) or implement on raw bytes (which misses escape-decoded equivalence). For canonicalization, decoded comparison is the only correct approach, because canonical output normalizes escape sequences.</p>

<h2 id="resource-bounds-seven-independent-limits">Resource Bounds: Seven Independent Limits</h2>

<p>The parser enforces seven configurable bounds, each checked at a different point in the parse:</p>

<table>
  <thead>
    <tr>
      <th>Bound</th>
      <th>Default</th>
      <th>Checked At</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Input size</td>
      <td>64 MB</td>
      <td>Before parsing begins</td>
    </tr>
    <tr>
      <td>Nesting depth</td>
      <td>1,000</td>
      <td>On each <code class="language-plaintext highlighter-rouge">{</code> or <code class="language-plaintext highlighter-rouge">[</code></td>
    </tr>
    <tr>
      <td>Total values</td>
      <td>1,000,000</td>
      <td>On each <code class="language-plaintext highlighter-rouge">parseValue</code> call</td>
    </tr>
    <tr>
      <td>Object members</td>
      <td>250,000</td>
      <td>Before adding each member</td>
    </tr>
    <tr>
      <td>Array elements</td>
      <td>250,000</td>
      <td>Before adding each element</td>
    </tr>
    <tr>
      <td>String bytes</td>
      <td>8 MB</td>
      <td>During string decode (per-string)</td>
    </tr>
    <tr>
      <td>Number chars</td>
      <td>4,096</td>
      <td>During number scan (per-token)</td>
    </tr>
  </tbody>
</table>

<p>All bounds are fail-fast: the parser stops at the first violation rather than continuing to consume input. The number character bound is checked <em>during</em> digit scanning, not just after:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">scanNonZeroIntegerDigits</span><span class="p">(</span><span class="n">numStart</span> <span class="kt">int</span><span class="p">)</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span> <span class="o">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">isDigit</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">data</span><span class="p">[</span><span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">])</span> <span class="p">{</span>
        <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
        <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">-</span><span class="n">numStart</span> <span class="o">&gt;</span> <span class="n">p</span><span class="o">.</span><span class="n">maxNumberChars</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">BoundExceeded</span><span class="p">,</span> <span class="n">numStart</span><span class="p">,</span>
                <span class="n">fmt</span><span class="o">.</span><span class="n">Sprintf</span><span class="p">(</span><span class="s">"number token length %d exceeds maximum %d"</span><span class="p">,</span>
                    <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">-</span><span class="n">numStart</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">maxNumberChars</span><span class="p">))</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="no">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This prevents a 100 MB number token from being fully scanned before the bound check fires.</p>

<p>Every bound violation returns a <code class="language-plaintext highlighter-rouge">BoundExceeded</code> failure class, regardless of which bound was hit. This is a deliberate classification choice: the <em>cause</em> is “resource policy violation,” not “invalid grammar” or “I/O error.” Machines consuming the exit code can distinguish policy rejections from syntax errors.</p>

<h2 id="error-offset-tracking">Error Offset Tracking</h2>

<p>Every parser error includes a byte offset pointing to the source position of the violation:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="p">(</span><span class="n">p</span> <span class="o">*</span><span class="n">parser</span><span class="p">)</span> <span class="n">newError</span><span class="p">(</span><span class="n">msg</span> <span class="kt">string</span><span class="p">)</span> <span class="o">*</span><span class="n">jcserr</span><span class="o">.</span><span class="n">Error</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">jcserr</span><span class="o">.</span><span class="n">New</span><span class="p">(</span><span class="n">jcserr</span><span class="o">.</span><span class="n">InvalidGrammar</span><span class="p">,</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="p">,</span> <span class="n">msg</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For most errors, the offset is the parser’s current position, the byte where parsing failed. But for escape sequences and surrogate pairs, the offset points to the <em>start</em> of the escape that caused the problem, not the byte where the violation was detected:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">b</span> <span class="o">==</span> <span class="sc">'\\'</span> <span class="p">{</span>
    <span class="n">escapeStart</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">pos</span>   <span class="c">// Record position of the backslash</span>
    <span class="n">p</span><span class="o">.</span><span class="n">pos</span><span class="o">++</span>
    <span class="n">r</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">p</span><span class="o">.</span><span class="n">parseEscape</span><span class="p">(</span><span class="n">escapeStart</span><span class="p">)</span>  <span class="c">// Pass source position</span>
    <span class="c">// ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>For a lone high surrogate in <code class="language-plaintext highlighter-rouge">"\uD800"</code>, the offset is 1 (the backslash). For a high surrogate followed by a non-low surrogate in <code class="language-plaintext highlighter-rouge">"\uD800\u0041"</code>, the offset is 7 (the second backslash). This matters for tooling: an editor or diagnostic tool can highlight the exact source token that caused the rejection.</p>

<p>The offset semantics are stable across the failure taxonomy. Parse errors always report byte positions in the original input. CLI errors use offset -1 (not applicable). Bound violations report the start of the bounded element (e.g., the first byte of a too-long number token).</p>

<h2 id="what-strictness-buys-you">What Strictness Buys You</h2>

<p>A lenient parser makes these implicit decisions:</p>
<ul>
  <li>“Leading zeros are fine” → Different parsers may interpret <code class="language-plaintext highlighter-rouge">012</code> as octal 10 or decimal 12</li>
  <li>“Lone surrogates get replaced” → The canonical output of <code class="language-plaintext highlighter-rouge">\uD800</code> is undefined</li>
  <li>“Duplicate keys use last value” → Or first value, depending on implementation</li>
  <li>“Trailing content is ignored” → <code class="language-plaintext highlighter-rouge">{"a":1}extra</code> silently becomes <code class="language-plaintext highlighter-rouge">{"a":1}</code></li>
</ul>

<p>A strict parser converts these implicit decisions into explicit rejections. The downstream consumer never has to wonder whether the input was ambiguous. If it parsed, it has exactly one interpretation. If it didn’t parse, the failure class and byte offset tell the consumer exactly what went wrong and where.</p>

<p>For infrastructure that depends on deterministic processing, this is the difference between “it works in my tests” and “it works because ambiguous input is structurally excluded.”</p>

<h3 id="strictness-as-error-budget">Strictness as Error Budget</h3>

<p>There’s a useful way to think about parser strictness: it’s an error budget. A lenient parser spends its error budget on user convenience, accepting malformed input so users don’t have to fix their data. A strict parser spends its error budget on correctness guarantees, ensuring every accepted input has exactly one interpretation.</p>

<p>Neither is wrong. They serve different purposes. But when you build infrastructure that sits between systems, processing input from one machine and producing output consumed by another, spending the error budget on convenience is spending it on the wrong consumer. The machine downstream doesn’t benefit from leniency. It benefits from guarantees.</p>

<p>The parser code in the <code class="language-plaintext highlighter-rouge">jcstoken</code> package exists to provide one guarantee: if the parser returns a value, that value has a single, unambiguous, deterministic canonical representation. Every rejection (leading zeros, lone surrogates, duplicate keys, noncharacters, overflow, underflow, negative zero) eliminates a case where two consumers might disagree.</p>

<p>The implementation lives in the <code class="language-plaintext highlighter-rouge">jcstoken</code> package of <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library. For callers who want a single parse-and-serialize call, <code class="language-plaintext highlighter-rouge">jcs.Canonicalize(input)</code> wraps this parser with the RFC 8785 serializer.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Corrected leading-zero quote attribution (RFC 8259, not ECMA-404 phrasing); fixed -0 policy attribution (project policy, not RFC 7493); removed stale source line reference; corrected bounds check timing (before add, not after)</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Removed line-count claims; added <code class="language-plaintext highlighter-rouge">jcs.Canonicalize</code> API reference</td>
    </tr>
    <tr>
      <td>2026-02-26</td>
      <td>Initial publication</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="parsing" /><category term="json" /><summary type="html"><![CDATA[A strict Go parser that enforces every constraint in RFC 8259. Surrogate pair validation, noncharacter detection, duplicate key rejection after escape decoding, and seven independent resource bounds, all things encoding/json silently accepts.]]></summary></entry><entry><title type="html">The Small Decisions That Infrastructure Depends On</title><link href="https://lattice-substrate.github.io/blog/2026/02/25/small-decisions-infrastructure-primitive/" rel="alternate" type="text/html" title="The Small Decisions That Infrastructure Depends On" /><published>2026-02-25T00:00:00+00:00</published><updated>2026-02-25T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/02/25/small-decisions-infrastructure-primitive</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/02/25/small-decisions-infrastructure-primitive/"><![CDATA[<p>Infrastructure software isn’t defined by one big architectural choice. It’s defined by getting dozens of small decisions right. Decisions that most projects skip because they seem unimportant until they aren’t.</p>

<p>Most software fails at the center: the core algorithm is wrong, the architecture doesn’t scale, the data model can’t represent the domain. Infrastructure software fails at the margins: the sort order is subtly wrong for one class of inputs, the error code changes in a patch release and breaks a CI pipeline, the upgrade removes a flag that a deployment script depends on.</p>

<p>These marginal failures are harder to prevent because they’re harder to see. They don’t cause test failures during development. They cause production incidents three months after a routine upgrade.</p>

<p>This article examines three categories of these decisions through the lens of a JSON canonicalization library: a correctness detail that affects sort order, a failure classification system that enables machine automation, and an ABI stability discipline that prevents upgrade emergencies. None of these individually is difficult. Together, they define whether downstream systems can depend on you without reservations.</p>

<h2 id="correctness-detail-utf-16-vs-utf-8-key-sorting">Correctness Detail: UTF-16 vs UTF-8 Key Sorting</h2>

<p><a href="https://www.rfc-editor.org/rfc/rfc8785">RFC 8785</a> (JSON Canonicalization Scheme) requires that object keys be sorted by lexicographic order of UTF-16 code units. That is not UTF-8 byte order, and it is not Unicode scalar-value order.</p>

<p>For most strings, these orderings are identical. They diverge when you have characters above U+FFFF (the upper boundary of the Basic Multilingual Plane). Here’s why.</p>

<p>Characters above U+FFFF require four bytes in UTF-8 but are encoded as <em>surrogate pairs</em> in UTF-16, as two 16-bit code units. The high surrogate falls in the range U+D800-U+DBFF. Consider two characters:</p>

<ul>
  <li><strong>U+10000</strong> (LINEAR B SYLLABLE B008 A): UTF-16 encoding is <code class="language-plaintext highlighter-rouge">D800 DC00</code> (surrogate pair). UTF-8 encoding is <code class="language-plaintext highlighter-rouge">F0 90 80 80</code>.</li>
  <li><strong>U+E000</strong> (first Private Use Area character): UTF-16 encoding is <code class="language-plaintext highlighter-rouge">E000</code> (single code unit). UTF-8 encoding is <code class="language-plaintext highlighter-rouge">EE 80 80</code>.</li>
</ul>

<p>In UTF-16 code-unit order: U+10000 (<code class="language-plaintext highlighter-rouge">D800</code>) &lt; U+E000 (<code class="language-plaintext highlighter-rouge">E000</code>). The high surrogate <code class="language-plaintext highlighter-rouge">D800</code> is numerically less than <code class="language-plaintext highlighter-rouge">E000</code>.</p>

<p>In UTF-8 byte order: U+E000 (<code class="language-plaintext highlighter-rouge">EE</code>) &lt; U+10000 (<code class="language-plaintext highlighter-rouge">F0</code>). The three-byte UTF-8 prefix <code class="language-plaintext highlighter-rouge">EE</code> is numerically less than the four-byte prefix <code class="language-plaintext highlighter-rouge">F0</code>.</p>

<p>The two orderings disagree. RFC 8785 mandates UTF-16 code-unit order (see §3.2.3). Most canonicalization implementations get this wrong because they use byte-order comparison, which works for UTF-8 strings but produces a different order than the specification requires.</p>

<p>Why does RFC 8785 use UTF-16 code-unit order instead of the more natural (for modern systems) Unicode code-point order? Because JCS is defined for interoperability with ECMAScript string serialization rules, and ECMAScript strings are sequences of 16-bit code units. The required sort is a pure lexicographic code-unit comparison, not locale-aware collation (<code class="language-plaintext highlighter-rouge">localeCompare</code> is locale-sensitive and is not the JCS rule).</p>

<p>This means every implementation in a language with UTF-8 native strings (Go, Rust, Python 3) must explicitly convert to UTF-16 for comparison. Implementations that skip this conversion will produce correct output for the BMP (U+0000 to U+FFFF) and incorrect output for supplementary-plane characters. Since supplementary-plane characters are rare in practice (emoji, historic scripts, mathematical symbols), the bug is unlikely to surface in casual testing. It will surface when a production system encounters a key containing an emoji or a CJK Extension B character.</p>

<p>The implementation is compact:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">serializeObject</span><span class="p">(</span><span class="n">buf</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">,</span> <span class="n">v</span> <span class="o">*</span><span class="n">jcstoken</span><span class="o">.</span><span class="n">Value</span><span class="p">)</span> <span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">sorted</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="n">sortableMember</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">Members</span><span class="p">))</span>
    <span class="k">for</span> <span class="n">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">v</span><span class="o">.</span><span class="n">Members</span> <span class="p">{</span>
        <span class="n">sorted</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">member</span> <span class="o">=</span> <span class="n">v</span><span class="o">.</span><span class="n">Members</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
        <span class="c">// Fast path: ASCII keys need no UTF-16 encoding since byte order</span>
        <span class="c">// equals UTF-16 code-unit order for U+0000..U+007F.</span>
        <span class="k">if</span> <span class="o">!</span><span class="n">isASCII</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">Members</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">Key</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">sorted</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">key16</span> <span class="o">=</span> <span class="n">utf16</span><span class="o">.</span><span class="n">Encode</span><span class="p">([]</span><span class="kt">rune</span><span class="p">(</span><span class="n">v</span><span class="o">.</span><span class="n">Members</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">Key</span><span class="p">))</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="n">sort</span><span class="o">.</span><span class="n">Slice</span><span class="p">(</span><span class="n">sorted</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">compareSortKeys</span><span class="p">(</span><span class="o">&amp;</span><span class="n">sorted</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="o">&amp;</span><span class="n">sorted</span><span class="p">[</span><span class="n">j</span><span class="p">])</span> <span class="o">&lt;</span> <span class="m">0</span>
    <span class="p">})</span>
    <span class="c">// ... serialize sorted members</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Non-ASCII keys are converted from Go’s native UTF-8 string to a <code class="language-plaintext highlighter-rouge">[]uint16</code> via <code class="language-plaintext highlighter-rouge">utf16.Encode([]rune(key))</code>. ASCII-only keys skip this conversion; byte order and UTF-16 code-unit order are identical for U+0000..U+007F. The <code class="language-plaintext highlighter-rouge">compareSortKeys</code> function handles both cases, falling back to <code class="language-plaintext highlighter-rouge">compareUTF16Units</code> when either key contains non-ASCII characters:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">compareSortKeys</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span> <span class="o">*</span><span class="n">sortableMember</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">key16</span> <span class="o">==</span> <span class="no">nil</span> <span class="o">&amp;&amp;</span> <span class="n">b</span><span class="o">.</span><span class="n">key16</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span> <span class="o">&lt;</span> <span class="n">b</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span> <span class="p">{</span>
            <span class="k">return</span> <span class="o">-</span><span class="m">1</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span> <span class="o">&gt;</span> <span class="n">b</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span> <span class="p">{</span>
            <span class="k">return</span> <span class="m">1</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="m">0</span>
    <span class="p">}</span>
    <span class="n">ak</span> <span class="o">:=</span> <span class="n">a</span><span class="o">.</span><span class="n">key16</span>
    <span class="k">if</span> <span class="n">ak</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">ak</span> <span class="o">=</span> <span class="n">utf16</span><span class="o">.</span><span class="n">Encode</span><span class="p">([]</span><span class="kt">rune</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="n">bk</span> <span class="o">:=</span> <span class="n">b</span><span class="o">.</span><span class="n">key16</span>
    <span class="k">if</span> <span class="n">bk</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">bk</span> <span class="o">=</span> <span class="n">utf16</span><span class="o">.</span><span class="n">Encode</span><span class="p">([]</span><span class="kt">rune</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="n">member</span><span class="o">.</span><span class="n">Key</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">compareUTF16Units</span><span class="p">(</span><span class="n">ak</span><span class="p">,</span> <span class="n">bk</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>When both keys are ASCII (<code class="language-plaintext highlighter-rouge">key16 == nil</code>), direct string comparison produces identical ordering, with no allocation needed. When either key contains non-ASCII characters, the function falls back to full UTF-16 code-unit comparison via <code class="language-plaintext highlighter-rouge">compareUTF16Units</code>. This is the only correct approach for RFC 8785 compliance. The canonical output for an object with keys U+10000 and U+E000 places U+10000 <em>first</em>, the opposite of what a naive byte-order sort would produce.</p>

<h3 id="string-escaping-what-gets-escaped-and-why">String Escaping: What Gets Escaped and Why</h3>

<p>Canonicalization also requires deterministic string escaping. The rules are specific:</p>

<table>
  <thead>
    <tr>
      <th>Character</th>
      <th>Canonical Form</th>
      <th>Rule</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>U+0008 (backspace)</td>
      <td><code class="language-plaintext highlighter-rouge">\b</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+0009 (tab)</td>
      <td><code class="language-plaintext highlighter-rouge">\t</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+000A (newline)</td>
      <td><code class="language-plaintext highlighter-rouge">\n</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+000C (form feed)</td>
      <td><code class="language-plaintext highlighter-rouge">\f</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+000D (carriage return)</td>
      <td><code class="language-plaintext highlighter-rouge">\r</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+0022 (quotation mark)</td>
      <td><code class="language-plaintext highlighter-rouge">\"</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+005C (reverse solidus)</td>
      <td><code class="language-plaintext highlighter-rouge">\\</code></td>
      <td>Named escape</td>
    </tr>
    <tr>
      <td>U+0000-U+001F (other controls)</td>
      <td><code class="language-plaintext highlighter-rouge">\u00xx</code></td>
      <td>Lowercase hex, zero-padded</td>
    </tr>
    <tr>
      <td>U+002F (solidus <code class="language-plaintext highlighter-rouge">/</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">/</code></td>
      <td>NOT escaped</td>
    </tr>
    <tr>
      <td>Everything else above U+001F</td>
      <td>Raw UTF-8</td>
      <td>No escaping</td>
    </tr>
  </tbody>
</table>

<p>The solidus rule is worth noting. JSON allows <code class="language-plaintext highlighter-rouge">\/</code> as a valid escape, and many serializers produce it. RFC 8785 specifies that solidus is <em>not</em> escaped in canonical output. This is a one-line decision in the implementation, but it means any test that compares canonical output byte-for-byte must agree on this point.</p>

<h2 id="failure-contracts-designing-a-taxonomy-that-machines-can-depend-on">Failure Contracts: Designing a Taxonomy That Machines Can Depend On</h2>

<p>Most error handling in CLI tools follows a pattern: print a message to stderr, exit non-zero. The message is for humans. The exit code is a vague signal, usually 1 for “something went wrong.”</p>

<p>This is insufficient for machine automation. When a CI pipeline runs a canonicalization check, it needs to distinguish between “the input was malformed JSON” (a build error) and “stdout couldn’t be written” (an infrastructure problem). The human can read the message. The script needs the exit code.</p>

<h3 id="13-classes-3-exit-codes">13 Classes, 3 Exit Codes</h3>

<p>The failure taxonomy defines 13 failure classes mapped to 3 exit codes:</p>

<table>
  <thead>
    <tr>
      <th>Exit Code</th>
      <th>Meaning</th>
      <th>Classes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>Success</td>
      <td>-</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Input rejection</td>
      <td>INVALID_UTF8, INVALID_GRAMMAR, DUPLICATE_KEY, LONE_SURROGATE, NONCHARACTER, NUMBER_OVERFLOW, NUMBER_NEGZERO, NUMBER_UNDERFLOW, BOUND_EXCEEDED, NOT_CANONICAL, CLI_USAGE</td>
    </tr>
    <tr>
      <td>10</td>
      <td>Internal error</td>
      <td>INTERNAL_IO, INTERNAL_ERROR</td>
    </tr>
  </tbody>
</table>

<p>The exit code space is deliberately sparse. Codes 0, 2, and 10 leave room for future expansion without breaking existing scripts that check <code class="language-plaintext highlighter-rouge">$?</code>. Eleven classes share exit code 2 because they all represent the same decision for automation: “the input or invocation was wrong; don’t retry without changing something.” Exit code 1 is intentionally avoided; many shells and tools use 1 as a generic failure code, and conflating tool-specific failures with generic failure would reduce the signal.</p>

<p>The two exit-10 classes represent a fundamentally different situation: the tool itself encountered a problem. A CI pipeline should fail the build on exit 2 (bad input is a build error) but might alert ops on exit 10 (the build infrastructure has a problem).</p>

<p>Why 13 classes instead of 3? Because the exit code is a coarse signal for automation, while the class is a fine-grained signal for diagnostics. A script checks the exit code. A log aggregator or monitoring system can parse the class name from stderr. Having <code class="language-plaintext highlighter-rouge">DUPLICATE_KEY</code> as a distinct class from <code class="language-plaintext highlighter-rouge">INVALID_GRAMMAR</code> lets a monitoring dashboard show “we’re seeing a spike in duplicate-key rejections” without confusing it with syntax errors.</p>

<h3 id="root-cause-classification">Root-Cause Classification</h3>

<p>The classification principle is: classify by <em>root cause</em>, not by error <em>origin</em>.</p>

<p>The clearest example: a missing file path. The user runs <code class="language-plaintext highlighter-rouge">jcs-canon canonicalize /nonexistent.json</code> and the file doesn’t exist. This is an <code class="language-plaintext highlighter-rouge">os.Open</code> error. An I/O error. So it should be <code class="language-plaintext highlighter-rouge">INTERNAL_IO</code>, right?</p>

<p>No. It should be <code class="language-plaintext highlighter-rouge">CLI_USAGE</code> (exit 2).</p>

<p>The root cause is that the user supplied an invalid argument. The file path is user input, just like the JSON content. When the path cannot be opened, the problem is the same as providing an unknown flag or specifying two input files. The invocation is wrong.</p>

<p><code class="language-plaintext highlighter-rouge">INTERNAL_IO</code> is reserved for failures <em>after</em> a valid I/O channel is established. If <code class="language-plaintext highlighter-rouge">os.Open</code> succeeds but a later <code class="language-plaintext highlighter-rouge">Read</code> fails due to a broken pipe, or if writing to stdout fails because the pipe was closed. Those are infrastructure problems. The channel was valid; something broke during operation.</p>

<p>This distinction matters for automation:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jcs-canon canonicalize input.json
<span class="k">case</span> <span class="nv">$?</span> <span class="k">in
  </span>0<span class="p">)</span>  <span class="nb">echo</span> <span class="s2">"Success"</span> <span class="p">;;</span>
  2<span class="p">)</span>  <span class="nb">echo</span> <span class="s2">"Input or usage problem - fix the invocation"</span> <span class="p">;;</span>
  10<span class="p">)</span> <span class="nb">echo</span> <span class="s2">"Infrastructure problem - investigate the environment"</span> <span class="p">;;</span>
<span class="k">esac</span>
</code></pre></div></div>

<h3 id="the-structured-error-type">The Structured Error Type</h3>

<p>Every error in the system is a <code class="language-plaintext highlighter-rouge">*jcserr.Error</code> with four fields:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">Error</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Class</span>   <span class="n">FailureClass</span>   <span class="c">// Stable category (determines exit code)</span>
    <span class="n">Offset</span>  <span class="kt">int</span>            <span class="c">// Source-byte position, or -1</span>
    <span class="n">Message</span> <span class="kt">string</span>         <span class="c">// Human-readable diagnostic</span>
    <span class="n">Cause</span>   <span class="kt">error</span>          <span class="c">// Underlying error (for wrapping)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Class</code> field is the stable contract. The <code class="language-plaintext highlighter-rouge">Message</code> field is <em>not</em> stable; its wording may change in minor releases. This is explicit policy: machines should switch on the class, not parse the message string. The <code class="language-plaintext highlighter-rouge">Offset</code> field gives byte-precise error positions for parse failures, enabling editors and diagnostic tools to highlight the exact location.</p>

<h3 id="offset-semantics">Offset Semantics</h3>

<p>The offset field deserves its own discussion because it’s an area where most error reporting gets lazy.</p>

<p>For a simple parse error like a leading zero in <code class="language-plaintext highlighter-rouge">{"n":01}</code>, the offset points to the <code class="language-plaintext highlighter-rouge">0</code> at the start of the number token. Straightforward. But for errors inside escape sequences, the offset must point to the <em>originating escape</em>, not the byte where the violation was detected.</p>

<p>Consider the string <code class="language-plaintext highlighter-rouge">"\uD800\u0041"</code>. This contains a high surrogate (U+D800) followed by a non-low-surrogate (U+0041). The parser detects the error when reading the second escape sequence, but the error offset points to byte 7 (the backslash of <code class="language-plaintext highlighter-rouge">\u0041</code>), not to byte 1 (the backslash of <code class="language-plaintext highlighter-rouge">\uD800</code>). This is because the <em>violation</em> is in the second escape: a high surrogate was followed by U+0041 instead of a low surrogate. Pointing to the first escape would be misleading: <code class="language-plaintext highlighter-rouge">\uD800</code> isn’t inherently wrong; it’s wrong because of what follows.</p>

<p>Conversely, for a lone low surrogate like <code class="language-plaintext highlighter-rouge">"\uDC00"</code>, the offset points to byte 1 (the backslash) because the low surrogate appearing without a preceding high surrogate is the violation.</p>

<p>These offset semantics are stable across the failure taxonomy and documented as part of the error contract. Changing where an offset points is a behavioral change, even if the class and message remain the same.</p>

<p>The error format is also deliberate:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jcserr: INVALID_GRAMMAR at byte 15: leading zero in number
jcserr: CLI_USAGE: read file "config.json": no such file or directory
jcserr: INTERNAL_IO: writing output: write: broken pipe
</code></pre></div></div>

<p>The class name always appears after <code class="language-plaintext highlighter-rouge">jcserr:</code>. Byte offsets appear only for parse-time errors (where they’re meaningful). The cause chain preserves the underlying error for debugging without losing the classification.</p>

<h2 id="abi-stability-preventing-upgrade-emergencies">ABI Stability: Preventing Upgrade Emergencies</h2>

<p>For a CLI tool consumed by scripts and CI pipelines, the <em>interface</em> is the product. Changing a flag name, moving output between stdout and stderr, or redefining an exit code breaks every downstream consumer. These breakages are invisible in your test suite and visible only in your users’ build failures.</p>

<h3 id="defining-the-stable-surface">Defining the Stable Surface</h3>

<p>The stable ABI surface includes:</p>

<ol>
  <li><strong>Command names</strong>: <code class="language-plaintext highlighter-rouge">canonicalize</code>, <code class="language-plaintext highlighter-rouge">verify</code></li>
  <li><strong>Flag names and semantics</strong>: <code class="language-plaintext highlighter-rouge">--quiet</code>, <code class="language-plaintext highlighter-rouge">--help</code>, <code class="language-plaintext highlighter-rouge">--version</code></li>
  <li><strong>Exit code mapping</strong>: 0/2/10 with defined semantics</li>
  <li><strong>Output stream placement</strong>: canonical data to stdout, diagnostics to stderr, verify result to stderr</li>
  <li><strong>Failure class names in stderr</strong>: The class name <code class="language-plaintext highlighter-rouge">INVALID_GRAMMAR</code> is stable; the surrounding message text is not</li>
  <li><strong>Canonical output bytes</strong>: identical input must produce identical stdout bytes</li>
</ol>

<p>Notably absent from the stable surface: the exact wording of error messages and help text. These are explicitly non-stable, allowing improvements to diagnostics without a major version bump.</p>

<p>There is one subtlety here: failure class <em>names</em> that appear in stderr (e.g., <code class="language-plaintext highlighter-rouge">INVALID_GRAMMAR</code> in the error output) are stable, even though the surrounding message text is not. This lets machines extract the class name from stderr as a fallback when they can’t use the exit code alone, for instance when a script needs to distinguish between <code class="language-plaintext highlighter-rouge">INVALID_GRAMMAR</code> and <code class="language-plaintext highlighter-rouge">DUPLICATE_KEY</code>, both of which share exit code 2.</p>

<p>The stream placement policy is also part of the stable surface. The <code class="language-plaintext highlighter-rouge">canonicalize</code> command writes canonical bytes to stdout and diagnostics to stderr. The <code class="language-plaintext highlighter-rouge">verify</code> command writes nothing to stdout and writes <code class="language-plaintext highlighter-rouge">ok\n</code> to stderr on success (suppressible with <code class="language-plaintext highlighter-rouge">--quiet</code>). If a future version moved the verify result from stderr to stdout, every script that captures <code class="language-plaintext highlighter-rouge">verify</code> output would break. This is why stream placement is documented, tested, and governed by SemVer.</p>

<h3 id="the-machine-readable-contract">The Machine-Readable Contract</h3>

<p>The ABI is documented in two forms. <code class="language-plaintext highlighter-rouge">ABI.md</code> is the human-readable specification. <code class="language-plaintext highlighter-rouge">abi_manifest.json</code> is the machine-readable contract:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"abi_version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"tool"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jcs-canon"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"commands"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"canonicalize"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
      </span><span class="nl">"flags"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"--quiet"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-q"</span><span class="p">,</span><span class="w"> </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">},</span><span class="w">
        </span><span class="nl">"--help"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-h"</span><span class="p">,</span><span class="w"> </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">}</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"stdout"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Canonical JSON bytes (on success)"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"stderr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Error diagnostics (on failure)"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"exit_codes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"verify"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
      </span><span class="nl">"flags"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"--quiet"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-q"</span><span class="p">,</span><span class="w"> </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">},</span><span class="w">
        </span><span class="nl">"--help"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"short"</span><span class="p">:</span><span class="w"> </span><span class="s2">"-h"</span><span class="p">,</span><span class="w"> </span><span class="nl">"stable"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">}</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"stdout"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Empty (verify never writes to stdout)"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"stderr"</span><span class="p">:</span><span class="w"> </span><span class="s2">"'ok</span><span class="se">\\</span><span class="s2">n' on success (unless --quiet), error diagnostics on failure"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"exit_codes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="mi">0</span><span class="p">,</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w"> </span><span class="mi">10</span><span class="p">]</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"exit_codes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"0"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"SUCCESS"</span><span class="p">},</span><span class="w">
    </span><span class="nl">"2"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"INPUT_REJECTION"</span><span class="p">},</span><span class="w">
    </span><span class="nl">"10"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="nl">"class"</span><span class="p">:</span><span class="w"> </span><span class="s2">"INTERNAL_ERROR"</span><span class="p">}</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"failure_classes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"INVALID_UTF8"</span><span class="p">,</span><span class="w"> </span><span class="nl">"exit_code"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"INVALID_GRAMMAR"</span><span class="p">,</span><span class="w"> </span><span class="nl">"exit_code"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DUPLICATE_KEY"</span><span class="p">,</span><span class="w"> </span><span class="nl">"exit_code"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">},</span><span class="w">
    </span><span class="err">...</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"compatibility"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"policy"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Strict SemVer. Any change to items marked 'stable: true' requires major version bump."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"stderr_wording"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Non-stable. Diagnostic message text may change in minor/patch releases."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"exit_code_mapping"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Stable. Failure class → exit code mapping is frozen."</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This file serves two purposes. First, it’s a test fixture: CI validates that the tool’s actual behavior matches the manifest’s claims. Second, it’s a communication device for downstream consumers. A script author can read the manifest to understand what’s stable, what might change, and what exit codes to expect, without parsing prose documentation.</p>

<h3 id="why-a-manifest-instead-of-just-documentation">Why a Manifest Instead of Just Documentation</h3>

<p>A reasonable question: why maintain both <code class="language-plaintext highlighter-rouge">ABI.md</code> (human-readable) and <code class="language-plaintext highlighter-rouge">abi_manifest.json</code> (machine-readable)? Isn’t the documentation sufficient?</p>

<p>Documentation drifts. When a developer adds a flag, they update the code and the tests, but they may forget to update the ABI documentation. The manifest is testable: CI can validate that the tool’s actual behavior matches the manifest’s claims. A test that parses <code class="language-plaintext highlighter-rouge">abi_manifest.json</code> and checks that <code class="language-plaintext highlighter-rouge">jcs-canon verify --help</code> exits 0, that <code class="language-plaintext highlighter-rouge">jcs-canon verify</code> produces <code class="language-plaintext highlighter-rouge">ok\n</code> on stderr for valid canonical input, and that error output goes to stderr, will catch ABI drift that documentation review would miss.</p>

<p>The manifest also serves as a communication device. A downstream consumer can <code class="language-plaintext highlighter-rouge">curl</code> the manifest from a release and programmatically determine what the stable surface looks like. This is more reliable than scraping a README.</p>

<h3 id="change-control">Change Control</h3>

<p>The SemVer rules are strict and non-negotiable:</p>

<ul>
  <li><strong>Patch releases</strong> (0.2.0 → 0.2.1): No behavior changes to stable surface items</li>
  <li><strong>Minor releases</strong> (0.2.x → 0.3.0): May add new commands or flags, but existing behavior is preserved</li>
  <li><strong>Major releases</strong> (0.x.x → 1.0.0): May change anything, with migration guidance</li>
</ul>

<p>Any ABI-impacting change must update <em>four artifacts in the same commit series</em>: the implementation, <code class="language-plaintext highlighter-rouge">abi_manifest.json</code>, test assertions, and <code class="language-plaintext highlighter-rouge">CHANGELOG.md</code>. This co-update requirement prevents the manifest from drifting out of sync with the code. A test that validates the manifest against actual behavior catches drift that documentation alone would miss.</p>

<h2 id="the-compound-effect">The Compound Effect</h2>

<p>Each of these decisions is individually minor. None of them requires novel engineering. None of them is exciting. A project that omits all three still works. It parses JSON, it serializes JSON, it runs from the command line.</p>

<p>But the project that includes all three communicates something different to its consumers. It says: “We have thought about the cases you will encounter. The sort order is correct, not just close. The exit codes mean what we say they mean, and we won’t change them without a major version. The error you get for a missing file is classified by root cause, not by which system call failed.”</p>

<p>Infrastructure trust is not earned by one big decision. It’s earned by consistent attention to the decisions that are easy to defer and hard to fix later. The sort order that’s wrong by one comparison. The exit code that changes in a patch release. The error message that scripts parse because there’s no stable classification.</p>

<p>These are the margins where infrastructure fails.</p>

<p>Consider the alternative. A project that uses UTF-8 byte-order sorting passes 99.9% of real-world key comparisons correctly; supplementary-plane characters in JSON keys are rare. But the 0.1% failure is a silent correctness bug, not a crash. The canonical output is wrong for those inputs, and the consumer has no way to detect it without an independent reference implementation. This is the kind of bug that ships, is discovered months later, and then can’t be fixed without a breaking change because existing consumers depend on the (wrong) output.</p>

<p>A project with unstructured exit codes works fine for human use. But the first time someone writes a CI pipeline that retries on exit 1, they’ll retry on parse errors (which will never succeed) and on I/O errors (which might). Distinguishing these requires parsing stderr text, which breaks when the message wording changes in a patch release.</p>

<p>A project without an ABI manifest accumulates undocumented behavioral changes. Each one seems minor: renaming a flag, changing where help text goes, adding a new exit code. But each one is a potential breaking change for a downstream consumer who discovered the behavior empirically and depended on it.</p>

<p>Getting these details right is not the interesting part of the engineering. But it’s the part that determines whether your downstream can depend on you.</p>

<p>The implementation discussed here is from <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library written in Go.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Corrected U+10000 character name (LINEAR B SYLLABLE B008 A); clarified BMP boundary phrasing</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Renamed article; revised title, opening, and series name</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Removed line-count metrics from compound-effect section; updated <code class="language-plaintext highlighter-rouge">serializeObject</code> and sort comparator snippets to reflect ASCII fast-path optimization</td>
    </tr>
    <tr>
      <td>2026-02-25</td>
      <td>Initial publication</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="architecture" /><category term="engineering" /><summary type="html"><![CDATA[UTF-16 code-unit key sorting, a thirteen-class failure taxonomy with stable exit codes, and machine-readable ABI contracts. The small decisions that determine whether downstream systems can depend on you.]]></summary></entry><entry><title type="html">Proving Determinism: Evidence-Based Release Engineering</title><link href="https://lattice-substrate.github.io/blog/2026/02/24/proving-determinism-evidence-release/" rel="alternate" type="text/html" title="Proving Determinism: Evidence-Based Release Engineering" /><published>2026-02-24T00:00:00+00:00</published><updated>2026-02-24T00:00:00+00:00</updated><id>https://lattice-substrate.github.io/blog/2026/02/24/proving-determinism-evidence-release</id><content type="html" xml:base="https://lattice-substrate.github.io/blog/2026/02/24/proving-determinism-evidence-release/"><![CDATA[<p>Any project can claim deterministic output. “Our tests pass” is not a determinism proof. It’s a confidence signal from one machine, one OS, one kernel, at one point in time.</p>

<p>For a JSON canonicalization library, determinism is the product. If the same input produces different bytes on Ubuntu vs Alpine, on x86_64 vs arm64, or on the third run vs the first run, the tool is broken, regardless of what the test suite says.</p>

<p>This article describes how to build an offline replay harness that produces <em>executable evidence</em> of determinism across distributions, kernels, and CPU architectures, and how to gate releases on that evidence. The approach is transferable to any project where output stability matters.</p>

<h2 id="the-problem-it-works-on-my-machine">The Problem: “It Works on My Machine”</h2>

<p>Unit tests prove that functions return correct values. Integration tests prove that components work together. Neither proves that the tool produces <em>identical output</em> across environments.</p>

<p>Consider what can differ between two Linux machines running the same Go binary:</p>

<ul>
  <li><strong>C library</strong>: glibc (Ubuntu, Debian, Fedora) vs musl (Alpine). Go is statically compiled, so this shouldn’t matter, but “shouldn’t” is not proof.</li>
  <li><strong>Kernel version</strong>: syscall behavior, filesystem semantics, memory layout. Go’s runtime abstracts these, but abstractions can leak.</li>
  <li><strong>CPU architecture</strong>: x86_64 vs arm64. Floating-point rounding, SIMD optimizations, and endianness. Go generates architecture-specific code.</li>
  <li><strong>Runtime initialization</strong>: Go’s runtime performs memory allocation, goroutine scheduling, and GC initialization. Any of these could influence program behavior if the code contains latent non-determinism.</li>
</ul>

<p>For a canonicalization tool, any of these differences producing a different output byte is a correctness failure. You can’t prove the absence of these failures by testing on one machine. You need to test on <em>many</em> machines and compare the results.</p>

<h2 id="what-evidence-looks-like">What Evidence Looks Like</h2>

<p>The harness runs the tool against a fixed set of inputs on multiple nodes: different Linux distributions, container and VM execution modes, x86_64 and arm64. Each node runs the tool multiple times. Every run records SHA-256 digests of its output. At the end, a validation function checks that <em>every digest matches</em>.</p>

<p>The output is a JSON evidence bundle:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"schema_version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"evidence.v1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"bundle_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"5654748feaa65318..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"control_binary_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"e0296e034d1440a4..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"source_git_commit"</span><span class="p">:</span><span class="w"> </span><span class="s2">"da4a4ee6fcefc4f4..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"source_git_tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"v0.2.1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"architecture"</span><span class="p">:</span><span class="w"> </span><span class="s2">"x86_64"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hard_release_gate"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"node_replays"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"node_id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpine320-container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"distro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpine-3.20"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"replay_index"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
      </span><span class="nl">"case_count"</span><span class="p">:</span><span class="w"> </span><span class="mi">74</span><span class="p">,</span><span class="w">
      </span><span class="nl">"passed"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
      </span><span class="nl">"canonical_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2818166c21e1b445..."</span><span class="p">,</span><span class="w">
      </span><span class="nl">"verify_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"66d329b3bd829da5..."</span><span class="p">,</span><span class="w">
      </span><span class="nl">"failure_class_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"af58643f979138da..."</span><span class="p">,</span><span class="w">
      </span><span class="nl">"exit_code_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"73d91ef3f2fd6d70..."</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"aggregate_canonical_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2818166c21e1b445..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"aggregate_verify_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"66d329b3bd829da5..."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>When all 60 replays (12 nodes times 5 replays each) produce identical digests, the evidence shows that canonical output is byte-stable <strong>for that specific binary across the tested matrix</strong>.</p>

<h2 id="proof-boundary-what-this-evidence-proves">Proof Boundary: What This Evidence Proves</h2>

<p>This matters for rigor. The evidence bundle does <strong>not</strong> claim:</p>

<ul>
  <li>“all possible builds are deterministic,”</li>
  <li>“all Linux environments are covered,”</li>
  <li>or “future toolchains will preserve behavior automatically.”</li>
</ul>

<p>It does claim:</p>

<ul>
  <li>this exact control binary (by SHA-256),</li>
  <li>built from this exact source commit,</li>
  <li>produced identical output across this explicit replay matrix and replay count.</li>
</ul>

<p>That distinction is the boundary between engineering evidence and overclaiming. Determinism is established for a concrete artifact under a declared environment envelope.</p>

<h2 id="test-bundles-immutable-input-packages">Test Bundles: Immutable Input Packages</h2>

<p>The first requirement is <em>immutable inputs</em>. If the test data can change between runs, comparing digests is meaningless. The harness packages all inputs into a tar archive with properties that eliminate environmental variation.</p>

<h3 id="fixed-timestamps-and-ownership">Fixed Timestamps and Ownership</h3>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">writeBundleTarGz</span><span class="p">(</span><span class="n">path</span> <span class="kt">string</span><span class="p">,</span> <span class="n">entries</span> <span class="p">[]</span><span class="n">bundleEntry</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="c">// ...</span>
    <span class="n">fixed</span> <span class="o">:=</span> <span class="n">time</span><span class="o">.</span><span class="n">Unix</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">)</span><span class="o">.</span><span class="n">UTC</span><span class="p">()</span>
    <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">e</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">entries</span> <span class="p">{</span>
        <span class="n">hdr</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="n">tar</span><span class="o">.</span><span class="n">Header</span><span class="p">{</span>
            <span class="n">Name</span><span class="o">:</span>    <span class="n">e</span><span class="o">.</span><span class="n">path</span><span class="p">,</span>
            <span class="n">Mode</span><span class="o">:</span>    <span class="n">e</span><span class="o">.</span><span class="n">mode</span><span class="p">,</span>
            <span class="n">Size</span><span class="o">:</span>    <span class="kt">int64</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">data</span><span class="p">)),</span>
            <span class="n">ModTime</span><span class="o">:</span> <span class="n">fixed</span><span class="p">,</span>   <span class="c">// Unix epoch: 1970-01-01T00:00:00Z</span>
            <span class="n">Uid</span><span class="o">:</span>     <span class="m">0</span><span class="p">,</span>       <span class="c">// root</span>
            <span class="n">Gid</span><span class="o">:</span>     <span class="m">0</span><span class="p">,</span>       <span class="c">// root</span>
            <span class="n">Uname</span><span class="o">:</span>   <span class="s">"root"</span><span class="p">,</span>
            <span class="n">Gname</span><span class="o">:</span>   <span class="s">"root"</span><span class="p">,</span>
        <span class="p">}</span>
        <span class="n">tw</span><span class="o">.</span><span class="n">WriteHeader</span><span class="p">(</span><span class="n">hdr</span><span class="p">)</span>
        <span class="n">tw</span><span class="o">.</span><span class="n">Write</span><span class="p">(</span><span class="n">e</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every entry in the tar archive has its modification time set to the Unix epoch, its ownership set to root:root, and its permissions set by the code rather than the filesystem. This means the archive is byte-identical regardless of when, where, or by whom it was created.</p>

<h3 id="sorted-entries">Sorted Entries</h3>

<p>Tar archives are ordered. If entries are added in filesystem order, the archive depends on the filesystem’s iteration behavior, which can vary between ext4 and xfs, between Linux kernel versions, and between NFS and local disk. The harness sorts entries lexicographically before writing:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">sort</span><span class="o">.</span><span class="n">Slice</span><span class="p">(</span><span class="n">entries</span><span class="p">,</span> <span class="k">func</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">j</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">entries</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">.</span><span class="n">path</span> <span class="o">&lt;</span> <span class="n">entries</span><span class="p">[</span><span class="n">j</span><span class="p">]</span><span class="o">.</span><span class="n">path</span>
<span class="p">})</span>
</code></pre></div></div>

<h3 id="sha-256-binding">SHA-256 Binding</h3>

<p>The bundle manifest records SHA-256 checksums for every component:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">type</span> <span class="n">BundleManifest</span> <span class="k">struct</span> <span class="p">{</span>
    <span class="n">Version</span>         <span class="kt">string</span>            <span class="s">`json:"version"`</span>
    <span class="n">BinaryPath</span>      <span class="kt">string</span>            <span class="s">`json:"binary_path"`</span>
    <span class="n">BinarySHA256</span>    <span class="kt">string</span>            <span class="s">`json:"binary_sha256"`</span>
    <span class="n">WorkerPath</span>      <span class="kt">string</span>            <span class="s">`json:"worker_path"`</span>
    <span class="n">WorkerSHA256</span>    <span class="kt">string</span>            <span class="s">`json:"worker_sha256"`</span>
    <span class="n">MatrixPath</span>      <span class="kt">string</span>            <span class="s">`json:"matrix_path"`</span>
    <span class="n">MatrixSHA256</span>    <span class="kt">string</span>            <span class="s">`json:"matrix_sha256"`</span>
    <span class="n">ProfilePath</span>     <span class="kt">string</span>            <span class="s">`json:"profile_path"`</span>
    <span class="n">ProfileSHA256</span>   <span class="kt">string</span>            <span class="s">`json:"profile_sha256"`</span>
    <span class="n">VectorFiles</span>     <span class="p">[]</span><span class="kt">string</span>          <span class="s">`json:"vector_files"`</span>
    <span class="n">VectorSHA256</span>    <span class="k">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span> <span class="s">`json:"vector_sha256"`</span>
    <span class="n">VectorSetSHA256</span> <span class="kt">string</span>            <span class="s">`json:"vector_set_sha256"`</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The binary, worker, matrix, profile, and each vector file have independent checksums. The <code class="language-plaintext highlighter-rouge">VectorSetSHA256</code> is a digest of all vector file checksums combined (sorted by path), so changing any single vector file changes the set digest.</p>

<p>The bundle archive itself also gets a SHA-256 checksum, which the evidence bundle records. This creates a chain: the evidence references the bundle by digest, the bundle references each component by digest, and the release gate validates all of these against the actual files on disk.</p>

<p>This chain has the same integrity property as certificate chains: modifying any component invalidates every layer above it. If someone edits a single vector file, its SHA-256 changes, which changes the vector set SHA-256, which changes the bundle manifest, which changes the bundle archive SHA-256, which no longer matches the evidence bundle’s recorded value. The release gate catches this at the top of the chain without needing to know <em>which</em> component was modified.</p>

<h2 id="the-replay-matrix-defining-across-environments">The Replay Matrix: Defining “Across Environments”</h2>

<p>A matrix defines the nodes that must be tested:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"architecture"</span><span class="p">:</span><span class="w"> </span><span class="s2">"x86_64"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"nodes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debian12-container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"distro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debian-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kernel_family"</span><span class="p">:</span><span class="w"> </span><span class="s2">"host"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"replays"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpine320-container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"container"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"distro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"alpine-3.20"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kernel_family"</span><span class="p">:</span><span class="w"> </span><span class="s2">"host"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"replays"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debian12-vm"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"vm"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"distro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"debian-12"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"kernel_family"</span><span class="p">:</span><span class="w"> </span><span class="s2">"distro"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"replays"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Each node has a mode (container or VM), a distribution, a kernel family, and a replay count. Container nodes share the host kernel but differ in userspace (glibc vs musl, different library versions). VM nodes run their own kernels.</p>

<p>The distinction matters: container-mode tests prove that userspace differences don’t affect output. VM-mode tests prove that kernel differences don’t affect output. Together, they cover the two main sources of environmental variation on Linux.</p>

<p>A typical x86_64 matrix includes 12 nodes: 6 container lanes (Debian 12, Ubuntu 22.04, Fedora 40, Rocky 9, Alpine 3.20, openSUSE) and 6 VM lanes (Debian, Fedora, Rocky, Ubuntu with GA kernel, Ubuntu with HWE kernel, and a legacy LTS kernel). Each runs 5 replays, for a total of 60 independent executions.</p>

<h3 id="environment-pinning-and-reproducibility-boundaries">Environment Pinning and Reproducibility Boundaries</h3>

<p>Matrix declarations define <em>which lanes</em> must run. Reproducibility additionally depends on how those lanes are provisioned over time. If a lane references a moving base image tag, the lane identity remains the same while underlying bits may drift.</p>

<p>For peer-review-grade replay reproducibility, pin lane substrates as immutable artifacts (for example: container image digests and checksummed VM base images/snapshots) and record those identifiers alongside evidence. Without that, evidence still proves parity for the observed run, but re-running months later may exercise different substrate bits.</p>

<p>A profile defines the policy for what constitutes a valid evidence bundle:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"maximal-offline-linux-x86_64"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"required_suites"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"canonical-byte-stability"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"verify-parity"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"failure-class-parity"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"bounds-limit-parity"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"binary-identity"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"env-independence"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"evidence-completeness"</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"min_cold_replays"</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span><span class="w">
  </span><span class="nl">"hard_release_gate"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"evidence_required"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"v1"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The profile enforces that every required node runs at least 5 times, that the evidence includes all required test suites, and that the evidence is a hard release gate (not advisory).</p>

<h2 id="evidence-capture-what-to-record">Evidence Capture: What to Record</h2>

<p>Each worker node runs the tool against every test vector and accumulates four independent digest streams:</p>

<ol>
  <li><strong>Canonical digest</strong>: SHA-256 of all canonical output bytes, concatenated with vector IDs</li>
  <li><strong>Verify digest</strong>: SHA-256 of verify mode results (exit code, stdout, stderr per vector)</li>
  <li><strong>Failure class digest</strong>: SHA-256 of failure class tokens (“OK” or the class name) per vector</li>
  <li><strong>Exit code digest</strong>: SHA-256 of numeric exit codes per vector</li>
</ol>

<p>These four streams capture different properties. The canonical digest proves byte-identical output. The verify digest proves that the verify command agrees. The failure class digest proves that error classification is stable. The exit code digest proves that the process-level interface is stable.</p>

<p>The digest accumulation works by concatenating structured records with delimiters, then computing a single SHA-256 over the entire stream. Each record includes the vector ID and the relevant output, separated by a unit separator (0x1F) and terminated by a newline. This produces a deterministic input to SHA-256 regardless of record ordering (vectors are processed in sorted order) or platform-specific line ending behavior.</p>

<p>Separating the four digest streams matters for diagnostics. If the canonical digest matches but the failure class digest doesn’t, the tool is producing the same output but classifying errors differently, which could indicate a failure taxonomy change that wasn’t intentional. If the exit code digest matches but the verify digest doesn’t, the tool exits correctly but produces different stderr text, which is acceptable if the stderr change is non-stable, but should be investigated.</p>

<h3 id="source-binding">Source Binding</h3>

<p>The evidence bundle records the exact source state:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"source_git_commit"</span><span class="p">:</span><span class="w"> </span><span class="s2">"da4a4ee6fcefc4f43777c76e5235d824d249807c"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"source_git_tag"</span><span class="p">:</span><span class="w"> </span><span class="s2">"v0.2.1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"control_binary_sha256"</span><span class="p">:</span><span class="w"> </span><span class="s2">"e0296e034d1440a4aad2a3620e5663d749c2007f..."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The git commit SHA pins the source code. The binary SHA-256 pins the compiled artifact. The git tag identifies the release. Together, these create an audit trail from evidence back to source code, with no ambiguity about which code produced the evidence.</p>

<h2 id="validation-logic-detecting-drift">Validation Logic: Detecting Drift</h2>

<p>The validation function implements a simple invariant: all replays must produce identical digests.</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">ValidateEvidenceBundle</span><span class="p">(</span><span class="n">e</span> <span class="o">*</span><span class="n">EvidenceBundle</span><span class="p">,</span> <span class="n">m</span> <span class="o">*</span><span class="n">Matrix</span><span class="p">,</span> <span class="n">p</span> <span class="o">*</span><span class="n">Profile</span><span class="p">,</span>
    <span class="n">opts</span> <span class="n">EvidenceValidationOptions</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>

    <span class="c">// ... schema version, profile match, SHA-256 format checks ...</span>

    <span class="k">var</span> <span class="n">baseline</span> <span class="o">*</span><span class="n">NodeRunEvidence</span>
    <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">id</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">requiredNodes</span> <span class="p">{</span>
        <span class="n">runs</span> <span class="o">:=</span> <span class="n">byNode</span><span class="p">[</span><span class="n">id</span><span class="p">]</span>
        <span class="n">wantReplays</span> <span class="o">:=</span> <span class="n">requiredReplayCount</span><span class="p">(</span><span class="n">matrixByID</span><span class="p">[</span><span class="n">id</span><span class="p">],</span> <span class="n">p</span><span class="p">)</span>
        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">runs</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">wantReplays</span> <span class="p">{</span>
            <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"node %s has %d replays, want at least %d"</span><span class="p">,</span>
                <span class="n">id</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="n">runs</span><span class="p">),</span> <span class="n">wantReplays</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">run</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">runs</span> <span class="p">{</span>
            <span class="k">if</span> <span class="n">baseline</span> <span class="o">==</span> <span class="no">nil</span> <span class="p">{</span>
                <span class="n">r</span> <span class="o">:=</span> <span class="n">run</span>
                <span class="n">baseline</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">r</span>
                <span class="k">continue</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="n">run</span><span class="o">.</span><span class="n">CanonicalSHA256</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">CanonicalSHA256</span> <span class="p">{</span>
                <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"canonical digest drift at node %s replay %d"</span><span class="p">,</span>
                    <span class="n">run</span><span class="o">.</span><span class="n">NodeID</span><span class="p">,</span> <span class="n">run</span><span class="o">.</span><span class="n">ReplayIndex</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="n">run</span><span class="o">.</span><span class="n">VerifySHA256</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">VerifySHA256</span> <span class="p">{</span>
                <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"verify digest drift at node %s replay %d"</span><span class="p">,</span>
                    <span class="n">run</span><span class="o">.</span><span class="n">NodeID</span><span class="p">,</span> <span class="n">run</span><span class="o">.</span><span class="n">ReplayIndex</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="n">run</span><span class="o">.</span><span class="n">FailureClassSHA256</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">FailureClassSHA256</span> <span class="p">{</span>
                <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"failure-class digest drift at node %s replay %d"</span><span class="p">,</span>
                    <span class="n">run</span><span class="o">.</span><span class="n">NodeID</span><span class="p">,</span> <span class="n">run</span><span class="o">.</span><span class="n">ReplayIndex</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="n">run</span><span class="o">.</span><span class="n">ExitCodeSHA256</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">ExitCodeSHA256</span> <span class="p">{</span>
                <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"exit-code digest drift at node %s replay %d"</span><span class="p">,</span>
                    <span class="n">run</span><span class="o">.</span><span class="n">NodeID</span><span class="p">,</span> <span class="n">run</span><span class="o">.</span><span class="n">ReplayIndex</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="c">// Aggregate digests must match baseline</span>
    <span class="k">if</span> <span class="n">e</span><span class="o">.</span><span class="n">AggregateCanonical</span> <span class="o">!=</span> <span class="n">baseline</span><span class="o">.</span><span class="n">CanonicalSHA256</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"aggregate canonical digest mismatch"</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="c">// ... verify, failure-class, exit-code aggregates ...</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The first replay becomes the baseline. Every subsequent replay (across all nodes, all distributions, all execution modes) is compared against this baseline. Any divergence is an immediate failure with a message identifying the exact node and replay index where drift was detected.</p>

<p>The aggregate digests provide a summary check: four SHA-256 values that represent the behavior of the entire run. If the aggregates match the baseline’s per-node digests, all nodes agreed.</p>

<h3 id="what-the-validation-checks">What the Validation Checks</h3>

<p>In addition to digest parity, the validation function enforces:</p>

<ul>
  <li><strong>Schema version</strong>: Must be <code class="language-plaintext highlighter-rouge">evidence.v1</code> (enables future schema evolution)</li>
  <li><strong>Profile match</strong>: Evidence profile name must match the policy profile</li>
  <li><strong>SHA-256 format</strong>: All digest fields must be exactly 64 hex characters</li>
  <li><strong>Git commit format</strong>: Must be exactly 40 hex characters</li>
  <li><strong>Architecture match</strong>: Evidence architecture must match the matrix</li>
  <li><strong>Artifact binding</strong>: Bundle, binary, matrix, and profile SHA-256s must match the actual files</li>
  <li><strong>Replay coverage</strong>: Every required node must have at least the minimum replay count</li>
  <li><strong>Replay contiguity</strong>: Replay indices must be 1, 2, 3, …, N (no gaps)</li>
  <li><strong>Pass status</strong>: Every replay must be marked <code class="language-plaintext highlighter-rouge">passed: true</code></li>
  <li><strong>Suite coverage</strong>: Required test suites must match the profile exactly</li>
</ul>

<h2 id="release-gating-the-go-test-that-says-no">Release Gating: The Go Test That Says No</h2>

<p>The release gate is a standard Go test function. It loads the evidence bundle, the matrix, and the profile, then calls the validation function with expected SHA-256 values computed fresh from the actual artifacts:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">func</span> <span class="n">TestOfflineReplayEvidenceReleaseGate</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">evidencePath</span> <span class="o">:=</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_EVIDENCE"</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">evidencePath</span> <span class="o">==</span> <span class="s">""</span> <span class="p">{</span>
        <span class="n">t</span><span class="o">.</span><span class="n">Skip</span><span class="p">(</span><span class="s">"set JCS_OFFLINE_EVIDENCE to validate offline evidence bundle"</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="n">bundlePath</span> <span class="o">:=</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_BUNDLE"</span><span class="p">)</span>
    <span class="n">controlBinaryPath</span> <span class="o">:=</span> <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_CONTROL_BINARY"</span><span class="p">)</span>
    <span class="c">// ... load matrix, profile, evidence ...</span>

    <span class="k">if</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">replay</span><span class="o">.</span><span class="n">ValidateEvidenceBundle</span><span class="p">(</span><span class="n">evidence</span><span class="p">,</span> <span class="n">matrix</span><span class="p">,</span> <span class="n">profile</span><span class="p">,</span>
        <span class="n">replay</span><span class="o">.</span><span class="n">EvidenceValidationOptions</span><span class="p">{</span>
            <span class="n">ExpectedBundleSHA256</span><span class="o">:</span>        <span class="n">mustFileSHA256</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">bundlePath</span><span class="p">),</span>
            <span class="n">ExpectedControlBinarySHA256</span><span class="o">:</span> <span class="n">mustFileSHA256</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">controlBinaryPath</span><span class="p">),</span>
            <span class="n">ExpectedMatrixSHA256</span><span class="o">:</span>        <span class="n">mustFileSHA256</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">matrixPath</span><span class="p">),</span>
            <span class="n">ExpectedProfileSHA256</span><span class="o">:</span>       <span class="n">mustFileSHA256</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">profilePath</span><span class="p">),</span>
            <span class="n">ExpectedArchitecture</span><span class="o">:</span>        <span class="n">matrix</span><span class="o">.</span><span class="n">Architecture</span><span class="p">,</span>
            <span class="n">ExpectedSourceGitCommit</span><span class="o">:</span>     <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_EXPECTED_GIT_COMMIT"</span><span class="p">),</span>
            <span class="n">ExpectedSourceGitTag</span><span class="o">:</span>        <span class="n">os</span><span class="o">.</span><span class="n">Getenv</span><span class="p">(</span><span class="s">"JCS_OFFLINE_EXPECTED_GIT_TAG"</span><span class="p">),</span>
        <span class="p">});</span> <span class="n">err</span> <span class="o">!=</span> <span class="no">nil</span> <span class="p">{</span>
        <span class="n">t</span><span class="o">.</span><span class="n">Fatalf</span><span class="p">(</span><span class="s">"offline evidence gate failed: %v"</span><span class="p">,</span> <span class="n">err</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The test is gated by an environment variable. In normal development, it’s skipped. During release, CI sets the variable and the test becomes a hard gate. The SHA-256 values are computed fresh from the files on disk; they’re not hardcoded. This means the test validates that the evidence bundle references the <em>actual</em> artifacts being released, not some previously valid set.</p>

<h3 id="environment-variable-binding">Environment Variable Binding</h3>

<p>The release gate uses seven environment variables:</p>

<table>
  <thead>
    <tr>
      <th>Variable</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_EVIDENCE</code></td>
      <td>Path to the evidence JSON file</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_BUNDLE</code></td>
      <td>Path to the tar bundle</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_CONTROL_BINARY</code></td>
      <td>Path to the release binary</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_MATRIX</code></td>
      <td>Path to the matrix JSON file</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_PROFILE</code></td>
      <td>Path to the profile JSON file</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_EXPECTED_GIT_COMMIT</code></td>
      <td>Expected source commit SHA</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JCS_OFFLINE_EXPECTED_GIT_TAG</code></td>
      <td>Expected release tag</td>
    </tr>
  </tbody>
</table>

<p>Most have sensible defaults: matrix and profile default to the repository’s standard files; bundle and control binary paths are derived from the evidence path’s directory. The evidence path has no default. It must be explicitly provided, which prevents accidental release without evidence.</p>

<p>The design as a <code class="language-plaintext highlighter-rouge">go test</code> function (rather than a standalone script) is intentional. It integrates with Go’s standard testing infrastructure: <code class="language-plaintext highlighter-rouge">go test -v</code> shows progress, <code class="language-plaintext highlighter-rouge">-run</code> selects specific gates, <code class="language-plaintext highlighter-rouge">-count=1</code> disables caching. The test is part of the same codebase as the tool it validates, which means the validation logic is versioned alongside the evidence schema. And because it’s a Go test, it can import the same <code class="language-plaintext highlighter-rouge">replay</code> package that generates the evidence, ensuring the validation code and generation code share type definitions.</p>

<p>The <code class="language-plaintext highlighter-rouge">t.Skip</code> pattern (skip when the environment variable is absent, fail when it’s present but the evidence is invalid) means the gate is silent during normal development and enforced during release. Developers running <code class="language-plaintext highlighter-rouge">go test ./...</code> don’t see the offline gate. The release pipeline, which sets the environment variables, does.</p>

<h2 id="schema-versioning">Schema Versioning</h2>

<p>The evidence schema is versioned independently from the tool version. The current schema is <code class="language-plaintext highlighter-rouge">evidence.v1</code>. The validation function checks the schema version as its first action:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">e</span><span class="o">.</span><span class="n">SchemaVersion</span> <span class="o">!=</span> <span class="n">EvidenceSchemaVersion</span> <span class="p">{</span>
    <span class="k">return</span> <span class="n">fmt</span><span class="o">.</span><span class="n">Errorf</span><span class="p">(</span><span class="s">"unsupported schema_version %q"</span><span class="p">,</span> <span class="n">e</span><span class="o">.</span><span class="n">SchemaVersion</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This enables schema evolution without invalidating existing evidence. A future <code class="language-plaintext highlighter-rouge">evidence.v2</code> could add new fields (node CPU architecture, memory constraints, filesystem type) without breaking the validation of v1 evidence bundles. The validation function would branch on the schema version and apply the appropriate checks for each.</p>

<p>The schema is also defined as a JSON Schema file (<code class="language-plaintext highlighter-rouge">offline/schema/evidence.v1.json</code>), enabling validation by external tools. Any system that consumes evidence bundles can validate them against the schema independently of the Go validation code.</p>

<h2 id="cross-architecture-parity">Cross-Architecture Parity</h2>

<p>The same harness runs independently for x86_64 and arm64, each with its own matrix, profile, and evidence bundle. The CI pipeline runs both and validates both independently.</p>

<p>Cross-architecture parity is not <em>required</em> to match; the aggregate digests between x86_64 and arm64 are compared separately. This is a deliberate design choice. Go’s runtime, floating-point behavior, and standard library may produce different intermediate results on different architectures. What matters is that each architecture is <em>internally</em> consistent: all x86_64 nodes agree with each other, and all arm64 nodes agree with each other.</p>

<p>If cross-architecture aggregate digests <em>do</em> match (which they do in practice for this tool, since the implementation uses pure integer arithmetic and explicit formatting), that’s additional confidence. But the harness doesn’t make it a requirement, because mandating it would create false failures if a future Go release changed architecture-specific behavior in a standard library function.</p>

<h2 id="what-running-5-times-per-node-proves">What Running 5+ Times Per Node Proves</h2>

<p>The replay count (5 per node in the maximal profile) is not arbitrary. A single run proves the tool works. Multiple runs prove it works <em>deterministically</em>.</p>

<p>Non-determinism in software can come from several sources: uninitialized memory, map iteration order, concurrent goroutine scheduling, time-dependent behavior, or environment-sensitive code paths. A single run may happen to hit the “correct” ordering every time. Multiple cold runs (starting from a fresh process each time, with no warm caches) increase the probability of surfacing non-deterministic behavior.</p>

<p>Five cold replays is a pragmatic balance between coverage and execution time. Each replay runs the full vector suite from a fresh process invocation, exercising the tool’s startup path, parser initialization, and output formatting from scratch. If any of these paths contain non-deterministic behavior, five independent executions have a reasonable chance of producing divergent digests.</p>

<h2 id="why-this-matters-evidence-as-a-first-class-artifact">Why This Matters: Evidence as a First-Class Artifact</h2>

<p>Most release processes treat testing as a gate: tests pass, the release ships. The evidence is a test log: ephemeral, human-consumed, not structured for machine validation.</p>

<p>Evidence-based release engineering treats evidence as a <em>first-class artifact</em>: versioned, checksummed, machine-readable, and committed to the repository alongside the code it validates. The evidence for v0.2.1 is available at the same commit as the v0.2.1 source code. Anyone can re-validate the release gate by running a single <code class="language-plaintext highlighter-rouge">go test</code> command with the evidence path.</p>

<p>This approach has three practical benefits:</p>

<ol>
  <li>
    <p><strong>Auditability</strong>: The evidence bundle is a complete record of what was tested, on what environments, at what time, from what source. There’s no ambiguity about whether the tests actually ran or what they covered.</p>
  </li>
  <li>
    <p><strong>Reproducibility</strong>: The bundle contains everything needed to reproduce the test: the binary, the vectors, the matrix. Re-running the harness with the same bundle should produce the same evidence (modulo wall-clock timestamps).</p>
  </li>
  <li>
    <p><strong>Trust</strong>: The SHA-256 chain from evidence to bundle to binary to source code means each layer’s integrity is independently verifiable. Tampering with any component breaks the chain.</p>
  </li>
</ol>

<p>The cost is real: maintaining the harness, running replays across multiple environments, committing evidence bundles to the repository. The evidence bundle for a single architecture is approximately 1,000 lines of JSON. The bundle archive contains the test binary, worker binary, all vector files, the matrix, and the profile. Running the full matrix takes minutes, not seconds.</p>

<p>For most projects, this is overkill. A well-written test suite with good coverage provides sufficient confidence for application software. But “sufficient confidence” and “proof” are different claims. When your README says “byte-deterministic output,” the evidence bundle makes that claim auditable. Anyone can examine the evidence, verify the SHA-256 chain, and confirm that 60 independent executions across 12 environments produced identical output.</p>

<p>For infrastructure that downstream systems depend on for correctness (not just convenience), this is the minimum required to make the claim credible.</p>

<p>The implementation described here is from <a href="https://github.com/lattice-substrate/json-canon">json-canon</a>, an RFC 8785 JSON canonicalization library written in Go.</p>

<h2 id="revision-history">Revision History</h2>

<table>
  <thead>
    <tr>
      <th>Date</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2026-03-04</td>
      <td>Corrected configuration snippets from YAML to JSON (matching actual file format); fixed matrix <code class="language-plaintext highlighter-rouge">kernel_family</code> value; expanded defaults description to cover bundle and control binary derived paths</td>
    </tr>
    <tr>
      <td>2026-03-03</td>
      <td>Reviewed against documentation restructure; no substantive changes required</td>
    </tr>
    <tr>
      <td>2026-02-24</td>
      <td>Initial publication</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Mark Lenhardt</name></author><category term="go" /><category term="devops" /><category term="testing" /><summary type="html"><![CDATA[Unit tests don't prove determinism. An offline replay harness that runs canonical operations across distributions and architectures, captures cryptographic evidence, and gates releases on byte-identical output.]]></summary></entry></feed>