<!-- SPDX-License-Identifier: CC-BY-4.0 -- see /LICENSE-PROTOCOL. The Compliance Test Suite is MIT. -->

# Sigil Protocol — v1.1

**Status:** amendment to [v1.0](../v1.0/SPEC.md).

**Revision:** `v1.1-rev25` — bumped by every §0 row. A conformance claim MUST name
the revision it was tested against; "conformant to v1.1" alone is not a claim
this document recognises. `v1.1` names a canonical form; a revision names the
text that describes it, and between rev1 and rev3 the same phrase meant two
incompatible things about arrays (§0, §6.4.7).

This document is normative for the sections it defines and incorporates all of
v1.0 by reference. Where the two disagree, v1.1 governs for v1.1 producers; a
v1.0 producer remains conformant to v1.0. Nothing in v1.1 changes how an
already-sealed v1.0 artifact verifies — that is the whole point of versioning a
canonical form rather than editing one.

v1.1 adds two things:

- **§6.3** — the **v2 per-load canonical form**, which supersedes §6.1 for newly
  sealed load chains.
- **§10** — **external chain-head anchoring**, the operator-independent
  commitment that turns "the chain is self-consistent" into "the chain existed
  at a time a third party attests to".

## 0. Errata

Changes made to this document AFTER v1.1 was published and after
`sigil-protocol-verifier` 1.1.x shipped to npm and PyPI. **No erratum alters how
any already-sealed artifact verifies; an implementation that was byte-correct
before remains byte-correct.** That is the invariant this section protects, and
it is stronger than "everything here is a clarification" — which is what this
preamble used to claim, and which stopped being true on 2026-07-26.

Entries come in four kinds, and the **Kind** column says which, because filing a
behaviour change as a clarification is the same defect as an unearned claim
anywhere else in this document:

- **Clarification** — behaviour that was already implemented and already
  enforced, stated in prose for the first time. Nothing an implementation does
  changes.
- **Prose correction** — a non-normative claim in this document was **wrong**.
  The normative rule is unchanged; the explanation of it was false and is now
  right. An implementer who "fixed" their code to match the wrong prose was made
  wrong BY this document, which is why these are called out rather than quietly
  edited.
- **Domain restriction** — an input that previously produced a root is now
  **REFUSED**. This is a **behaviour change**, said plainly. It is not a change
  to a canonical form and therefore does not require a new version, on a test
  that has to be met rather than asserted: **no input that had a defined
  canonical form gets a different one.** Each such entry restricts the input
  domain to values that HAD a defined form in the first place — a string with no
  UTF-8 encoding never had one, and a nesting depth no two conforming
  implementations could both walk never had one either. Every payload that
  encoded before still encodes, to the same bytes.
- **Normative correction** — a normative rule in this document did **not**
  describe **any** implementation's behaviour, and is replaced with the rule
  every published implementation follows. This is distinct from a prose
  correction (which touches no normative rule) and from a domain restriction
  (which narrows accepted input): here the rule itself was wrong, and following
  it produced an artifact no conforming verifier accepts. It carries a test that
  MUST be met rather than asserted: **every already-sealed artifact verifies
  identically before and after**, because no implementation ever followed the
  replaced rule. Where the published implementations disagreed with **each
  other**, the weaker guarantee applies — no input that had a _single_ defined
  form gets a different one — and the disagreement itself is filed under
  [`GOVERNANCE.md`](../GOVERNANCE.md) rather than settled by picking a side
  (§11.0).

An erratum that changed a canonical form would invalidate sealed evidence, which
is the one thing this protocol exists to prevent; that still gets a new version
number instead. The two domain restrictions below were also made **before** any
verifier implementing §6.4 was published (see §1). Their practical blast radius
is also nil: production payloads are ids, currency codes and integers, none of
which can carry an unpaired surrogate, and the rate-con shape the compliance
fixtures record is flat. The classification stands on its own regardless of
that, which is why it is stated first.

| Date       | Section     | Kind                 | What changed                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| ---------- | ----------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-07-26 | §6.3.4      | Normative correction | **`payloadHash` MUST equal `payloadRoot` on a v2 entry.** Enforced by the verifier since v2 shipped; stated in no published document. A producer following v1.0 §3.1 literally had its chains reported BROKEN. **Re-classified from Clarification on 2026-07-27 — see the note below.**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 2026-07-26 | §6.4        | Clarification        | **The canonical form of `payloadRoot` is now specified.** Previously one clause ("a structured per-field Merkle root") described 480 lines of behaviour, so the protocol could be verified against but not produced.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| 2026-07-26 | §6.4.4      | Clarification        | **The NFC boundary is labelled.** v1.0 §4 mandates NFC and the CTS `nfc-strings` fixture teaches it; §6.4 commits strings verbatim and must. Nothing previously said the fixture's scope was the canonical form only.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 2026-07-26 | §6.4.1      | Domain restriction   | **A string with no UTF-8 encoding MUST be refused.** An unpaired surrogate has no UTF-8 bytes, so it never had a canonical form; one reference substituted U+FFFD and produced a root, the other raised. Substitution collided two payloads onto one root, tied §6.4.7's total order, and proved values that were never sealed.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| 2026-07-26 | §6.4.6      | Domain restriction   | **`MAX_PAYLOAD_DEPTH` is 8192's missing half, and is 64.** A payload nested past it MUST be refused. Unbounded nesting sat in a window where one reference sealed and the other hit its recursion limit, so no single canonical form existed there to preserve.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| 2026-07-26 | §6.4.7      | Prose correction     | **Trap 3 was false.** It said array indices "sort as text, not as numbers" and that `i10` sorts before `i2`. The normative sort is over the length-prefixed encoded path, where `2:i2` precedes `3:i10`, so fold order IS numeric for every array this form admits. An implementer who followed the warning broke every array.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 2026-07-26 | §6.4.9      | Clarification        | **A malformed proof step returns `false`, and `siblingIsRight` MUST be a JSON boolean.** The wire schema already said `boolean`; nothing said it was enforced, and one reference coerced by truthiness while the other raised, so the same bytes got opposite verdicts.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 2026-07-26 | §6.4.9.1    | Clarification        | **A wire number step MUST be a non-negative integral binary64 within the safe-integer range.** Both implementations already refused everything else; the text authorized nothing, at the one place it says a path crosses a trust boundary.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| 2026-07-26 | §6.4.10     | Prose correction     | **The `sigil:num:` string is producer-side and is NOT cross-language reproducible.** The paragraph implied a byte guarantee it never delivered: only the `e+` → `e` reduction was ever specified, and `3e-05` / `3e-5` / `0.00003` are all defensible renderings of one value.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 2026-07-27 | §11.0       | Normative correction | **v1.0 Appendix A's tiebreak is narrowed to ambiguity only, and is void where the two references disagree with each other.** The rule described no implementation's behaviour: neither published verifier consults the other as ground truth, and §4 key order — where the two land on opposite sides — proves it could not be followed. §11.0.1 registers the divergences clause 1 exposes.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| 2026-07-27 | §6.3.4.1    | Clarification        | **The served entry document is specified.** The three commitments are carried NESTED under `commitments`; §6.3.4's flat list is hashing order, not wire shape. `payloadHash` MAY be absent. Nothing here is hashed.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| 2026-07-27 | §6.3.7.2    | Normative correction | **Partial disclosure is valid.** The three openings are independent and any subset MAY be disclosed; an absent opening is UNDISCLOSED, not failed. The previous all-or-nothing reading described no usable behaviour — the Python reference raised `KeyError` and the TypeScript reference threw on a property of `undefined`, so the feature the separate commitments exist to provide was unreachable through either published verifier. A verifier MUST now report which openings it checked.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| 2026-07-27 | §6.3.2.1    | Clarification        | **The actor value is defined.** It commits the acting principal's identifier as an unmodified string in the producer's own namespace; absent or system actors commit the empty string. Two of the three commitments previously had undefined inputs, so a v2 entry could not be produced from published text.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| 2026-07-27 | §6.3.2.2    | Clarification        | **The attested value is defined**, with its known limit named: structured attestations have no specified canonical form, so the commitment is checkable while its binding to the underlying attestation is not.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| 2026-07-27 | §6.3.2      | Domain restriction   | **A salt MUST carry at least 128 bits of entropy.** A literally conformant 2-byte salt is brute-forceable over exactly the low-entropy actor and geo spaces the hiding property protects. Producer obligation; not checkable from a sealed artifact.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| 2026-07-27 | §6.3.8      | Clarification        | **The served bundle envelope is specified.** Only `entries` is required; omitted and explicit-null members are the same fact; unknown members MUST be ignored.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 2026-07-27 | §6.3.1      | Clarification        | **`version` and `chainIndex` render in INTEGER FORM inside the preimage.** Already enforced by the CTS and stated only in `compliance-suite/README.md`, which is not normative and does not ship with the spec. Merged only after a differential run of the full corpus in both languages showed no digest moved.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| 2026-07-27 | §11.1       | Normative correction | **Object keys sort by UTF-8 BYTE order**, a third clarification overriding JCS's UTF-16 order. v1.0 §4 said byte order and then named JCS, which says otherwise; the rules invert above the BMP and the TypeScript reference followed JCS.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| 2026-07-27 | §11.2       | Normative correction | **§4 numbers render under §6.4.2.3 within the §6.4.2.4 band.** §4's "no exponent / no trailing `.0`" described neither published verifier: both emit `1e+21`, and they disagreed on integral floats. The "edge cases only" hedge is deleted.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| 2026-07-27 | §11.3       | Domain restriction   | **NFC applies to object KEYS, before ordering, and a normalization collision MUST be refused.** Neither reference normalized keys, so two spellings of one key produced different digests where §4 says they are one payload.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| 2026-07-27 | §11.4       | Normative correction | **The v1 per-load entry form is stated as it actually is.** v1.0 §6.1 said `geo` lives in `payload` and is hashed via `payloadHash`; no implementation ever did that. The sealer folds the four geo fields into the entry preimage and also commits `actorUserId`, which no section defined. Cannot be fixed in code — it is the preimage of every sealed v1 entry.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| 2026-07-27 | §11.5       | Clarification        | **A v1 entry carries no in-band version marker.** The form is selected out of band, and a verifier MUST NOT try both forms and accept whichever matches — that is a downgrade oracle. v2 fixed this by construction with `LP(version)`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 2026-07-27 | §10.2       | Clarification        | **A single-leaf checkpoint's root IS that leaf**, and its empty inclusion proof is not malformed. Read literally the old text made a lone leaf the odd node out, giving `node(leaf,leaf)` — so two conforming producers published different roots for the same single-head checkpoint. Leaves order by UTF-8 bytes (§11.1).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| 2026-07-27 | §10.5       | Normative correction | **Condition (4): the root MUST be obtained independently of the party serving the evidence**, reported as `rootCorroborated`. The three previous conditions are jointly satisfiable by an operator who rewrites a chain and mints a fresh root — the exact attack §10.0 exists to defeat. Both references already implemented this; no published clause did.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| 2026-07-27 | §10.6       | Clarification        | **The RFC-3161 message imprint is specified.** SHA-256, and `hashedMessage` is the 32 RAW bytes obtained by hex-decoding the published root — NOT `SHA256` of the served hex string. The single load-bearing byte of the external-trust claim, previously in one line of unpublished service code.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 2026-07-27 | §10.7       | Clarification        | **The checkpoint publication channel is specified** — object shapes for the checkpoint and inclusion proof, unauthenticated retrieval, newest-first list plus by-id read. §10.5's "published root" carried the whole guarantee while no document said where publication happened.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| 2026-07-27 | §11.6       | Normative correction | **v1.0 §5's PAdES-B-LTA claim is corrected to B-B, and B-T is now produced.** The sealer never produced B-LTA — no DSS, no validation data, no document timestamp — and §5.1's `id-aa-timeStampToken` attachment was never done. A signature timestamp is now attached, imprinted over the SIGNATURE VALUE. `B-LT`/`B-LTA` remain unclaimed: B-LTA is a standing operational obligation, not a sealing feature.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| 2026-07-27 | §11.7       | Clarification        | **v1.0 §8's `role` and `status` are now emitted**, and `role` is defined as the STRUCTURAL role (`SIGNER`/`VIEWER`/`CC`), never a free-text display label — the latter is operator-defined, so it is neither interoperable nor safe to publish. `status` distinguishes DECLINED from not-yet-signed, which a null `completedAt` alone cannot.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| 2026-07-27 | §6.3.4      | Domain restriction   | **`occurredAt` MUST render as RFC 3339 UTC with exactly three fractional-second digits**, and a verifier MUST hash the served string verbatim without reformatting. This is the one preimage field a `timestamptz` round-trip silently changes. The restriction binds PRODUCERS only: a six-digit form already served still verifies as served, because restricting the verifier would invalidate sealed evidence.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 2026-07-27 | §6.3.4.2    | Clarification        | **Field domains are collected, and the genesis `prevHash` is stated where a load-chain implementer reads.** It was defined only in v1.0 §3.2 — the ORG chain, in the other document — so §6.3.6's "the genesis value" named nothing recoverable except by copying a literal out of an example. `kind` and `loadId` are stated to be OPAQUE strings with no enumeration and no format rule, which was true and unwritten.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| 2026-07-27 | §6.3.8      | Clarification        | **The served bundle carries no payload, so `payloadRoot` is bound to no content.** It is checked as a term of the entry preimage and against `payloadHash`, and that is all; the envelope has no member for a §6.4.9 field proof, so the root-to-content binding is not reachable through the bundle path. Named rather than left to be discovered as a missing check.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| 2026-07-27 | §6.3.9      | Clarification        | **The chain verification REPORT is specified.** Three rules already made normative statements about its contents (`loadIdCorroborated`, `anchor.rootCorroborated`, the `status` values) while no section said what the object was. `null` means absent and never failed; both corroboration flags are required, and `false` is the honest default.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 2026-07-27 | §10.1       | Domain restriction   | **The anchor leaf's `index` MUST be an integral binary64 within the safe-integer range, and renders in INTEGER FORM.** The two references disagreed: `String(1e21)` gave `1e+21` and Python gave `1000000000000000000000`, both produced a leaf, and **the leaves differed** — two roots for one checkpoint. Refused now, in both. No sealed artifact is in the affected class (largest production index: 601).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| 2026-07-27 | §6.3.2      | Clarification        | **A salt's ENCODING is producer-defined**, because the same string travels in the opening and no two implementations ever have to agree on it. The 128-bit floor is on the drawn ENTROPY, not the string's length.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 2026-07-27 | §6.3.3      | Prose correction     | **The geo construction's first two terms are spelled `latitude` and `longitude`**, matching every other mention in this document and both references. They were written `lat` and `lon`. Positional construction, so no byte changes — but an implementer had to rule out a second, shorter field.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 2026-07-27 | §6.4.2.5    | Prose correction     | **"Non-normative" is explained**: conformance is defined by the bytes produced, never by which function produced them, and §6.4.11's vectors are the acceptance test. An implementer read the disclaimer as "the only concrete guidance here is unreliable", which is backwards.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| 2026-07-27 | §11.8.2     | Domain restriction   | **The Python reference's legacy `snake_case` aliases are removed.** Its own docstring said "removal in 2.0" and 2.0 shipped with them, so the two published packages emitted different key SETS (7 vs 5) for one verdict. The fixture family added to catch that class could not see it, because `specKeysPresent` filters to the spec list before comparing. Removed in the 3.0.0 release.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| 2026-07-28 | §11.0.1     | Prose correction     | **THIRD correction to the same release-state table, and this one was measured wrong rather than unmeasured.** It said a non-boolean `siblingIsRight` is `BROKEN` in both packages; that was tested on ONE value, `[]`, which is falsy in Python and truthy in JavaScript, and every other non-boolean goes the other way. Measured across four values: npm returns `VERIFIED` for all, and `"false"`, `"true"` and `1` are `VERIFIED` in BOTH — a proof step whose flag spells `"false"` authenticates as a right sibling. The surrogate row was wrong too: PyPI crashes with `UnicodeEncodeError` and no report. Testing one instance and writing a general claim looks measured and is not. Clean-room run 7.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| 2026-07-28 | §11.0.1     | Prose correction     | **The §10.1/§10.3 row named the two published packages the wrong way round.** It said `"proof": {}` was `VERIFIED` in PyPI and `BROKEN` in npm; measured against the installed artifacts, npm exits 2 with no report and PyPI reports `BROKEN`. The row was written from a source diff rather than from the packages it describes — the same defect the register exists to record, committed inside the register. Replaced with measured values for all three shapes. Clean-room run 6, D6.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| 2026-07-28 | §1.1        | Prose correction     | **The install commands now pin `@latest` and `-U`.** A bare `pip install sigil-protocol-verifier` is a no-op where an older version is present and silently leaves 1.4.0 — the release this register records as non-conformant — in place. Clean-room run 3 lost three acceptance criteria to exactly that, and §1.1 was still handing readers the command that causes it. Clean-room run 6, D8.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| 2026-07-28 | §6.3.8      | Clarification        | **A bundle proves a PREFIX of the chain, never the whole chain, and a reader MUST NOT read `VERIFIED` as complete history.** Measured on `bundles/clean.json` cut from two entries to one: all four verifiers report `VERIFIED`, `loadIdCorroborated: true`, `anchor.inclusionValid: true`. The empty chain clean-room run 6 filed is the limit case. This document previously contained one occurrence of "truncate", about payload leaves; it had an honesty box for what a bundle cannot prove about payloads and none for history. Detecting truncation needs an envelope member that does not exist.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| 2026-07-28 | §10.5       | Normative correction | **A supplied root with NO anchor is answered rather than dropped.** The anchor block is emitted with `inclusionValid: null`, `rootCorroborated: false` and a reason; condition (4) then fails in the ordinary way. All four verifiers previously returned `VERIFIED` with `anchor: null` — correct under §6.3.9 point 3, which is why the silence was invisible. `--expect-root` has never shipped, so no existing invocation reaches this path. Clean-room run 6, D5.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| 2026-07-28 | §6.3.8      | Normative correction | **A verifier MUST use the envelope `loadId` as the expected id when no caller id is given, and point 7 is now a list item rather than text glued inside a blockquote.** It described what two references do rather than obliging a third, while `bundles/wrong-load.json` requires `BROKEN` on a chain that is internally valid and fails only against the envelope. A verifier built strictly from the text returned `VERIFIED` and failed the vector. Clean-room run 6, plus run 5 D6 for the formatting half.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| 2026-07-28 | §6.3.9      | Normative correction | **`checked` is the number of positions ACTUALLY EXAMINED and MUST equal `disclosed.length` on every branch, including 0.** Three implementations gave three answers on three openings over two entries — text 3, npm 2.0.0 2, corrected references 0 — with `disclosed` empty in all three. The definition was written for the ordinary path and silently extended to refusal branches. Clean-room run 6.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| 2026-07-27 | §10.3       | Normative correction | **The anchor proof's wire shape is enforced, on the same terms §6.4.9 already stated for the payload proof.** The two proofs are the same shape and only one had a contract, so each reference invented its own: `"proof": {}` was VERIFIED with `inclusionValid: true` in Python and BROKEN in TypeScript, and `siblingIsRight: []` was the reverse. Worse than a divergence, a step with `sibling` ABSENT renders the literal string `"undefined"` and authenticates against a root folded with it — an inclusion proof with a hole. An EMPTY proof stays valid (§10.2). Clean-room run 5 threat model, T3.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| 2026-07-27 | §10.5       | Normative correction | **Condition (2) is not establishable from a served load bundle, and is reported as `headBoundToChain: true \| false \| null` rather than guessed.** Checkpoints fold ORG chain heads; a v2 load chain reaches anchoring only transitively via `LOAD_EVENT_SEALED`; the bundle carries no org chain. Implementing (2) literally against a bundle reports BROKEN on every document Sigil serves — and passed 15/15 on a corpus whose fixtures had been generated the same wrong way. Only `false` may force BROKEN.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| 2026-07-27 | §6.3.9      | Normative correction | **"Corroborated" means COMPARED AND MATCHED, in both flags.** `loadIdCorroborated` was set from whether a caller supplied an id, so a transplant checked against the id the reader actually asked for reported the load id as corroborated, and an empty chain reported `true` for any id having compared nothing. §11.9 had already settled `rootCorroborated` as a real equality, and this section already said the two meant the same thing. Moves a flag without moving a verdict.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| 2026-07-27 | §1.1        | Normative correction | **There are TWO integrity manifests, one per path space, and the section previously described a check that could not pass.** The served tree's `SHA256SUMS` lists files relative to `/protocol/`; the archive's members are repo-relative. Following §1.1 literally against the published rev12 artifacts produced **78 of 79 lines `FAILED open or read`** — the mechanism that proves the corpus was not tampered with reporting near-total failure. The archive now carries its own manifest at its root. Rebuilding the archive to match the served paths was rejected: it relocates `run-suite.mjs` relative to this document, whose absence the suite treats as fatal. Clean-room run 5, D7.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 2026-07-27 | §11.9       | Clarification        | **The out-of-band root is carried by the conformance apparatus, and the corpus now pins BOTH branches.** The adapter contract gained a request `expectedRoot` and a response `anchor.rootCorroborated`, and `bundles/out-of-band-root.json` joins `bundles/self-corroborated-root.json` as a byte-identical pair differing only in whether a root arrived out of band. Until 2026-07-27 the seam forwarded no root and compared no flag, so an implementation with §11.9 exactly backwards passed it cleanly — the rule was published, pinned for the references, and unenforceable against anyone else. No normative rule changes; the revision moves so the corpus archive name keeps identifying unique bytes.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| 2026-07-27 | §11.8.2     | Clarification        | **`documentSha256` is the digest the verifier COMPUTED, never the digest it has on record.** The two agree for every intact artifact and diverge on exactly one verdict — `TAMPERED`, where the bytes in hand no longer hash to what was sealed — so an implementation that fills the field from its own seal record is correct on every input except the one the field exists to expose. Stated because Sigil's API had to choose between the two while closing the row below, and the section did not say.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| 2026-07-27 | §11.8.4     | Normative correction | **The in-band signature timestamp is READ and reported, and a stored one carries `source`.** Neither reference read `id-aa-timeStampToken` at all; Sigil's API served an operator-held document timestamp under the bare name §11.8.4 exists to forbid; and §11.8.4 asserted all three carried `source` while none did. The TSA identity is deliberately not in the required shape — see the section.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 2026-07-27 | §11.9       | Normative correction | **An envelope `expectedRoot` corroborates nothing.** §10.5 condition (4) requires a root obtained INDEPENDENTLY of the serving party; an envelope member arrives with the artifact. `rootCorroborated` is true only for an out-of-band root, envelope agreement sets nothing, and envelope disagreement is BROKEN. All three verifiers previously reported `true` for a bundle that corroborated itself.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| 2026-07-27 | §6.3.9      | Normative correction | **`expectedRoot` is a `VERIFIED` condition.** A caller-supplied root that disagrees with the served anchor root is `BROKEN` — corroboration attempted and failed, not corroboration absent. The corpus required it, both verifiers implemented it, and three normative sections read as though it could not be a failure.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| 2026-07-27 | §6.3.9      | Clarification        | **`checked` and `disclosed` are indexed by SUPPLIED OPENING, not by entry**, and `checked` counts positions examined including null holes. Defined as observed rather than redefined; a reader who needs "how many actually verified" counts non-empty `disclosed` entries.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| 2026-07-27 | §11.8.5     | Clarification        | **"§5.2 step 2 hash mismatch" is defined.** The comparison is the reconstructed ByteRange digest against the `messageDigest` signed attribute; v1.0 §8's phrasing was circular. An undecodable `/Contents` is rule 5, not rule 1.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| 2026-07-27 | §11.8.6     | Clarification        | **A failed lookup is `recognized: null` plus a visible failure, never `false`.** The double duty `null` now carries is stated as a limitation rather than left to be discovered; a fourth state is a v1.2 shape change.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| 2026-07-27 | §11.8.2     | Clarification        | **The operator-independent fields are TOP-LEVEL**, not inside the optional `document` block. v1.0 §8 put the digest somewhere this section makes optional; the flat name existed only in a corpus fixture.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| 2026-07-27 | §11.8.2     | Normative correction | **`documentSha256` is the SHA-256 of the WHOLE FILE**, is what §11.8.1's lookup is keyed by, is NOT the signed byte-range digest, and MUST be emitted on every path including `UNKNOWN` and `INVALID`. Both references emitted the byte-range digest under that name, so a conforming third-party verifier reported `UNKNOWN` on every genuinely sealed document. The byte-range value moves to `signedByteRangeSha256`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| 2026-07-27 | §11.8.4     | Normative correction | **`timestamp` is OPERATOR-DEPENDENT.** A B-B seal carries no in-band signature timestamp — §11.6 point 3 makes B-B legitimate by construction — while an operator may hold a document timestamp that no reader of the bytes can produce. One name over two artifacts, so a `source` of `IN_BAND` / `OPERATOR_HELD` is required to tell them apart.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 2026-07-28 | §6.3.8      | Clarification        | **Every member of `anchor` and of `anchor.head` is REQUIRED once `anchor` is present, absent equals null, and a missing one is `BROKEN` with a reason naming it.** Point 3 gave the wire shape and no rule for a member of it going missing, so an `anchor` carrying `proof` and `root` but no `head` had no defined outcome. The two in-tree references agree byte for byte across all eleven shapes; the published packages emit no report on most of them, which is the §11.0.1 row this missing rule made possible. Clean-room run 9, D1 and D2.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| 2026-07-28 | §6.3.9      | Normative correction | **An `openings` section supplied but NOT EVALUATED is a fourth report state.** Over a `BROKEN` chain nothing the openings would open is sealed, so a verifier reports `checked: 0`, `disclosed: []`, `valid: false` and a reason. `null` is wrong because the bundle did carry openings; a bare `valid: false` is worse, because it reads as the lying-disclosure accusation §6.3.7.1 calls the most damaging false verdict this protocol can emit. Unlike the other rev20 clarifications this does NOT describe every implementation: npm 2.0.0 reports `valid: true` with a full `disclosed` list over a chain it calls BROKEN, which is already an OPEN §11.0.1 row. Clean-room run 9, D6.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| 2026-07-28 | §6.3.4.1    | Clarification        | **The `payloadHash` / `payloadRoot` equality is over the STRING, so mixed case is `BROKEN`.** All four verifiers already do this and none said so. A reader who implemented the comparison case-insensitively — defensible for two hex digests — passes every corpus vector, because no fixture mixes case. Point 5 makes the fields lowercase while §11.0.1 records the hex domain as deliberately unenforced, so the two rules jointly permit an uppercase field to exist and require it to fail this check. Clean-room run 9, D10.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 2026-07-28 | §6.3.8      | Clarification        | **Unknown members MUST be ignored at EVERY depth**, not only in the envelope: on an entry, inside `commitments`, inside an opening, and inside `anchor.head`. All four verifiers already ignore all four positions. `commitments` is the consequential one, because it is the only place an unknown member sits among preimage terms — so a future member meant to be hashed would be silently dropped, and can therefore only arrive with a `version` bump. Clean-room run 9, D11.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| 2026-07-28 | §6.3.9      | Normative correction | **The exit-code line is drawn at PARSING, settling the point-7 contradiction.** Unreadable or unparseable input is exit `2`; everything the JSON parser accepted gets a verdict and exit `1` with a report naming the fault. Both CLIs pre-checked for an `entries` array and exited `2`, so the CLI contradicted the library and the text, and §11.0.1 carried it as _unresolved on purpose_ through two clean-room runs that each reported it. A **behaviour change to a published exit code**, so it lands with 3.0.0 and not as an erratum to shipped artifacts.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| 2026-07-28 | §10.7       | Clarification        | **Sigil's checkpoint feed is NAMED, at `/anchors` and `/anchors/{id}`, unauthenticated.** Paths are operator-defined and this section said so and stopped, so a clean-room implementer probed three plausible spellings, got the SPA catch-all on all of them, and correctly reported that §10.5 condition (4) — the only operator-independent fact in this protocol — was not reachable by a third party against Sigil from published text. The feed was live the whole time. An operator MUST now publish its own path: a channel whose location is undiscoverable is not a publication channel. Clean-room run 9, item 11.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| 2026-07-28 | §6.3.9      | Normative correction | **`loadIdCorroborated` is `false` when the only comparison was against the envelope's own `loadId`.** §6.3.8 point 7 makes that comparison mandatory when no caller id is supplied, so it genuinely happens and genuinely matches, satisfying "COMPARED AND MATCHED" read literally — while the same clause says the flag means the value came from OUTSIDE the artifact, which it did not. Two rules in this document, opposite answers. Settled on the §11.9 reasoning: an attacker who transplants a chain rewrites the envelope `loadId` in the same edit. Both references and an independent clean-room implementer already do this, so it states what everyone does; the flag moves on no input at all. Clean-room run 9, D7.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| 2026-07-28 | §11.0.1     | Prose correction     | **The anchor row described a three-shape class that is eleven, and the `--expect-root` row named one package where the fact is true of both.** Both are the same error the register has now made three times: writing the general claim from the instances in hand. The eleven shapes are executed by `check:release-state` rather than asserted, and the `--expect-root` correction is filed inside the sentence that corrects it. Clean-room run 9, D1 and D3.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| 2026-07-28 | §6.3.7.1    | Clarification        | **An opening's `value` and `salt` are STRINGS with a UTF-8 encoding, and a malformed opening is `BROKEN` with a reason naming the member.** Point 2 said "each opening is `{ value, salt }`" and stopped, so nothing refused a number, a null, an array or an object in either member — and both reach `LP`, which counts UTF-8 bytes. Measured across eight shapes: **npm 2.0.0 exits 2 with no report and PyPI 2.0.0 exits 1 with no report**, on byte-identical input, so one caller reads "unreadable" where the other reads "tampered evidence". A non-string member never had an `LP` encoding and therefore never had a commitment preimage, so no already-sealed artifact verifies differently. Clean-room run 8, D4.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| 2026-07-28 | §10.5       | Normative correction | **An ENVELOPE `expectedRoot` with no anchor is answered rather than dropped, and stays `VERIFIED`.** The anchor block is emitted with `inclusionValid: null`, `rootCorroborated: false` and a reason; the verdict does not move. Condition (4)'s envelope half is narrowed to the case where an anchor root was actually served — read literally it made an unsubstantiated envelope root a failure, which no implementation has ever done and which every reference had to short-circuit past, and that short-circuit is what swallowed the report. The mirror case (a CALLER root with no anchor) stays `BROKEN`: somebody asked. All four verifiers previously returned `VERIFIED` with `anchor: null`, correct under §6.3.9 point 3, which is why the silence was invisible. Clean-room run 8, D5.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| 2026-07-28 | §6.3.8      | Prose correction     | **Point 4's blockquote no longer teaches the envelope as the channel for an independently-fetched root.** It read "`expectedRoot` exists so a reader who fetched the root independently can say so", pointing a reader at an envelope member to carry an out-of-band fact — the exact self-corroboration §11.9 closed, still being taught two revisions later. Both package READMEs had already been corrected for the same defect; this sentence survived that pass. A reader says so through the verifier's OPTION, never through the document.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| 2026-07-27 | §11.0.1     | Prose correction     | **The register's summary sentence is deleted rather than corrected.** It claimed "as of `v1.1-rev4` … the ten rows above, all of them now closed" while the document was at rev6 with thirteen rows and two OPEN. A summary is a second copy of a table, and this one had already drifted; the tables are now the only statement of count and state.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| 2026-07-27 | §6.3.9      | Normative correction | **A structurally malformed entry is `BROKEN` with a reason naming the field, and a verifier MUST NOT throw.** Both published references crashed on flat commitments, a missing `payloadRoot`, and a non-object opening — and exited with DIFFERENT codes on identical bytes (npm 2, PyPI 1), so one caller read "unreadable" where the other read "tampered evidence". Fixed in both; three corpus vectors now pin it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| 2026-07-27 | §6.3.4.1    | Prose correction     | **Point 1's promise was false.** It said serving the commitments flat "surfaces as a hash mismatch ... rather than a serialization mistake", about the error it names as the most likely first-attempt one. It surfaced as an unhandled exception in both references. The required outcome is now stated and pinned.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| 2026-07-27 | §6.3.3      | Normative correction | **Absent and null are the same fact for all four geo terms**, and an absent `capturedFrom` encodes as `NONE`. The two references DISAGREED: TypeScript threw on an absent `accuracyMeters` where Python committed the same bytes as an explicit null, and Python raised `KeyError` on an absent `capturedFrom`. Aligned in both plus the producer; no digest moved. Also cross-references §6.4.2.2, whose step order is the trap that would refuse `latitude: 0`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| 2026-07-27 | §6.3.8      | Clarification        | **The `anchor` member's shape is given** — `head` is `{orgId, index, headHash}`, not the empty `{}` the sketch printed. §10.1 names those as formula operands and §10.7's inclusion proof is a differently-shaped flat object, so the only place the wire names appeared was inside a fixture.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 2026-07-27 | §6.3.4.1    | Clarification        | **Emitting `payloadHash` is OPTIONAL**; §1.2's producer index row now reads "if emitted" rather than as an obligation to emit.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| 2026-07-27 | §11.8       | Normative correction | **The "public seal record" v1.0 §8 requires is defined, and §8's report is split into an operator-independent half and an operator-dependent one.** The phrase appeared once in the corpus and nowhere else, so §8's `UNKNOWN` MUST was implementable only by the operator — the one section of this protocol that structurally required trusting Sigil. `recognized` becomes `boolean \| null`, the `document` block becomes optional, and `status` precedence is stated: `TAMPERED` outranks the unrecognized case.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 2026-07-28 | §5.2 step 4 | Normative correction | **The "configured trust policy" is anchored on the signing certificate's SHA-256 FINGERPRINT, not on its subject CN, and the default is to trust NOTHING.** v1.0 §5.2 step 4 says a verifier confirms "the signing cert's subject CN matches a configured trust policy" and that "Sigil ships a permissive policy for the reference verifiers". **Matching the CN provides zero security and the permissive default is what made the MUST vacuous.** The production signing certificate is SELF-ISSUED, so there is no chain to validate and an impostor self-issues a certificate carrying the identical CN — which is exactly how a PDF whose subject read `Totally Not Sigil, Inc.` returned `VALID` from all three verifiers. A verifier now takes a list of accepted DER-certificate fingerprints; §11.8.3 gains rules 3 and 6 and the new §11.8.7 states the rules. **A signature nobody has vouched for is `UNKNOWN`, which changes the verdict on every OFFLINE verification of a genuine Sigil document** and the published CLIs' exit code on the bare-path invocation from 0 to 1 — a behaviour change to a published contract, so it lands with 3.0.0 and not as an erratum to shipped artifacts.                                                                                                                                                                                                                                                 |
| 2026-07-28 | §1.1        | Clarification        | **"Corpus revision" is defined, and it is NOT the specification revision.** §1, §1.2, §1.1 and `GOVERNANCE.md` have all required a conformance claim to name it since v1.1 shipped and nothing said what it was; clean-room runs 9 and 10 both asked and neither could answer from published text. It is `corpus-` followed by the SHA-256 over the corpus's own per-file manifest, published at `/protocol/compliance-suite/CORPUS-REVISION`, carried in the archive at the same relative path, and recomputable by anyone holding the corpus. **The obvious answer was disproved the day this was written**: two fixtures were added while the specification still said `rev20`, so the served `rev20` archive changed contents under a revision already published and measured — two corpora, one identifier. A specification revision moves on §0 rows and adding a test vector is not an erratum.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| 2026-07-28 | §11.8.5     | Prose correction     | **INSERTING A RULE INTO §11.8.3 RENUMBERED EVERY RULE BELOW IT, AND TWENTY-FIVE CITATIONS WERE LEFT POINTING AT THE OLD NUMBERS.** The trust-anchor rule went in at position 3 on 2026-07-28, so the lookup moved 3→4 and `INVALID` moved 4→5. §11.8.5 still said an undecodable `/Contents` "is rule 4, `INVALID`", and so did this changelog's own §11.8.5 row — off by one in the clause §11.8.3 calls the one "the whole top half of the precedence table turns on", where a reader who trusts the number implements that case as `UNKNOWN` and fails the corpus's own `signature-byte-flipped` vector. Corrected across the specification, both reference implementations, their tests and the API's tests. **The rule that would have prevented it was already written down**: "CHECK CROSS-REFERENCES BEFORE RENUMBERING" has been in the handoff notes since rev13, together with the observation that nothing in the gate checks them. It still does not, and that is now the oldest unaddressed lesson in this program. **Two citations were wrong BEFORE the renumbering and are correct only by coincidence after it** — §11.8's "rule 4 could not be implemented by anybody" and the impostor box's "rule 4 is the only thing in §8 that was ever supposed to withhold it" both meant the LOOKUP while the lookup was rule 3. They are left as they now read, and recorded here so the accident is not mistaken for care. Clean-room run 11, D4. |
| 2026-07-28 | §11.9       | Normative correction | **Hex is compared by PROVENANCE: a CALLER-SUPPLIED root case-insensitively, an ENVELOPE root as bytes.** Four caller-facing hex inputs were settled three different ways and one of them was written down. `--expect-root` compared exactly, so an uppercase root was `BROKEN` — and §6.3.9 point 1 condition 4 makes that the operator-minted-root accusation, so **a paste format produced the gravest verdict this protocol emits against a party that had done nothing**. §10.6's imprint is 32 RAW BYTES and openssl hex-encodes uppercase, so the reader most likely to trip it is the one who obtained the root independently exactly as §10.5 condition (4) asks. §11.8.1 already required either case for the seal-record digest and §6.3.4.1 point 3 already required exact bytes for `payloadHash`; this states the rule both were instances of. **The envelope half is deliberately unchanged**: both values come from one producer in one document, so a case disagreement is that producer contradicting its own §6.3.4.2 domain and nobody else is accused. A behaviour change to a published verdict, so it lands with 3.0.0. Clean-room run 11, D7.                                                                                                                                                                                                                                                                                          |
| 2026-07-28 | §11.8.7     | Clarification        | **A supplied trust-policy fingerprint is NORMALISED: case folded, `:` separators removed.** Rule 1 tells the reader the policy is what `openssl x509 -fingerprint -sha256` prints, and what openssl prints is `SHA256 Fingerprint=03:9D:76:48:…` — uppercase and colon-separated. Every published verifier already accepts that spelling and none of them said so, so a verifier built STRICTLY from this rule rejected the exact string the rule pointed at and reported `signerTrusted: false` on genuine evidence, from a paste format. This is the third caller-supplied hex in the document and was the only one whose normalisation was unstated: §11.9 rule 4 settles the caller-supplied root and §11.8.1 the seal-record digest, both on the reasoning that the party who chose the formatting is not the party being accused. No implementation changes; this states what all four already do. Clean-room run 12, A4.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| 2026-07-28 | §1.1        | Prose correction     | **The worked archive example is gone; the rule is stated as a substitution.** The example wrote the revision out as `v1.1-rev21` while the `Revision:` line said `v1.1-rev23`, so it returned a 404 and the rule beside it returned the corpus. A worked example carrying the revision is a SECOND COPY of the revision number requiring an edit on every bump — the exact defect §1.1 was rewritten at rev21 to fix, reintroduced by the sentence demonstrating the fix. The generated directory listing now carries the only spelled-out URL, because it cannot go stale. Clean-room run 12, D1.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| 2026-07-28 | §11.0.1     | Register update      | **The TEN rows that said "closes on 3.0.0" are CLOSED.** 3.0.0 published 2026-07-28; every one was verified against the BUILT artifact by `pnpm check:release-gate` (48 checks, both languages) BEFORE the upload, not from a source diff. The register drops from 23 open rows to 13. **Five divergence cells were rescoped in the same edit**, because they described 2.0.0 in the PRESENT TENSE — "neither published CLI accepts it", "both published packages CRASH", "no implementation has ever performed this check" — and 3.0.0 made all five false. Closing a row without rescoping its divergence cell leaves the register wrong in the OPPOSITE direction, which is this section's own defect class inverted. **A CLOSED row's middle column describes the divergence AS IT WAS in the release named there, not as it is now.**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| 2026-07-28 | §6.3.9      | Prose correction     | **§6.3.9's own report skeleton violated two of the MUSTs below it.** It omitted `anchor.headBoundToChain`, which point 4 makes REQUIRED whenever an anchor is present and which both published packages emit — so the normative example was the only artifact in the program without it. And it printed `"checked": 6` beside a `disclosed` of length ONE, while point 5 requires the two to be equal on every branch. A reader building to the example rather than the prose produced a report that fails the section it was copied from. The example now demonstrates the rules instead: two opened positions out of six entries, which §6.3.7.2 point 4 permits, and `headBoundToChain: null`, which is the only honest value for a served load bundle. Clean-room run 11 reported the first; the second nobody had reported.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| 2026-07-28 | §6.3.9      | Clarification        | **`disclosed[i]` names the fields EXAMINED at position `i`, in UTF-8 byte order — `actor`, `attest`, `geo` — and the walk STOPS at the first field that fails.** Point 5 previously listed the three names in the §6.3.4 preimage order while specifying no order at all, and a clean-room implementer who followed the order the sentence used produced a report disagreeing with both references. It also said nothing about the failure branch, where the two readings diverge. **A failing field IS listed**, as the last element: under the other reading a lying opening and one never offered are both simply absent, and §6.3.7.2 makes those carry opposite weight. **This clause was first drafted as "reports what the opening OFFERED", which is what NEITHER reference does** — it was written from `lying-opening.json`, whose lying field is `geo` and therefore last, so offered and examined coincide there. An entry whose `attest` lies separates them, and is now `bundles/lying-opening-mid-order.json`. **And the adapter contract did not forward `disclosed` at all**, so run 11 disagreed with both references about a published field and scored the seam 32/32; it is now part of the contract and pinned on three vectors. Third field to arrive in the contract this way, after `expectedLoadId` and `rootCorroborated`. Clean-room run 11, D6.                                                                                  |

> **Note on the §11.8 classification, 2026-07-27.** It is filed as a Normative
> correction because it changes what a verifier emits, and filing a behaviour
> change as a clarification is the defect the Kind column exists to prevent. It
> differs from the other Normative corrections in one way worth stating rather
> than leaving to be noticed: those replaced a rule no implementation followed
> **with the rule every implementation already followed**. This one keeps §8's
> rule and makes it reachable, which means Sigil's own API is non-conformant to
> it on the day this is published. That divergence is §11.0.1's job, and it is
> listed there rather than resolved by weakening the rule to match the code.

> **Note on the §6.3.4 re-classification, 2026-07-27.** When the fourth kind was
> added, the eight rows above were re-read against it. Exactly one moved. §6.3.4
> was filed as a Clarification on the reading that this document had been
> _silent_ about `payloadHash` on a v2 entry. It was not silent: v1.1
> incorporates all of v1.0 by reference (§Status), so v1.0 §3.1's
> `payloadHash := sha256(canonical(payload))` governed v2 entries too, described
> no implementation's behaviour, and got a producer that followed it reported
> BROKEN. §6.3.4's own text says it **overrides** v1.0 §3.1 — a clarification
> does not override a normative rule, and that word is the evidence. The
> Normative-correction test is met: no already-sealed artifact verifies
> differently, because no implementation ever followed the replaced rule.
>
> The re-classification changes no behaviour and no canonical form. It is
> recorded rather than quietly applied, because a silently relabelled erratum is
> the same defect as a misfiled one.

## 1. Conformance

A v1.1 verifier MUST implement §6.3 and §10 in addition to all of v1.0, and MUST
pass the Compliance Test Suite, including its cross-language parity gate.

A verifier MUST advertise the maximum protocol version it implements (v1.0 §9).

Governance, defect intake and the classification procedure are specified in
[`GOVERNANCE.md`](../GOVERNANCE.md), which is incorporated by reference. A
conformance claim MUST name the protocol version it implements and the
specification revision and corpus revision it was tested against. The
specification revision is the **Revision** line in this document's header; the
corpus revision is defined in §1.1 and is **not** the same value — it is
content-addressed, and the two move independently.

### 1.1 Obtaining this specification and the corpus

Everything an implementer needs is retrievable without a repository account, a
support request, or any agreement with Sigil:

| What                | Where                                                                             |
| ------------------- | --------------------------------------------------------------------------------- |
| Specification       | `https://sealedby.com/protocol/v1.1/SPEC.md` (and `/protocol/v1.0/SPEC.md`)       |
| Governance          | `https://sealedby.com/protocol/GOVERNANCE.md`                                     |
| Compliance corpus   | `https://sealedby.com/protocol/compliance-suite/`                                 |
| Corpus archive      | `https://sealedby.com/protocol/sigil-compliance-suite-<revision>.tar.gz`          |
| Integrity manifest  | `https://sealedby.com/protocol/SHA256SUMS`                                        |
| Reference verifiers | `npm i sigil-protocol-verifier@latest` · `pip install -U sigil-protocol-verifier` |

The published tree is generated from the repository on every deploy, so it is
the same bytes this document is maintained as rather than a periodically
refreshed copy.

**`<revision>` is the SPECIFICATION revision, verbatim, exactly as it appears on
this document's `**Revision:**` line** — including the `v1.1-` prefix. So the
archive is `https://sealedby.com/protocol/sigil-compliance-suite-`, then that
line's value verbatim, then `.tar.gz`. The corpus retrieval point serves a
directory listing naming every file it contains, and that listing prints the
archive's full URL — which is the copy that cannot go stale, because it is
generated.

> **This paragraph used to end in a worked example with the revision written
> out, and the example was two revisions behind and returned a 404.** It said
> `v1.1-rev21` while the `Revision:` line said `v1.1-rev23`, so a reader who
> copied the example got a 404 page and a reader who applied the rule got the
> corpus. A worked example containing the revision is a SECOND COPY of the
> revision number that has to be edited on every bump — which is the defect this
> very section was rewritten at rev21 to fix, reintroduced by the sentence
> demonstrating the fix. Stated as a substitution instead. Clean-room run 12, D1.

> **This rule was unpublished until 2026-07-28, and the one place it was written
> down was a 404 page.** §1.1 carried the `<revision>` placeholder and expanded
> it nowhere, so a reader had to guess; the naming rule and a worked example sat
> in the body of the protocol tree's not-found response, which is not a published
> document, is cited by nothing, and had already drifted to `rev18` while the
> archive was `rev20`. Compounding it, `/protocol/compliance-suite/` was listed
> here and in the clean-room brief as the corpus retrieval point and returned
> `404` — before that it returned `200` and a web page, which was worse. Both
> halves closed at rev21: the rule is here, and the listing exists. Clean-room
> run 10, D4, and run 8's D1 for the wrong-guess-returns-a-web-page half.

**Checking what you downloaded, and THERE ARE TWO MANIFESTS because there are
two path spaces.** The digests are over the **files**, not only over the
archive: a per-file digest is content-addressed and reproducible by anyone
holding the same documents, whereas an archive digest is only reproducible by
someone who also reproduces `tar`'s metadata.

- **The served tree.** `https://sealedby.com/protocol/SHA256SUMS` lists every
  file at its path relative to `/protocol/` — `v1.1/SPEC.md`,
  `compliance-suite/README.md`. Use it when you fetched documents individually.
- **The archive.** The tarball carries its **own** `SHA256SUMS` at its root,
  listing its members at their paths inside it — `docs/protocol/v1.1/SPEC.md`,
  `protocol/compliance-suite/README.md`. Extract, then run `sha256sum -c
SHA256SUMS` from the extraction directory.

No digest is quoted in this prose, deliberately — a hash written into a sentence
is wrong the next time the file is regenerated, and a stale integrity value is
worse than none.

> **Why this is spelled out, 2026-07-27.** There was one manifest and two path
> spaces, and this paragraph told you to verify the archive with the served
> tree's manifest. Measured against the published rev12 artifacts, doing exactly
> what this section said produced **78 of 79 lines `FAILED open or read`**. Each
> manifest was internally correct and neither could ever verify the other, so
> the one mechanism that proves this corpus was not tampered with reported
> near-total failure — which reads as tampering rather than as a packaging bug,
> and is the most alarming possible way to be wrong about integrity.
>
> The obvious repair was rejected: rebuilding the archive so its members match
> the served paths would relocate `run-suite.mjs` relative to this document, and
> the suite resolves `../../docs/protocol/v1.1/SPEC.md` and treats a miss as
> fatal. It would have fixed the paths by breaking the corpus for the
> independent implementer the archive exists to serve. Clean-room run 5, D7.

A conformance claim MUST name the corpus revision it was tested against.

**The corpus revision is `corpus-` followed by the SHA-256 over the corpus's own
per-file manifest**, in paths relative to `compliance-suite/`, one
`<digest>  <path>` line per file, newline-separated with a trailing newline.

Three details decide the value, and each of them has a wrong answer that looks
right:

1. **Paths sort in UTF-8 BYTE order** — the same order §6.4.7, §10.2 and §11.1
   require everywhere else. The recipe below is a shell pipeline and `sort`
   collates by LOCALE: under glibc's `en_US.UTF-8` punctuation is ignored at the
   primary level, so `README.md` sorts AFTER `adapter/README.md` and the digest
   changes. `LC_ALL=C` is not decoration.
2. **Two files inside `compliance-suite/` are excluded**, because they are
   generated apparatus rather than corpus content: `CORPUS-REVISION`, which
   cannot contain its own hash, and `index.html`, the directory listing, which
   exists only in the served tree.
3. **Directories are not entries.** Only regular files are hashed.

It is published at
`https://sealedby.com/protocol/compliance-suite/CORPUS-REVISION`, carried inside
the archive at the same relative path, and named in the corpus listing.

**It is a value you RECOMPUTE, not one you take from us**, which is the whole
point of publishing a definition rather than a number. It is computed over the
corpus **as published**, and the served tree and the archive agree on it once
the two generated files above are excluded — recompute it from what you
downloaded rather than from a version-control checkout, where line-ending
normalisation on some platforms yields different bytes and therefore a different
digest:

```bash
cd protocol/compliance-suite
find . -type f | sed 's|^\./||' \
  | grep -vx 'CORPUS-REVISION' | grep -vx 'index.html' \
  | LC_ALL=C sort \
  | while IFS= read -r f; do printf '%s  %s\n' "$(sha256sum "$f" | cut -d' ' -f1)" "$f"; done \
  | sha256sum
```

`sed` rather than `find -printf`, which is a GNU extension and is absent on
macOS and the BSDs — the first published version of this recipe did not run
there at all.

> **It is CONTENT-ADDRESSED because the specification revision demonstrably
> cannot carry it, and that was established the day this clause was written.**
> The obvious reading — the corpus revision is the specification revision, since
> the archive is named for it — is what a reader infers today. On 2026-07-28 two
> fixtures were added while this document still said `rev20`, so the served
> `sigil-compliance-suite-v1.1-rev20.tar.gz` changed contents underneath a
> revision that had already been published and measured by a clean-room run:
> **two different corpora under one identifier.** A specification revision moves
> on §0 errata rows and adding a test vector is not an erratum, so the two cannot
> be made to move together without lying about one of them. A content-addressed
> revision needs no discipline to stay true — it moves exactly when the corpus
> moves and never otherwise — and it is identical in the served tree and in the
> archive despite their different path prefixes.
>
> §1, §1.2, §11.1 and `GOVERNANCE.md` have required a conformance claim to name
> this since v1.1 shipped, and nothing defined it. **Clean-room runs 9 and 10
> both asked and neither could answer from published text.**

**§6.4 conformance, stated separately and honestly.** §6.4 is normative for any
implementation that **produces** a `payloadRoot`, and for any verifier that
checks a payload field disclosure. It is NOT required of a verifier that only
walks a chain: such a verifier treats `payloadRoot` as an opaque 64-hex field
inside the §6.3.4 preimage and never needs to recompute it.

> **Implementation status, stated precisely because the previous version of this
> paragraph is already out of date.** It said the two reference verifiers "do not
> implement §6.4 at all". They now do: both carry the full §6.4 surface —
> `computePayloadRoot` / `compute_payload_root`, leaf collection, the fold,
> proof production and disclosure verification — and the Compliance Test Suite
> exercises it in both languages on every fixture in
> `fixtures/payload-root/`.
>
> **An adopter can now install it.** Both were released on 2026-07-28: npm
> `sigil-protocol-verifier` **3.0.0** and PyPI `sigil-protocol-verifier`
> **3.0.0**. Conformance is claimed against a `protocolVersion` and a corpus
> revision, never a package version
> ([`PUBLISHING.md`](../../../protocol/PUBLISHING.md)) — the two package numbers
> coincide here only because one change broke both on the same day, and they are
> not required to match.
>
> **3.0.0 is a MAJOR bump because it changes three published verdicts**, and a
> reader upgrading from 2.0.0 should expect all three.
>
> 1. **A genuine sealed document verified OFFLINE now returns `UNKNOWN`, not
>    `VALID`** (§5.2 step 4, §11.8.7). The signing certificate is self-issued, so
>    an impostor copies its subject name exactly; until a caller supplies a trust
>    anchor or a lookup is performed, nothing has vouched for the signer and
>    `VALID` would be an endorsement nothing earned. The bare CLI invocation on a
>    genuine document consequently exits 1 where it exited 0.
> 2. **A malformed bundle exits 1 with a report** where the packages previously
>    crashed or exited 2 (§6.3.9).
> 3. **An anchor proof step with no `sibling` member is REFUSED.** In 2.0.0 it
>    authenticated: `VERIFIED`, `inclusionValid: true`, exit 0, against a root
>    folded the same wrong way. That is a forgery the previous release accepted
>    and this one does not.
>
> **2.0.0 was itself a MAJOR bump because `canonicalHash` output changed** under
> §11.1-§11.3, and that reasoning still stands for anyone upgrading from the
> 1.x line: a caller recomputing a digest over an astral key, a non-NFC key or an
> integral float gets a different value than from 1.3.0/1.4.0.
>
> The previous text said the opposite ("neither revision carrying §6.4 has been
> released"), and was stale for a day. It is now pinned: a test asserts the
> versions named here match both package manifests and `PUBLISHING.md`, so this
> paragraph cannot silently rot again. A standards document that misstates its
> own implementations is worth less than no document, which is exactly what this
> paragraph warned about while being an instance of it.

### 1.2 Conforming producer

A **conforming producer** is an implementation that SEALS: it computes
`payloadRoot` (§6.4), the three commitments (§6.3.2) and the entry hash (§6.3.4),
and emits the documents of §6.3.4.1, §6.3.7.1 and §6.3.8. The term is used
normatively throughout this document; this section defines it and indexes every
obligation it carries.

Producing is strictly harder than verifying, and the difference is the reason
this index exists. **A verifier's obligations are all checkable against an
artifact it holds. Several of a producer's are not** — salt entropy, timestamp
precision and identifier namespace leave no trace a reader could audit — so a
producer cannot establish conformance by running the corpus alone. The
**Checkable** column says which is which, and every `producer-attested` row is a
rule the Compliance Test Suite will pass you on regardless.

| Rule                                              | §        | Checkable         |
| ------------------------------------------------- | -------- | ----------------- |
| `version` / `chainIndex` in INTEGER FORM          | §6.3.1   | CTS               |
| Salt from a CSPRNG, ≥128 bits, not derived        | §6.3.2   | producer-attested |
| Actor value; empty identifiers forbidden          | §6.3.2.1 | producer-attested |
| Attested value; structured blobs unspecified      | §6.3.2.2 | producer-attested |
| Geo canonical form and number rendering           | §6.3.3   | CTS               |
| Refuse to seal an unrenderable number             | §6.3.3   | CTS               |
| `occurredAt` exactly three fractional digits      | §6.3.4   | producer-attested |
| `payloadHash` equals `payloadRoot` **if emitted** | §6.3.4   | CTS               |
| Entry document shape; lowercase 64-hex            | §6.3.4.1 | CTS               |
| Openings document shape; geo value verbatim       | §6.3.7.1 | CTS               |
| Stored timestamps carry `source`, never bare      | §11.8.4  | producer-attested |
| Bundle envelope shape                             | §6.3.8   | CTS               |
| No explicit null for an absent optional           | §6.4.6   | CTS               |
| Refuse a payload over `MAX_PAYLOAD_LEAVES`        | §6.4.6   | CTS               |
| `orgId` MUST NOT contain `"\|"` (anchor leaf)     | §10.1    | producer-attested |

This index is **cross-reference only**. It deliberately does not restate any
rule: a restated normative rule is a second source of truth and drifts from the
first, which is the finding that produced this repository's doc-pointer gate.
Read the section.

`scripts/check-producer-index.mjs` fails the build when a producer rule appears
in a section this table does not cite. It is not a completeness proof — a regex
over prose cannot be one, and a guard that implied otherwise would be worse than
none, because it would license the belief that a green run means every
obligation is listed.

A conformance claim by a producer SHOULD state the protocol version, the
specification revision, the corpus revision (§1.1), and its identifier namespace
(§6.3.2.1).

## 6.3 Load chain, version 2

### 6.3.0 Why a second form

v1.0 §6.1 joins fields with a newline. That form has three defects that matter
once a third party is asked to fund money against the evidence:

1. A free-text business value containing the delimiter can forge an alternate
   field assignment with an equal hash.
2. There is no domain separator, so a load preimage can be reread as another
   form to hunt a cross-form collision.
3. The device-attestation blob — the strongest anti-account-takeover signal in
   the record — was carried side-band and **unhashed**, making it the one field
   a tamper could alter without breaking the seal.

v2 fixes all three. It exists **alongside** v1; `load-chain.ts` and v1.0 §6.1 are
never modified, because adding a field to a live canonical form is itself a
tamper event. Verifiers dispatch on the `version` tag bound into the hash.

### 6.3.1 Length prefixing

```
LP(s) := decimal(utf8_byte_length(s)) || ":" || s
```

The length is the **UTF-8 byte length**, not the character or UTF-16 code-unit
count. Because the length is exact and precedes the value, concatenated
length-prefixed fields parse unambiguously, and a value containing a delimiter
cannot span into a sibling field.

**`LP` takes a string.** Every non-string term of the §6.3.4 preimage is rendered
to a string first, and this section says how, because the rendering is part of
the hashed bytes even though `LP` itself never sees the original type.

`version` and `chainIndex` MUST be non-negative integral binary64 values within
the safe-integer range, and render inside the preimage in **INTEGER FORM** per
§6.4.2.3 — no fractional part, no exponent, no leading zeros, no sign. A
float-valued `2.0` and the integer `2` therefore render identically and are the
**same entry**; a serializer or JSON round-trip that turns one into the other
does not change a verdict. `fixtures/served-shapes/envelope-and-number-forms.json`
pins this.

> This rule was already enforced by the Compliance Test Suite and stated only in
> `compliance-suite/README.md`, which is not a normative document and does not
> ship with the specification. An implementer reading only the spec had to infer
> the rendering of two preimage terms from an example. Stating it changes no
> bytes: it is filed as a Clarification and was merged only after a differential
> run of the full corpus in both languages showed **no digest moved**.

### 6.3.2 Commitments

```
COMMIT_DOMAIN := "sigil-load-commit:v1"
commit(value, salt) := SHA256( LP(COMMIT_DOMAIN) || LP(value) || LP(salt) )
```

`salt` MUST be drawn fresh per commitment from a CSPRNG, and MUST NOT be derived
from row data. A salt computed as a KDF over `(loadId, chainIndex, occurredAt)`
is reproducible by anyone who can read the row, which makes the hiding property
vacuous over the low-entropy actor and geo value spaces.

**The salt's ENCODING is producer-defined, and that is not an oversight.** `LP`
takes a string, so a salt is a string, and this document deliberately does not
say whether the drawn bytes are rendered as hex, base64, base64url or anything
else. It cannot matter: the identical string travels in the §6.3.7 opening, and
a verifier recomputes `commit(value, salt)` over the string it was given. There
is no step at which two implementations must agree on an encoding, so pinning
one would constrain producers for nothing. Sigil's producer draws 24 bytes and
renders them base64url without padding; a producer that renders 16 bytes as hex
is equally conformant.

**The 128-bit floor is on the drawn ENTROPY, not on the string's length.** A
32-character string is not a 128-bit salt if it encodes 6 random bytes, and a
verifier cannot tell the difference from the artifact — this is a producer
obligation that no fixture can check, which is why it is stated rather than
tested.

A salt MUST carry at least **128 bits** of entropy from that CSPRNG. This is not
checkable from a sealed artifact and is therefore a **producer obligation**: a
verifier that sees a two-byte salt cannot distinguish it from a short encoding of
a long one, and a successful opening is never evidence of salt quality (§6.3.7.1).
It is stated because a literally conformant 2-byte salt is brute-forceable over
exactly the low-entropy actor and geo spaces the hiding property exists to
protect — the rule above says the salt must be unpredictable, and without a floor
that is satisfiable by a value an attacker can simply enumerate.

`value` is committed as an **unmodified UTF-8 string**. This specification applies
no normalization to it — not NFC, not case folding, not trimming. §6.3.3 defines
the geo value; §6.3.2.1 and §6.3.2.2 define the other two.

#### 6.3.2.1 The actor value

`actorCommit` commits the acting principal's identifier as an unmodified string,
**in the producer's own identifier namespace**.

- An unknown, absent or system actor commits the **empty string**.
- The identifier is **opaque to a verifier.** A verifier MUST NOT parse it, MUST
  NOT infer structure from it, and MUST NOT assume portability across producers.
  An opening reveals who the **producer says** acted, in the producer's
  namespace, and nothing more.
- A producer SHOULD document its namespace alongside its conformance claim, so
  an escrowed opening is interpretable by whoever receives it.

> **Publishing the serialization does not make the value portable, and that is
> the point of this section.** Sigil commits an internal cuid. A third party
> reading an opened `usr_01HQ…` learns a string; whether it denotes a person, a
> service account or a machine is knowable only from the producer's namespace
> documentation. Specifying the bytes without specifying that would produce
> openings that verify and cannot be read, which is a worse failure than an
> undefined value because it looks like it worked.

> **The empty-string convention has a cost, stated rather than discovered.** An
> escrowed opening of `""` is indistinguishable from an actor whose identifier is
> genuinely the empty string. Producers MUST NOT issue empty identifiers.

#### 6.3.2.2 The attested value

`attestCommit` commits an **opaque producer-chosen string**. An absent
attestation commits the empty string. A verifier MUST NOT parse it.

> **Known limit, stated rather than discovered: structured attestations have no
> specified canonical form.** Sigil's producer accepts a structured
> device-attestation blob and serializes it with a sorted-key stable stringify
> before committing (`canonicalAttestation`). **That serialization is not part of
> v1.1.**
>
> Be precise about what does and does not still work, because the obvious
> statement of this limit is wrong. The escrowed opening carries the
> **already-serialized string**, so `commit(value, salt)` remains fully
> reproducible by any implementation — an attest opening verifies normally. What
> is NOT reproducible is the step **before** it: an independent producer holding
> the same structured attestation cannot derive the same committed string, and a
> reader cannot check that the opened string faithfully represents the original
> object. So the commitment is checkable and the **binding to the underlying
> attestation is not**.
>
> Flat string attestations have no such gap and are what this version specifies.
> Naming a canonicalization for structured blobs would change every
> `attestCommit` already sealed over a nested object — a canonical-form change,
> which under §0 requires a new protocol version rather than an erratum. It is
> deliberately not done here.

### 6.3.3 Geo canonicalization

```
GEO_DOMAIN := "sigil-load-geo:v1"
canonicalizeGeo(g) := LP(GEO_DOMAIN)
                   || LP(latitude) || LP(longitude)
                   || LP(accuracyMeters) || LP(capturedFrom)
```

A `null` coordinate encodes as the empty string. Numbers render in the canonical
decimal form below; this string is the value committed as `geoCommit`, and it is
what an escrowed geo opening carries.

> **The first two terms were written `lat` and `lon` until 2026-07-27**, while
> every other mention in this document, in the wire shapes and in both
> references spells them `latitude` and `longitude`. The construction is
> positional, so nothing about it was ambiguous — but an implementer holding a
> field called `latitude` and reading `LP(lat)` has to stop and rule out a
> second, shorter field before continuing. Spelled out here so nobody pays that
> tax twice. **No byte changes**: these are value slots, never key names, and
> nothing in this preimage ever carried the identifier.

**An event with NO geo at all commits `canonicalizeGeo` over the all-absent
stamp**: `latitude`, `longitude` and `accuracyMeters` each the empty string, and
`capturedFrom` the literal `NONE`. It does NOT commit the empty string, and it
does not skip the commitment.

**Within a PRESENT geo object, every one of the four terms independently encodes
as the empty string when it is null or absent** — not only the two coordinates.
The rule above says "a null coordinate", and `accuracyMeters` is not a
coordinate, so an implementer reading it literally had no rule for a geo stamp
carrying real coordinates and no accuracy. `capturedFrom` is the one exception:
absent or null, it encodes as the literal `NONE`, the same value the all-absent
stamp uses, because an unstated capture source and an explicitly unknown one are
the same fact. (Clean-room run 2, A3.)

**Numbers here render under §6.4.2.2's encoder, whose STEP ORDER is normative.**
The rules below are the same rules, stated in prose; they are not a second
algorithm, and an implementer MUST NOT write a separate geo renderer from them.
The trap is that §6.4.2.2 tests **integrality before** the magnitude band, so
`latitude: 0` renders `0` and survives, while a renderer that checks the band
first refuses it as "below 1e-4". `fixtures/geo/canonical.json` covers
`latitude: 0`, so the corpus catches it — but the prose did not say so, and
§11.2 already had to make this exact cross-reference for §4. (Clean-room run 2,
A2.)

> **This was undefined, and silence here is expensive.** §6.3.2.1 and §6.3.2.2
> each say what an absent actor and an absent attestation commit; §6.3.3 said
> only what a null COORDINATE encodes as, never what an event carrying no geo
> object does. Three readings — commit the empty string, commit the all-absent
> stamp, omit the commitment — give three different `geoCommit` values, **no
> verifier can distinguish them** (a commitment is opaque without its opening),
> and no fixture covered it. A clean-room implementer hit this, could not
> resolve it from published text, and avoided it by giving every event real
> coordinates. The rule above is the one Sigil's producer follows.

**Number rendering (normative).** A conforming verifier MUST refuse to
canonicalize, and a conforming producer MUST refuse to seal, any value whose
decimal rendering differs between implementations:

- an integral value renders with no fractional part (`41`, never `41.0`);
- negative zero renders as `0`;
- values outside ±(2^53 − 1) MUST be rejected;
- non-integral values below 1e-4 in magnitude MUST be rejected.

These are the exact boundaries at which JavaScript's `String(n)` and Python's
`repr(float)` diverge. Rejection is deliberate: an unrepresentable number must
fail loudly at seal time rather than produce a commitment a conforming verifier
in another language would call BROKEN.

> **Where Sigil enforces each half (as of 2026-07-25).** The reference verifiers
> implement the verifier half. The producer half is enforced at the **wire
> boundary** (`loadEventGeoInputSchema` in `@sigil/shared`): a capture carrying
> an unrenderable coordinate is refused with a 400 naming the field, before
> anything is sealed.
>
> It is deliberately NOT enforced inside `canonicalizeGeo` in `@sigil/crypto`.
> That function is also called by the hourly chain verifier when it re-derives a
> stored row's `geoCommit`, so a throw there would crash the verification job on
> a hypothetical legacy row instead of reporting it BROKEN — trading a reporting
> failure for an availability failure. Refusing at the edge and staying
> permissive in the primitive keeps the guarantee without that trade.
>
> Checked against production before the guard shipped: of 27 events carrying
> GPS, zero fall in the rejected domain, and the single v2-sealed event carries
> no coordinates at all. The guard refuses nothing that has ever been captured.

### 6.3.4 Entry hash

```
ENTRY_DOMAIN := "sigil-load-entry:v2"

entryHashV2(e) := SHA256(
    LP(ENTRY_DOMAIN)
 || LP(version) || LP(loadId) || LP(chainIndex) || LP(occurredAt) || LP(kind)
 || LP(prevHash)
 || LP(envelopePresent) || LP(envelopeId)
 || LP(payloadRoot)
 || LP(actorCommit) || LP(geoCommit) || LP(attestCommit) )
```

- `version` is `2`.
- `loadId` MUST be bound. Without it, load identity lives only in a database
  foreign key — outside the tamper-evident boundary — and two loads with matching
  genesis fields seal byte-identical entries, making a chain transplantable.
- **`occurredAt` is hashed as the served string, verbatim.** A v1.1 producer MUST
  render it as RFC 3339 UTC with a literal `Z` and **exactly three
  fractional-second digits** (`2026-07-24T14:02:11.000Z`). A verifier hashes the
  bytes it was given and **MUST NOT reformat, re-parse or normalize** the value
  before hashing it.

  > **This is the one preimage field a database round-trip silently changes,
  > which is why the producer rule and the verifier rule point in opposite
  > directions.** `timestamptz` columns commonly return microsecond precision and
  > several languages' native ISO formatters emit six fractional digits, so
  > `…:11.000Z` and `…:11.000000Z` denote the same instant and are different
  > preimages. A verifier that "helpfully" normalized either into the other would
  > compute a digest over bytes nobody sealed and report an intact chain as
  > BROKEN — turning a formatting difference into an accusation of tampering.
  >
  > A six-digit form therefore **still verifies as served**: it is the PRODUCER
  > that is non-conformant, not the artifact. This asymmetry is deliberate and is
  > the only safe one. Restricting the verifier instead would invalidate sealed
  > evidence, which §0 forbids. `fixtures/load-chain-v2/valid/occurred-at-precision-{3,6}-digit.json`
  > pins both forms as VERIFIED.

- `envelopePresent` is `"0"` when `envelopeId` is absent and `"1"` otherwise;
  `envelopeId` encodes as the empty string when absent. The presence flag exists
  so absence and an empty id cannot be flipped for one another post-seal.
- `payloadRoot` is a structured per-field Merkle root (enabling selective
  disclosure), not a whole-payload digest. Its canonical form is normative and is
  defined in **§6.4**.
- **`payloadHash` on a v2 entry MUST equal `payloadRoot`.** This overrides v1.0
  §3.1, which defines `payloadHash` as `sha256(canonical(payload))`; that rule
  governs v1 entries only. A v2 verifier MUST reject an entry whose `payloadHash`
  and `payloadRoot` differ, and MUST report it as a broken chain rather than as a
  malformed entry.

  > **Why, and why a producer cannot skip it.** The v2 preimage binds
  > `payloadRoot`, not `payloadHash`. Left unconstrained, `payloadHash` would
  > become a freely rewritable column on a sealed row — a tamper-evidence
  > property v1 had and the upgrade would have silently dropped. Mirroring it and
  > enforcing the equality keeps the column committed.
  >
  > This was implemented and enforced before it was written down. Until v1.1 was
  > amended, a third-party producer that followed v1.0 §3.1 literally and wrote a
  > §4 canonical digest into `payloadHash` would have had its chains reported
  > **BROKEN** by Sigil's own verifier, for a rule that existed only in Sigil's
  > source. That is the exact failure mode an open standard exists to prevent, so
  > it is stated here as a MUST rather than left as an implementation detail.

### 6.3.4.1 The served entry document

§6.3.4 defines the **preimage**: what is hashed, and in what order. This section
defines the **wire document**: what a producer serves and what a verifier reads.
The two are different shapes, and conflating them is the first thing an outside
producer gets wrong.

**Nothing in this section is hashed.** It is a serialization, not a canonical
form: key order is free, whitespace is free, and no rule here affects any digest.
A producer may not infer a canonical form from it, and a change to it is never an
erratum to a canonical form.

```json
{
  "version": 2,
  "loadId": "load_01HQZX5J8K4M2N9P6R3T7V",
  "chainIndex": 0,
  "occurredAt": "2026-07-24T14:02:11.000Z",
  "kind": "RATE_CON_SIGNED",
  "prevHash": "0000000000000000000000000000000000000000000000000000000000000000",
  "envelopeId": "env_01HQZX5J8K4M2N9P6R3T7W",
  "payloadRoot": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "commitments": {
    "actorCommit": "de9e3fa17c511a03e191177b8b361cd5f3042df779b203a67524a63c1deabac5",
    "geoCommit": "aeb9880e5b9c40d008898ab259f6c814a16b0510827ad7d22a04d70618891ee6",
    "attestCommit": "33ea59abb1a8ec6eaee294dec874dbed574d6b5ee5897f4891aede4b59818e33"
  },
  "hash": "74a770eb92adf31fcfab418d540c57e2c1584007262ea780b69018b0bf05970e"
}
```

Normatively:

1. **The three commitments are carried NESTED, under `commitments`**, keyed
   `actorCommit`, `geoCommit` and `attestCommit`. §6.3.4 lists them as flat terms
   because that is **hashing order**, not wire shape. This is the single most
   likely first-attempt error for an outside producer, and it is called out for
   that reason. A verifier MUST report it as `BROKEN` with a reason naming the
   missing member (§6.3.9 point 7); `fixtures/bundles/malformed-flat-commitments.json`
   is the vector.

   > **Correction, 2026-07-27.** This point used to say the mistake "surfaces as
   > a hash mismatch rather than as a missing field — so it reads like a tampered
   > chain rather than a serialization mistake." **That was false, and it was
   > false about the error this document singles out as the most likely one.** It
   > surfaced as an unhandled exception in both published references, with no
   > report at all and with _different exit codes_ on identical bytes. So an
   > implementer making the predicted mistake got neither the hash mismatch
   > promised here nor a verdict, and this document had told them what to expect
   > from behaviour nobody had run. Found by clean-room run 2; §6.3.9 point 7 now
   > states the required outcome and the corpus pins it.

2. **`envelopeId` MAY be omitted or serialized as `null`.** The two are the same
   fact and MUST verify identically. Note this is a statement about the WIRE:
   §6.3.4's `envelopePresent` flag still distinguishes an absent id from an
   **empty-string** id in the preimage, and those two are NOT the same fact.
3. **`payloadHash` MAY be present, absent, or `null`. Emitting it is OPTIONAL,
   and §1.2's producer index does not make it mandatory** — that row reads
   "equals `payloadRoot` if emitted", and it is listed there because the
   equality is CTS-checkable, not because the field is required. (Clean-room
   run 2, A4: the index read as an obligation to emit.) When present and
   non-null it **MUST equal `payloadRoot`** (§6.3.4, erratum 2026-07-26); a
   verifier MUST report a difference as a **BROKEN chain**, not as a malformed
   entry. **The comparison is over the STRING, not over the digest it denotes**,
   so an uppercase `payloadHash` beside a lowercase `payloadRoot` is a
   difference and is `BROKEN`. Measured 2026-07-28: all four verifiers already
   do this, and none of them says so. A reader who implemented the equality
   case-insensitively — a defensible reading of "MUST equal" for two hex
   digests — passes every corpus vector, because no fixture mixes case. Point 5
   makes these fields lowercase, but §11.0.1 records the hex DOMAIN as
   deliberately unenforced, so the two rules jointly permit an uppercase field
   to exist while requiring it to fail this one. Clean-room run 9, D10. Absence is not a failure: `GET /v1/loads/{id}/bundle` omits it and the
   OpenAPI `LoadAuditView` publishes it, and both are conformant.

   > This is the only v2 rule whose violation is **invisible to the entry hash**,
   > because `payloadHash` is not a term of the preimage. Every other check
   > passes on a row whose `payloadHash` was rewritten. Without the equality
   > rule it is a freely editable column on a sealed row — the tamper-evidence
   > property v1 had, which the upgrade would otherwise have silently dropped.
   > `fixtures/load-chain-v2/tampered/payload-hash-mismatch.json` is the vector.

4. **`version` MUST be the integer `2`** for entries under this section. A
   verifier MUST treat an unrecognised version as BROKEN and MUST NOT re-read it
   as v1; both are the shape a downgrade attack takes.
5. **Every hex field is lowercase, unpadded, exactly 64 characters** —
   `prevHash`, `payloadRoot`, `payloadHash`, all three commitments, and `hash`.
6. Numbers are JSON numbers. A serializer that renders `2` as `2.0` on a
   round-trip does not change a verdict; `fixtures/served-shapes/` pins this.

#### 6.3.4.2 Field domains

What each field may contain. Collected here because the rules were spread across
§6.3.1, §6.3.4, §6.3.6 and v1.0 §3.2, and two of them were not written down
anywhere an implementer of the **v2 load chain** would look.

| field           | domain                                                                           |
| --------------- | -------------------------------------------------------------------------------- |
| `version`       | the integer `2`                                                                  |
| `loadId`        | **any non-empty UTF-8 string.** Opaque to a verifier                             |
| `chainIndex`    | integral binary64, `0`-based, within the safe-integer range; equals its position |
| `occurredAt`    | RFC 3339 UTC, exactly three fractional-second digits (§6.3.4 erratum)            |
| `kind`          | **any non-empty UTF-8 string.** Opaque to a verifier                             |
| `prevHash`      | lowercase 64-hex; at `chainIndex 0` the **genesis value** below                  |
| `envelopeId`    | any UTF-8 string, or absent/`null` (§6.3.4.1 point 2)                            |
| `payloadRoot`   | lowercase 64-hex (§6.4)                                                          |
| `payloadHash`   | lowercase 64-hex equal to `payloadRoot`, or absent/`null`                        |
| `commitments.*` | lowercase 64-hex (§6.3.2)                                                        |
| `hash`          | lowercase 64-hex                                                                 |

**The genesis `prevHash` is `"0"` repeated 64 times.**

```
LOAD_GENESIS_PREV_HASH := "0000000000000000000000000000000000000000000000000000000000000000"
```

It was defined only in v1.0 §3.2, which is the **org** audit chain — a different
chain, in a different document, that a v2 load-chain implementer has no reason
to open. §6.3.6 rule 4 says `prevHash` must equal "the genesis value for `i = 0`"
and named no value. The only place it appeared in this document was inside an
example, so it was recoverable by copying a literal out of a code block and
hoping. A clean-room implementer did exactly that.

**`kind` and `loadId` are OPAQUE STRINGS, deliberately.** There is no enumeration
of event kinds and there is no format rule for a load id, and a verifier MUST NOT
impose either. This is not an omission:

- A closed `kind` enum would make **every new event type a protocol version
  bump**, and would make Sigil's own event vocabulary — which is product
  surface, not protocol — binding on every other implementation.
- A `loadId` format rule would force other operators onto Sigil's identifier
  scheme. The only constraint that matters is §6.3.6 rule 2: every entry in a
  chain carries the SAME `loadId`, and a caller-supplied id MUST be preferred
  over the envelope's.

Both are length-prefixed into the preimage (§6.3.1), so the only real
requirement is that they are strings with a UTF-8 encoding. A value containing a
delimiter, a newline or a `:` is safe by construction — that is what length
prefixing bought.

> **Stated because the absence of a rule is invisible.** An implementer who
> assumes there is a kind enum goes looking for it, does not find it, and cannot
> tell whether it is unpublished or nonexistent. Those are very different
> situations and only one of them is safe to build on.

> **Not to be confused with Sigil's audit projection.** `GET /v1/loads/{id}/audit`
> serves a `LoadAuditView`, a human-facing projection keyed `chainVersion` with
> flat commitments. It is **NOT the verifiable entry** and MUST NOT be fed to a
> chain verifier. The verifiable entry is served by `GET /v1/loads/{id}/bundle`.

### 6.3.5 Single-entry verification

Recompute `entryHashV2` from the entry's own served fields and compare to its
sealed `hash`. An entry whose `version` is not `2` MUST be rejected here rather
than re-read under another form.

### 6.3.6 Chain verification

For each entry at position `i`:

1. `version` MUST be `2`. An unrecognized version is BROKEN — never skipped, and
   never quietly re-read as v1. Both are the shape a downgrade attack takes.
2. `loadId` MUST equal every other entry's `loadId`, and — when the caller states
   which load it asked for — MUST equal that load. **The transplant defense is
   the binding of §6.3.4 PLUS this check.** A chain sealed for another load is
   internally consistent and will otherwise verify.
3. `chainIndex` MUST equal `i`.
4. `prevHash` MUST equal the previous entry's `hash`, or the genesis value for
   `i = 0`.
5. `hash` MUST equal `entryHashV2(entry)`.

Verification reads only the sealed commitments, so chain integrity is checkable
without revealing any private value.

### 6.3.7 Openings

An opening is a `(value, salt)` pair. It verifies when
`commit(value, salt)` equals the corresponding sealed commitment.

Openings are **never** part of the entry hash and are escrowed side-band into the
sealed bundle at funding time. A commitment proves consistency and precedence,
not availability: an operator who never reveals an opening cannot be compelled to
by cryptography alone, so disclosure must be captured before a dispute exists.

### 6.3.7.1 The openings document

Like §6.3.4.1 this is a **wire serialization, not a canonical form**. Nothing
here is hashed; the values it carries are hashed, under §6.3.2.

```json
[
  {
    "actor": { "value": "usr_01HQZX5J8K4M2N9P6R3T7X", "salt": "v2vector-actor-salt-000000" },
    "geo": {
      "value": "17:sigil-load-geo:v17:41.87818:-87.62982:126:DEVICE",
      "salt": "v2vector-geo-salt-0000000"
    },
    "attest": {
      "value": "appattest:v1:chicago-dock-device-a",
      "salt": "v2vector-attest-salt-0000"
    }
  }
]
```

Normatively:

1. **`openings[i]` corresponds to `entries[i]`.** The alignment is positional and
   it is normative. Until now it was stated only in the two reference-verifier
   READMEs, which are not normative documents and do not ship with the spec.
2. Each opening is `{ value, salt }`, verified by recomputing §6.3.2's
   `commit(value, salt)` and comparing it to the matching commitment on
   `entries[i]` — `actor` against `actorCommit`, `geo` against `geoCommit`,
   `attest` against `attestCommit`.

   **`value` and `salt` are both STRINGS, and both MUST have a UTF-8 encoding**
   (§6.4.1.1). An opening that is not a JSON object, or either of whose members
   is absent or is not an encodable string, is **structurally malformed**: the
   verifier MUST report `BROKEN` with a reason naming **which member of which
   field** failed, and MUST NOT throw (§6.3.9 points 6 and 7). The **chain**
   half of that report stays valid when the entries themselves are intact — a
   malformed disclosure is not evidence that the sealed history moved, and those
   two carry opposite evidentiary weight.

   > **Both published packages died here, at DIFFERENT exit codes, 2026-07-28.**
   > This clause previously said "each opening is `{ value, salt }`" and stopped,
   > so nothing refused a number, a `null`, an array or an object in either
   > member. Both reach `LP`, which counts UTF-8 bytes, so a non-string reached
   > the hasher. Measured across eight shapes and both members: **npm 2.0.0
   > exits 2 with no report** (`The "string" argument must be of type string`)
   > and **PyPI 2.0.0 exits 1 with no report**
   > (`AttributeError: 'int' object has no attribute 'encode'`).
   >
   > **The split is the damage, and it is worse than either crash alone.** The
   > exit contract below §6.3.9 makes `2` mean _I could not evaluate this_ and
   > `1` mean _this document is BROKEN_. So one caller reads an unreadable file
   > where the other reads tampered evidence, over byte-identical input, and
   > neither is told which field did it. Found by clean-room run 8, D4.
   >
   > Nothing here restricts what an honest producer could already have sealed:
   > a non-string member has no `LP` encoding and therefore never had a
   > commitment preimage at all, so no already-sealed artifact verifies
   > differently (§0).

3. **`geo.value` is the §6.3.3 canonical string, verbatim** — the exact bytes
   `canonicalizeGeo` produces, length prefixes and all, as in the example above.
   It is **not** a coordinate object and **not** a re-serialization of one.

   > **Read that example with a parser, not with your eyes.** A length prefix
   > sits immediately before the value it measures, so the latitude's prefix
   > runs straight into the domain and `…-geo:v1` + `7:` reads as `…-geo:v17:`.
   > The example above therefore LOOKS like latitude `41.87818`, longitude
   > `-87.62982`, accuracy `126`. Decoded, it is **`41.8781`, `-87.6298`,
   > `12`** — the trailing digits belong to the NEXT field's prefix. Both
   > readings are self-consistent; only one is the value that was committed.
   >
   > A clean-room implementer lost twenty minutes here and concluded this
   > specification had miscomputed its own example, recovering only by decoding
   > `fixtures/geo/canonical.json`. That is on the one field this section flags
   > as producing "the most damaging false verdict this protocol can emit", so
   > the misreading is called out rather than left as a trap.

   > **Why this one is called out.** A producer that serves
   > `{"latitude": 41.87818, "longitude": -87.62982, …}` here has served a
   > well-formed document that fails its commitment check, and a failed geo
   > opening is reported as a **lying disclosure** — the claim that the
   > discloser is asserting a location the entry never committed to. That is the
   > most damaging false verdict this protocol can emit: it accuses an honest
   > party of fabricating evidence, on the strength of a serialization mistake.
   > `fixtures/bundles/lying-opening.json` is the true-positive vector; a
   > coordinate object here would be indistinguishable from it.

4. `salt` is carried exactly as committed, and is encoded under `LP` by §6.3.2
   like any other term. §6.3.2's freshness requirement is a **producer**
   obligation; a verifier cannot check it, because a fresh CSPRNG salt and a
   salt derived by KDF from row data are indistinguishable to a reader. A
   verifier MUST NOT infer salt quality from a successful opening.
5. An opening that is **absent** is undisclosed, not failed. See §6.3.7.2.

### 6.3.7.2 Partial disclosure

Selective disclosure is the entire reason the three values are committed
separately rather than folded into one commitment. A discloser who must reveal a
delivery location to a factor should not thereby reveal which employee captured
it, nor the device attestation blob. This section makes that operable.

1. **The three openings are independent and MAY be disclosed individually.** Any
   subset of `actor`, `geo` and `attest` may be present on any entry.
2. **An absent opening is UNDISCLOSED.** It is neither an error nor an assertion
   of agreement, and it MUST NOT affect the chain verdict. A verifier MUST NOT
   treat absence as failure and MUST NOT treat it as success.
3. **An opening that is present and does not match its commitment is a lying
   disclosure and MUST fail.** Presence is what makes a claim checkable; this is
   the only case that fails.
4. **An `openings` array shorter than `entries`, or containing holes** (a `null`
   or omitted element), **is not invalid.** The missing positions are
   undisclosed. An array LONGER than `entries` has no corresponding entry to
   check against and MUST fail.
5. **A verifier MUST report which openings it checked**, per entry. Without this
   a reader cannot distinguish "checked and agreed" from "never offered", and
   those carry opposite evidentiary weight. A report that says only `valid: true`
   over a bundle disclosing nothing at all asserts far more than it verified.

> **Classification: Normative correction (§0).** The previous text admitted only
> all-three-or-nothing, and both published verifiers implemented that: the
> Python reference raised `KeyError` on a partial openings map and the
> TypeScript reference threw on a property of `undefined`. So the feature the
> commitment scheme exists to provide was unreachable through either published
> implementation. No already-sealed artifact verifies differently — this changes
> what a verifier accepts as a disclosure document, never what any commitment or
> entry hashes to.

### 6.3.8 The served bundle envelope

The document `GET /v1/loads/{id}/bundle` returns and `sigil-verify chain`
consumes. Like §6.3.4.1 and §6.3.7.1 it is a **wire serialization, not a
canonical form**; nothing in the envelope is hashed.

```json
{
  "loadId": "load_01HQZX5J8K4M2N9P6R3T7V",
  "entries": [],
  "openings": [],
  "anchor": { "head": {}, "proof": [], "root": "…" },
  "anchorCheckpointId": "ckpt_01HQ…",
  "expectedRoot": "…",
  "notes": "free text"
}
```

Normatively:

1. **Only `entries` is REQUIRED.** A bundle carrying entries alone is a complete,
   conformant document; it verifies the chain and reports the other sections as
   absent rather than failed (`fixtures/bundles/chain-only.json`).
2. **An omitted member and an explicit `null` are the same fact** and MUST verify
   identically. This holds for `openings`, `anchor` and `expectedRoot`, and it is
   the shipped 1.3.0 / 1.4.0 behaviour that `fixtures/bundles/explicit-nulls.json`
   requires. A verifier MUST NOT prefer one form, and this document does not tell
   producers to omit rather than serve `null` — a serializer that emits nulls for
   absent optionals is conformant.
3. **The `anchor` member's shape**, which the sketch above prints as `"head": {}`
   and therefore does not give:

   | member  | shape                                                              |
   | ------- | ------------------------------------------------------------------ |
   | `head`  | `{ orgId, index, headHash }` — the §10.1 leaf operands, as members |
   | `proof` | array of `{ sibling, siblingIsRight }` (§10.3)                     |
   | `root`  | the §10.2 root as served, lowercase 64-hex                         |

   > **§10.1 gives these names as FORMULA OPERANDS, never as wire members**, and
   > §10.7's inclusion proof is a **flat** object carrying `orgId`, `index` and
   > `headHash` at its top level — a different shape from this nested one, for
   > the same three values. So an implementer had a formula, a differently-shaped
   > sibling object, and an empty `{}`, and the only place the real member names
   > appeared was inside `fixtures/anchored/head-binding.json`. This is exactly
   > the flat-versus-nested defect §6.3.4.1 point 1 exists to prevent for
   > `commitments`; the `anchor` member never got the same treatment. Found by
   > clean-room run 2, which recovered the names by reading the fixture.

   **Every member of `anchor`, and of `anchor.head`, is REQUIRED once `anchor`
   is present, and an ABSENT one is the same fact as a `null` one.** A verifier
   MUST report a missing or wrongly-typed sub-member as `BROKEN` with a reason
   naming it — `anchor.head.orgId`, not "malformed bundle" — and MUST NOT throw
   (§6.3.9 points 6 and 7). Point 2's omitted-equals-null equivalence is stated
   for the top-level members by name; it holds here too, and both readings
   produce the identical report.

   > **Point 3 gave the shape and no rule for a member of it going missing,
   > until 2026-07-28.** An `anchor` carrying `proof` and `root` but no `head`
   > had no defined outcome, and a clean-room implementer had to choose one.
   > Measured across all eleven shapes — `root`, `head` and `proof` each absent
   > and each `null`, `anchor` as `{}`, `head` as `{}`, and each of `head`'s
   > three members absent — the two in-tree references agree byte for byte and
   > name the member every time. The published packages do not: **PyPI 2.0.0
   > emits no report on ten of the eleven and npm on five**, at different exit
   > codes. That divergence is §11.0.1's; the missing RULE is this clause's, and
   > it is the rule that made the divergence possible. Clean-room run 9, D1 and
   > D2.

4. **`anchorCheckpointId` is an OPTIONAL pointer**, used to fetch the checkpoint
   independently under §10.

   > **A root handed to a verifier inside the bundle it is checking is not
   > corroboration.** Both the proof and the root arriving from the same party
   > proves internal consistency and nothing about external time. A verifier MUST
   > report such an anchor as included-but-uncorroborated (§10.5) rather than
   > anchored. This is the one place in the envelope where accepting a
   > well-formed document at face value would overstate what was verified.
   >
   > **`expectedRoot` in THIS envelope is not that channel, and this paragraph
   > used to say it was.** It read "`expectedRoot` exists so a reader who
   > fetched the root independently can say so" — pointing a reader at an
   > envelope member to carry an out-of-band fact, which is precisely the
   > self-corroboration §11.9 closed. A reader says so through the verifier's own
   > input (`--expect-root` / `expectedRoot` as an OPTION), never through the
   > document. The envelope member is checked for self-consistency against the
   > served anchor root and sets nothing; with no anchor served it is reported
   > and settles nothing (§10.5, §11.9 rule 6). Corrected 2026-07-28: both
   > package READMEs had already had to be fixed for teaching the same defect as
   > the method, and this sentence survived that pass.

5. **`notes` is non-normative free text** and MUST NOT affect any verdict. A
   verifier MUST NOT parse it.
6. **Unknown members MUST be ignored, HERE AND AT EVERY DEPTH** — on an entry,
   inside `commitments`, inside an opening, and inside `anchor.head`. The
   envelope is expected to grow, and a verifier that refuses an unrecognised key
   would make every future addition a breaking change.

   > **This rule was stated for the ENVELOPE only until 2026-07-28**, and the
   > four nested cases were left to be inferred. All four verifiers already
   > ignore them — measured on each of the four positions — so this states what
   > everyone does rather than changing anyone.
   >
   > **`commitments` is the consequential one and is worth saying out loud.** It
   > is the one place an unknown member sits inside an object whose siblings are
   > preimage terms, so "ignore it" is a real choice rather than an obvious one:
   > a future member that WAS meant to be hashed would be silently dropped by
   > every conforming verifier built to this clause. That is the correct
   > trade — the alternative makes every addition breaking — but it means a new
   > hashed term can only ever arrive with a `version` bump, which §6.3.4's
   > `LP(version)` exists to carry. Clean-room run 9, D11.

> ### The bundle carries NO payload, and `payloadRoot` is therefore unchecked
>
> Say this plainly, because the field's name promises otherwise. **Nothing in
> this envelope lets a verifier confirm that `payloadRoot` is the §6.4 root of
> any actual payload.** There is no `payload` member and no place to put one.
>
> What a chain verifier does check `payloadRoot` for, and it is worth being
> exact:
>
> 1. it is a **term of the §6.3.4 entry preimage**, so altering it breaks `hash`;
> 2. it MUST equal `payloadHash` when that is present (§6.3.4.1 point 3).
>
> Both are consistency checks **within** the entry. Neither binds the root to
> content. An operator who seals a root over one payload and later describes a
> different payload in a support ticket is not contradicted by anything in this
> document — the chain says only that _whatever was committed at seal time has
> not moved since_.
>
> Binding a root to content requires **§6.4.9 field proofs**, obtained
> separately. The corpus's composition vector proves the production sealer's
> roots and its §6.4 implementation agree; that is a statement about Sigil's
> code, not something a reader of one bundle can reproduce.
>
> **This envelope has no member for a §6.4.9 disclosure**, so today that binding
> is not reachable through the bundle path at all. That is a real gap and it is
> named here rather than left for an implementer to discover by looking for a
> check that does not exist. Adding an optional disclosure member is protocol
> work for a later revision, not an erratum.

7. `loadId` at the envelope level is the load the bundle **claims** to be for.
   It is not bound by any hash. **When no caller-supplied id is given, a
   verifier MUST use the envelope's `loadId` as the expected load id for
   §6.3.6**, which detects an envelope disagreeing with its own entries.

   > **This was a DESCRIPTION until 2026-07-28, and it was not even a list
   > item — it was glued to the end of the paragraph above, inside a
   > blockquote, while three sections cite it by number.** It read "Both
   > reference verifiers use it as the expected load id": an observation about
   > two programs, not an obligation on a third. Yet
   > `fixtures/bundles/wrong-load.json` requires `BROKEN` on a document whose
   > chain is INTERNALLY VALID — it verifies against its own entries' `loadId`
   > and fails only against the envelope's. A verifier built strictly from the
   > normative text returned `VERIFIED` and failed a corpus vector with nothing
   > in the text to explain why. Clean-room run 6 hit exactly that, and it was
   > the only finding that sent the implementer back to their own code. Same
   > family as the anchor-head defect run 5 exposed: the corpus encoding a rule
   > the normative text only described. (Run 5 D6 filed the formatting half.)

8. **A bundle proves a PREFIX of the chain, never the whole chain.** Nothing in
   this envelope lets a verifier confirm that `entries` is every entry sealed for
   this load. An operator that serves the first _n_ of _m_ entries produces a
   document that verifies perfectly: the hashes chain, the openings open, the
   anchor's inclusion holds, and with a caller-supplied id `loadIdCorroborated`
   is `true`. A reader MUST NOT read `VERIFIED` as "this is the complete
   history".

   `chain.entries` is the only signal, and it is a count with nothing to compare
   it against. Detecting truncation requires a length or head commitment the
   envelope does not carry, which is protocol work for a later revision rather
   than an erratum — the same call this section already makes for the §6.4.9
   field proof.

   > **Stated because all four verifiers report the strongest possible verdict
   > on a truncated chain, 2026-07-28.** Measured on the corpus's own
   > `bundles/clean.json` cut from two entries to one: `VERIFIED`,
   > `loadIdCorroborated: true`, `anchor.inclusionValid: true`. The empty chain
   > that clean-room run 6 filed is the limit case of the same gap, and this
   > document had exactly one occurrence of the word "truncate", about payload
   > leaves. An honesty box already existed for what a bundle cannot prove about
   > PAYLOADS; there was none for what it cannot prove about HISTORY.

> **That check does not detect a transplant, and a reader must not read it as
> one.** An attacker who moves a chain onto another load rewrites this field
> too, and the bundle then agrees with itself perfectly. §6.3.6's transplant
> defence is only real when the expected load id comes from **outside the
> bundle** — the id the reader asked for. A verifier SHOULD accept a
> caller-supplied load id and, when given one, MUST prefer it over the
> envelope's. Same shape as the uncorroborated root above: a value that
> arrived with the artifact cannot corroborate the artifact.
>
> **Both references now offer that seam.** `verifyBundle(doc, {
expectedLoadId })` and `verify_bundle(doc, expected_load_id)` accept it, and
> both CLIs take `--expect-load <id>`. It is OPTIONAL, because requiring it
> would break every existing invocation, so the report carries
> **`loadIdCorroborated`**: `false` means the chain was checked only against
> the envelope's own self-asserted id, and is therefore internally consistent
> and NOT proven to be the load you asked for. Same reporting contract as
> `anchor.rootCorroborated`, for the same reason — silence would let an
> internally-consistent document read as a corroborated one.
>
> Two fixtures pin both sides on ONE identical, internally-perfect document,
> whose chain was re-sealed onto a different load and whose envelope was
> rewritten to match: `bundles/transplant-envelope-rewritten.json` is
> **VERIFIED** without a caller-supplied id — the transplant is genuinely
> undetectable from the artifact alone — and
> `bundles/transplant-caught-by-expected-load.json` is **BROKEN** with one.

### 6.3.9 The chain verification report

§6.3.8 defines what a verifier READS. This defines what it EMITS. Three separate
rules in this document already make normative statements about this report's
contents — `loadIdCorroborated` (§6.3.8 point 7), `anchor.rootCorroborated`
(§10.5) and the `status` values — while no section said what the report is.
An implementer had three MUSTs about the fields of an object whose shape was
published nowhere.

Like §6.3.4.1 and §6.3.8 this is a **wire serialization, not a canonical form**.
Nothing here is hashed.

```json
{
  "status": "VERIFIED",
  "chain": { "entries": 6, "valid": true, "brokenAtIndex": null, "reason": null },
  "openings": {
    "checked": 2,
    "valid": true,
    "reason": null,
    "disclosed": [["actor", "attest", "geo"], ["geo"]]
  },
  "anchor": {
    "inclusionValid": true,
    "rootCorroborated": false,
    "headBoundToChain": null,
    "reason": null
  },
  "loadIdCorroborated": false
}
```

> **This skeleton violated two of the MUSTs below it until 2026-07-28**, and
> both are the same failure: a normative example maintained by hand beside rules
> that moved.
>
> It omitted **`headBoundToChain`**, which point 4 makes REQUIRED whenever an
> anchor is present and which both published packages emit — so the example was
> the only artifact in the program that did not carry it. And it printed
> `"checked": 6` beside a `disclosed` of length **one**, while point 5 requires
> the two to be equal on every branch. A reader building to the example rather
> than to the prose produced a report that fails the section it was copied from.
>
> The example now demonstrates rather than contradicts: `disclosed` is indexed by
> SUPPLIED OPENING and this bundle opened two positions out of six entries, which
> §6.3.7.2 point 4 permits; the first position disclosed all three fields in the
> order point 5 requires, and the second disclosed one. `headBoundToChain` is
> `null` because a served load bundle can never establish §10.5 condition (2).
> Clean-room run 11, D5, and one it did not report.

Normatively:

1. **`status` is `VERIFIED` or `BROKEN`.** There is no third value and no
   partial credit. A bundle is `VERIFIED` only when all of the following hold:
   1. the chain is valid;
   2. every supplied opening verifies;
   3. when an anchor was supplied, its inclusion holds;
   4. **when the caller supplied an out-of-band root, it equals the served
      anchor root; and when the ENVELOPE carries `expectedRoot`, that equals it
      too.**

   **Condition (4) is a failure, not a missing corroboration, and the difference
   is the whole of §10.5's purpose.** An `expectedRoot` that was never supplied
   leaves `rootCorroborated: false` and the bundle `VERIFIED` — the caller simply
   did not fetch the public feed, and the result is an inclusion claim rather
   than an external-timestamp claim. An `expectedRoot` that WAS supplied and
   DISAGREES is something else entirely: the operator served evidence that
   contradicts a root obtained independently of them. That is the
   operator-minted-root attack §10.0 exists to defeat, arriving in the open, and
   a verifier that reported it as merely uncorroborated would be reporting the
   attack as a formatting preference. `fixtures/bundles/wrong-expected-root.json`
   is the vector.

   > **Stated 2026-07-27, after clean-room run 3 reached 23/23 only by inferring
   > it from a fixture.** Both published verifiers already implemented condition
   > (4) — one of them says so in a source comment — and this list already
   > enumerated the `VERIFIED` conditions as though exhaustively, while §6.3.8
   > point 4 described `expectedRoot` purely as a corroboration signal and §10.5
   > is emphatic that failed corroboration produces a reported flag rather than a
   > failed verdict. So an implementer reading the normative text got three
   > sections agreeing that this could not be a failure, and a corpus fixture
   > requiring that it was. The contradiction was inside this document, not
   > missing from it.

2. **`chain` is always present.** `brokenAtIndex` and `reason` are `null` on a
   clean run and non-null together on a broken one.
3. **`openings` and `anchor` are `null` when the bundle carried none.** `null`
   means _absent_, never _failed_; a bundle with no anchor is not a bundle with
   a bad anchor, and a reader who conflates them downgrades a conformant
   document.

   **An `openings` section that was supplied but NOT EVALUATED is a fourth
   state, and it is neither of the above.** When the chain is `BROKEN`, nothing
   the openings would open is sealed: the commitments they would be checked
   against sit in an entry whose hash no longer binds them, so agreement with
   them establishes nothing and disagreement accuses nobody. A verifier MUST
   report `checked: 0`, `disclosed: []`, `valid: false`, and a `reason` saying
   the openings were not evaluated and why.

   `null` is wrong here because the bundle DID carry openings and point 3 makes
   `null` mean it did not. A bare `valid: false` with no reason is worse: it
   reads as a lying disclosure, which §6.3.7.1 calls the most damaging false
   verdict this protocol can emit, and it would be levelled at a discloser whose
   openings were never examined.

   > **There was no representation for this state until 2026-07-28**, and the
   > three obvious answers are all wrong in different directions. Unlike the
   > clarifications around it, this one does NOT describe what every
   > implementation does: **npm 2.0.0 reports `valid: true` with a full
   > `disclosed` list over a chain the same report calls `BROKEN`** — measured —
   > which is already an OPEN row in §11.0.1. The in-tree references emit the
   > state above, and a clean-room implementer reached the same shape
   > independently from the published text, having recorded that the text did
   > not settle it. Clean-room run 9, D6.

4. **`loadIdCorroborated` and `anchor.rootCorroborated` are REQUIRED when their
   section is present, and `false` is the honest default.** Both mean the same
   thing in different places: _this value came from outside the artifact_. They
   MUST NOT be omitted. Silence would let an internally-consistent document read
   as a corroborated one, which is the single failure mode §10.5 condition (4)
   and §6.3.8 point 7 exist to prevent.

   **"Corroborated" means COMPARED AND MATCHED, in both fields.** A caller-supplied
   value that was compared and DISAGREED sets the flag `false`, not `true`: that
   is refutation, and reporting refutation as corroboration inverts the one
   thing the field is for. A comparison that never happened — no value supplied,
   or nothing present to compare it against — is also `false`. So `false` carries
   two cases and `true` carries exactly one, which is the safe direction for a
   flag a reader uses to decide whether to trust a document.

   **The value MUST have come from OUTSIDE the artifact, and that is the
   governing half of the definition.** A comparison against the envelope's own
   `loadId` — which §6.3.8 point 7 requires a verifier to perform when no caller
   id was supplied — is the document agreeing with itself, and sets
   `loadIdCorroborated: false` no matter how well it matches.

   > **Two rules in this document gave opposite answers here, 2026-07-28.**
   > §6.3.8 point 7 makes the envelope `loadId` the expected id when no caller
   > id is given, so a comparison genuinely happens and genuinely matches, which
   > satisfies "COMPARED AND MATCHED" read literally. The paragraph above then
   > says both flags mean _this value came from outside the artifact_, which is
   > not satisfied at all. Both readings are defensible from the text and they
   > disagree, which is the state §11.0 clause 2 exists for.
   >
   > **Settled as `false`, and the reasoning is the same one §6.3.8 point 7 and
   > §11.9 already run on:** an attacker who transplants a chain rewrites the
   > envelope `loadId` in the same edit, so agreement with it establishes
   > nothing. The two references already behave this way, and a clean-room
   > implementer reading only the published text chose `false` independently —
   > so this states what everyone does rather than changing anyone. No sealed
   > artifact verifies differently; the flag moves on no input at all.
   > Clean-room run 9, D7.

   > **Both references reported `true` for a comparison that failed, and one of
   > them had a test asserting it, 2026-07-27.** `loadIdCorroborated` was set
   > from whether a caller supplied an id at all, so a transplant checked against
   > the id the reader actually asked for — the case the seam exists for, where
   > the answer is "no, this chain is for a different load" — reported the load
   > id as corroborated. On an empty chain it reported `true` for any id
   > whatsoever, having compared nothing. Meanwhile §11.9 had already settled
   > `rootCorroborated` the other way, computing it from a real equality. Two
   > adjacent fields sharing the suffix `Corroborated` and meaning different
   > things is not a distinction a reader can be expected to infer, and this
   > clause already said they meant the same thing.
   >
   > No already-sealed artifact verifies differently: the status was `BROKEN`
   > either way. This moves a flag without moving a verdict, exactly as §11.9
   > did.

   **`anchor.headBoundToChain` is also REQUIRED when an anchor is present**, and
   is `true`, `false` or `null` per §10.5. Unlike the two flags above its `null`
   is not a default-to-safe: it means the artifact cannot establish the fact
   either way, and a served load bundle can never establish it.

5. **`disclosed[i]` lists which of `actor`, `attest`, `geo` position `i` was
   EXAMINED for**, in that order, and MAY be empty. §6.3.7.2 makes an undisclosed
   opening not a failure, so the report has to say what was actually checked or a
   reader cannot tell a fully opened chain from an unopened one — both are
   `valid: true`.

   **The order is UTF-8 byte order over the three names** — the same rule §4 and
   §6.4.7 apply everywhere else — which is `actor`, `attest`, `geo`. Both
   published references already emit that; this clause used to LIST them in the
   §6.3.4 preimage order while requiring nothing, and a clean-room implementer
   who followed the order the sentence used produced a report that disagreed with
   both of them. Naming an order in prose while specifying none is how that
   happened.

   **An opening that was supplied and did NOT match IS listed**, and the walk
   **stops after it**: `disclosed[i]` ends at the first field that fails, and any
   field after it in the order is absent because it was never examined. `valid`
   and `reason` carry the verdict. So on an entry offering all three where
   `attest` lies, `disclosed[i]` is `["actor", "attest"]` — `geo` was offered and
   is truthful and is still absent.

   > **The word is EXAMINED, and it is the same word `checked` uses one level
   > up.** `checked` counts POSITIONS the verifier examined; `disclosed[i]` names
   > the FIELDS it examined at position `i`. Neither claims anything about work
   > it did not do.
   >
   > **Why the failing field is listed rather than dropped.** Under the other
   > reading — list only what verified — an opening that **LIED** and one that was
   > **never offered** become indistinguishable here, because both are simply
   > absent. §6.3.7.2 makes those carry OPPOSITE weight: an undisclosed opening
   > is explicitly not a failure, while a lying one is what §6.3.7.1 calls the
   > most damaging false verdict this protocol can emit. Listing it keeps the
   > lying field visible, as the LAST element, beside the `reason` that names it.
   >
   > The rationale above does not settle any of this on its own, and that is
   > worth stating rather than arguing around: it justifies `disclosed` by the
   > need to tell a fully opened chain from an unopened one **when both are
   > `valid: true`**, and on that branch every reading coincides. The whole
   > ambiguity lives on the failure branch it never mentions, which is why two
   > implementations could read this clause and disagree without either
   > misreading it. Clean-room run 11, D6.
   >
   > **This clause was first drafted as "reports what the opening OFFERED", which
   > is what NEITHER reference does** — it was written from the `lying-opening`
   > fixture, where the lying field happens to be `geo` and therefore last, so
   > offered and examined produce the same list. Constructing an entry whose
   > `attest` lies separated them. A MUST describing behaviour no implementation
   > has is the defect this document has recorded five times; it was caught here
   > by testing the general claim instead of the instance in hand.

   **`disclosed` is indexed by SUPPLIED OPENING, not by entry**, so on a run that
   examines the openings its length is the length of the `openings` array, which
   §6.3.7.2 point 4 allows to be shorter than `entries`. **`checked` is the
   number of openings positions the verifier ACTUALLY EXAMINED, and it MUST
   equal `disclosed.length` on every branch** — including positions carrying an
   explicit `null` hole, where a position was examined and nothing was verified.

   **On a branch that examines NO positions, both are `0`.** That covers an
   `openings` section refused as malformed, one longer than `entries`, and one
   not evaluated because the chain is broken. A non-zero `checked` beside an
   empty `disclosed` is not an overcount; it is a claim that positions were
   examined when none were.

   > **Three implementations gave three answers here, 2026-07-28.** On three
   > openings over two entries this section said `checked` was the openings array
   > length (3), npm 2.0.0 reported 2, and the corrected references reported 0 —
   > while `disclosed` was empty in all three. The definition was written for the
   > ordinary path and silently extended to refusal branches, where the array
   > length describes what was OFFERED rather than what was READ. Found by
   > clean-room run 6. The `checked === disclosed.length` invariant is stated as
   > a MUST precisely because it is the one form all three candidate answers
   > agree is checkable.

   > **Both were undefined until 2026-07-27, and `checked` is the one that
   > misleads.** A bundle with three entries and a two-element `openings` array
   > carrying one hole reports `checked: 2` having actually verified one opening.
   > "checked: 2, valid: true" therefore tells a reader more than was verified,
   > which is the exact failure §6.3.7.2 point 5 exists to prevent, reappearing
   > in the field written to prevent it. It is defined as observed rather than
   > redefined, because both published verifiers already behave this way and
   > changing the number would silently alter every report in circulation. A
   > reader who needs "how many openings actually verified" MUST count non-empty
   > entries of `disclosed`, not read `checked`. Found by clean-room run 3, which
   > had to pick between two defensible readings of point 5.

6. **A verifier MUST NOT throw on malformed content; it MUST report.** A crash
   is indistinguishable from a verdict to a shell, and the two references
   diverged on exactly this in 1.1.0, where an explicit `"anchor": null` threw in
   TypeScript and reported correctly in Python.
7. **A structurally malformed entry is `BROKEN`, with a `reason` naming the
   field.** It is NOT exit 2, and this is the one place the two ideas nearly
   collide: exit 2 means _I could not read your input at all_, while a bundle
   that parsed as JSON and carries a recognisable `entries` array HAS been read
   — it is simply not a valid chain. A verifier that reported exit 2 here would
   be saying it could not evaluate a document it evaluated well enough to reject.

   > **The reason string is load-bearing, not decoration.** `BROKEN` alone reads
   > as an accusation of tampering, and the commonest cause of a malformed entry
   > is a serialization mistake by an honest producer on their first attempt.
   > The reason is the only thing that tells those two apart, so "commitments is
   > missing or not nested under `commitments`" is a conformance-relevant output
   > and "invalid entry" is not.

> **Exit codes, for the CLIs.** `0` on `VERIFIED`, `1` on `BROKEN`, `2` on a
> usage or I/O error. The separation matters: `2` says _I could not evaluate
> this_, and collapsing it into `1` would report an unreadable file as tampered
> evidence.
>
> **THE LINE IS DRAWN AT PARSING, and this is now settled.** A file that cannot
> be read, and a byte sequence the JSON parser rejects, are exit `2` — no
> verdict is possible about a document nobody can decode. **Everything the
> parser accepted has been read**, and therefore gets a verdict: `null`, a bare
> string, a number, an array and an object with no `entries` are all exit `1`
> with a report naming what is wrong, exactly as point 7 requires.
>
> **Both CLIs used to pre-check for an `entries` array and exit `2`**, so the
> CLI and the library disagreed about what a malformed document is: the library
> produced `BROKEN` with a reason naming `entries` and the CLI refused to print
> it, leaving the caller a bare sentence on stderr. §11.0.1 carried this as an
> OPEN row marked _unresolved on purpose_, because picking a side is a normative
> decision and no one had made it. **Made 2026-07-28, in favour of this
> section's own text**, after two consecutive clean-room runs reported the
> contradiction. It is a **behaviour change to a published exit code** and
> therefore lands with the next MAJOR release, not as an erratum to shipped
> artifacts.

## 6.4 Payload root

### 6.4.0 Why a structured root

§6.3.4 binds `payloadRoot` and says only that it is "a structured per-field
Merkle root". That clause is enough to _check_ a Sigil record — `payloadRoot` is
an opaque 64-hex field inside the entry preimage — and not enough to _produce_
one. This section is the missing half.

v1 sealed the whole event payload as one digest over one particular JSON
rendering. That has two defects. It is opaque: proving a single field (the
agreed rate) to a factor means handing over the entire payload — carrier
margins, contact details, everything. And it is not reproducible outside the
producing language, because a whole-payload digest fixes the bytes to that
serializer's key order and number formatting.

`payloadRoot` replaces it with a Merkle root over a canonical, sorted set of
per-field leaves. A holder can disclose one field with a Merkle path and a
verifier confirms it against the sealed root without ever seeing the siblings.

### 6.4.1 Domains and primitives

```
LEAF_DOMAIN    := "sigil-payload-leaf:v1"
PAYLOAD_DOMAIN := "sigil-payload:v1"
```

`LP` is §6.3.1 length prefixing, unchanged. Every preimage in this section is a
character string; the digest is SHA-256 over its **UTF-8 bytes**, rendered as
**lowercase hex**. `LP`'s length is likewise the UTF-8 byte length.

`LEAF_DOMAIN` tags a leaf preimage and appears nowhere else. `PAYLOAD_DOMAIN`
appears in exactly one place, the empty-payload sentinel of §6.4.8.

#### 6.4.1.1 Every string MUST have a UTF-8 encoding

Because every preimage is hashed over UTF-8 bytes and `LP` counts UTF-8 bytes, a
string that **has no UTF-8 encoding has no canonical form**. Exactly one class of
string is in that position: one containing an **unpaired surrogate** — a code
point in `U+D800`–`U+DFFF` that is not part of a well-formed surrogate pair.

> **Any object key or string value containing an unpaired surrogate MUST be
> REFUSED. It MUST NOT be substituted, replaced, dropped, or repaired in any
> way.** This applies at every depth, to keys as well as values, and it applies
> whether the string arrived from a JSON document or from a host-language caller.
> A well-formed surrogate pair is an ordinary astral code point and MUST be
> accepted; `U+FFFD` is an ordinary code point and MUST be accepted.

**This is reachable input, not a theoretical concern.** RFC 8259 §7 notes that
the grammar admits string values that cannot encode Unicode characters, an
unpaired surrogate being its own example, and both reference languages' parsers
accept one: `JSON.parse('"\\ud800"')` and `json.loads('"\\ud800"')` both succeed.
What happens next is where implementations silently part company:

| language | `"\ud800"` → UTF-8       | result                                        |
| -------- | ------------------------ | --------------------------------------------- |
| Python   | `str.encode('utf-8')`    | raises `UnicodeEncodeError`                   |
| Node.js  | `Buffer.from(s, 'utf8')` | `ef bf bd` — **U+FFFD substituted, silently** |

**Substitution is not a cosmetic difference.** It breaks three separate
guarantees this section makes, and it was doing all three before this rule was
written down:

1. **Leaf collision.** `{"a":"\ud800"}` and `{"a":"�"}` commit the **same**
   root. A holder with a genuine §6.4.9 proof for the first can disclose the
   second, and verification returns **true** — a value that was never sealed,
   proven against a real root, in the one feature §6.3.4 advertises. The
   membership check of §6.4.9 opens the same way.
2. **Order dependence.** Two distinct keys encode to one path. §6.4.7's sort then
   ties, the fold order becomes a function of key insertion order, and the
   promise that the root is "independent of key insertion order" fails. The two
   orderings of `{"\ud800":1,"�":2}` produce different roots.
3. **Cross-language disagreement.** One implementation returns a root where the
   other raises, for byte-identical input.

Refusing is the only fail-closed answer. Substituting invents a value the
producer never sent; dropping the code unit commits a shorter string than the one
supplied; and either choice is a commitment to something nobody signed. There is
no in-band rescue either: the "commit it as its string form" fallback of §6.4.10
does not help, because the string form of an unencodable string is the same
unencodable string. A producer that must bind such a value has to carry a
representation that itself has UTF-8 bytes — the `\uXXXX` escape spelled out as
ordinary characters — and that is a producer decision, not a repair this section
performs.

`fixtures/payload-root/unpaired-surrogates.json` carries the vectors, including
the forgery reproduced end to end and the two accepted controls (`U+FFFD` and a
well-formed astral pair) that catch the over-correction.

Interior nodes of the payload tree are folded by the **same node function §10.2
defines** for anchor checkpoints:

```
node(l, r) := SHA256( "sigil-anchor-node:v1" || "|" || l || "|" || r )
```

The node function is shared; the **leaves are not**. A payload leaf preimage
begins `LP(LEAF_DOMAIN)` and an anchor leaf preimage begins
`"sigil-anchor-leaf:v1|"`, so no preimage is valid as both and a payload leaf
can never be presented as an anchor leaf or the reverse. Interior nodes are, by
construction, computed identically in the two trees; separation rests entirely
on the leaf domains.

> **Why this is worth stating rather than leaving implied.** The node function
> here is pipe-delimited, not length-prefixed, unlike everything else in §6.3.
> An implementer who assumes §6.3.1 applies uniformly will fold the tree wrong
> for every payload with more than one leaf, and will still reproduce the
> single-leaf roots correctly — the worst kind of partial agreement.

### 6.4.2 Number encoding (normative, and the highest-risk rule here)

#### 6.4.2.1 The numeric domain is IEEE-754 binary64

Every payload number MUST be interpreted as an **IEEE-754 binary64** value. A
JSON number token is parsed to the nearest binary64 (ties to even) and the
canonical form is a function of **that** value, never of the source token.

This is normative, not an implementation note, and it is the assumption the rest
of §6.4.2 rests on. Under any other reading — parsing into `BigDecimal`,
`decimal.Decimal`, an arbitrary-precision rational — "shortest round-trip"
is meaningless (there is nothing to round-trip to), `1e400` is a finite value
rather than a step-1 rejection, and `0.1` is exact rather than
`0.1000000000000000055511151231257827…`. Such an implementation will disagree
with every root Sigil has sealed while passing its own tests.

Consequences an implementer MUST accept:

- A token with more precision than binary64 carries is **narrowed on parse**, and
  the canonical form commits the narrowed value. `1.0000000000000000001` commits
  as `1`.
- `1e400` and `-1e400` parse to infinity and are rejected at step 1 of §6.4.2.2.
- Two distinct tokens that parse to the same binary64 commit the **same** bytes.
  That is intended: the commitment is over the value, not the spelling.

#### 6.4.2.2 The encoder

```
encodeNumber(n):    # n is an IEEE-754 binary64
  1. if n is not finite (NaN, +Inf, -Inf)  -> REJECT
  2. if n is negative zero                 -> "0"
  3. if n is integral:
       a. if |n| > 2^53 - 1                -> REJECT
       b. else                             -> INTEGER FORM (6.4.2.3)
  4. if |n| < 1e-4                         -> REJECT
  5. otherwise                             -> FRACTION FORM (6.4.2.3)
  6. if the result contains "e" or "E"     -> REJECT
```

**The step order is normative.** Integrality is tested at step 3, _before_ the
magnitude band at step 4. `0` is integral, so `0` is **accepted** even though
`|0| < 1e-4`. An implementation that tests the band first rejects every zero in
every payload.

Step 6 is unreachable for any binary64 in the accepted band (§6.4.2.4 says why).
It MUST still be implemented, as a hard stop against a future band change
silently emitting an exponent form.

#### 6.4.2.3 The rendering rules

Both forms produce a string over `[0-9]`, `.` and a leading `-`, and nothing
else. Stated exhaustively, because "shortest round-trip" alone does not fix a
byte string:

**INTEGER FORM** — the exact mathematical value in base ten:

- optional leading `-`, then one or more decimal digits;
- no leading zeros (`0` is the single digit `0`, never `00` or `-0`);
- no decimal point, no fractional part, no exponent, no `+`, no thousands
  separator.

**FRACTION FORM** — the **shortest decimal digit string that round-trips**: of
all decimal strings that parse back to exactly this binary64, the one with the
fewest significant digits, and among equally short candidates the one nearest
the true value, ties resolved **to even**. This is the Steele–White / Ryū /
Grisu-exact output, not a fixed-precision `printf`. In addition:

- **fixed (positional) notation only** — no exponent, no `e`/`E`, no `+`;
- **at least one integer digit** — `0.5`, never `.5`; `-0.5`, never `-.5`;
- no leading zeros in the integer part beyond a single `0`;
- **no trailing zeros** in the fraction (`1.5`, never `1.50`) and no trailing
  bare `.`;
- the decimal separator is **`.`**, always, regardless of host locale.

> **The locale clause is not pedantry.** .NET's and Java's default number
> formatting are culture-sensitive. Under `de-DE`, a default `ToString()` on
> `30.2672` yields `"30,2672"` — a payload root that differs from every other
> implementation's, produced by correct-looking code, on a machine whose only
> sin is its regional settings. Format with the invariant culture explicitly.

#### 6.4.2.4 The band, and why it is where it is

**Accepted:** integral values in `[-(2^53 - 1), 2^53 - 1]`; non-integral values
of magnitude `>= 1e-4`. `1e-4` itself is accepted — the comparison at step 4 is
strict.

**The invariant that makes this work, stated so it is not lost:**

> Every accepted non-zero magnitude lies in **`[1e-4, 1e16)`**, and that interval
> is precisely the window in which the shortest-round-trip renderers of the
> reference languages agree to emit **fixed** rather than exponential notation.
> The floor is enforced explicitly at step 4. The ceiling is enforced
> _implicitly_ by step 3a: every binary64 of magnitude `>= 2^52` is integral, so
> a non-integral value can never reach `1e16`, and the safe-integer cap
> (`2^53 - 1 ≈ 9.007e15`) keeps integral values below it too.

That ceiling is load-bearing and invisible, which is why it is written down.
Python's `repr` switches to exponential at `1e16` and below `1e-4`; JavaScript's
`String` switches at `1e21` and below `1e-6`. The intersection of the two fixed
windows is exactly `[1e-4, 1e16)`. **A future editor who lowers the floor to
`1e-6` "because JavaScript is fine there" moves the band outside Python's fixed
window and silently breaks cross-language agreement for every value in
`[1e-6, 1e-4)`.** Raising the integer cap has the same effect at the top.

Verified independently, not assumed: fuzzing 420,014 binary64 values across
`1e-20 … 1e19` plus the boundary sweep yields 215,454 accepted values, of which
**zero** render with an exponent and **zero** disagree between JavaScript
`String` and the Python recipe below. Observed magnitudes span
`[1e-4, 9.0072e15]`.

**What is rejected, and why rejection rather than a fallback.** Outside the band
the languages diverge, and not only in cosmetic ways:

| value  | JavaScript `String(n)` | Python `repr(n)` | divergence                      |
| ------ | ---------------------- | ---------------- | ------------------------------- |
| `1e-5` | `"0.00001"`            | `"1e-05"`        | **form** — fixed vs exponential |
| `1e-7` | `"1e-7"`               | `"1e-07"`        | exponent padding                |
| `1e16` | `"10000000000000000"`  | `"1e+16"`        | **form**                        |

The `1e-5` row is the one to keep in mind: it is a single decade below the
floor, and the two languages do not merely pad an exponent differently — one
emits positional notation and the other scientific. There is no repair that
preserves both.

An implementation of this canonical form MUST refuse such a value rather than
commit a digest a conforming implementation in another language would call
BROKEN. A producer that needs the value bound anyway MUST convert it to a
**string** before sealing — §6.4.10 records the conversion Sigil's own producer
applies, so the refusal is never reached on the seal path. Money SHOULD stay
integer cents, as the loads domain already does.

#### 6.4.2.5 Per-language recipes

Non-normative. §6.4.2.3 is the rule; these are starting points. The **integral**
and **non-integral** branches are separate, which is where a language most often
gets it half right.

> **"Non-normative" here means "not the thing you are judged against", NOT "not
> to be relied on".** Conformance is defined by the **bytes you produce**, never
> by which function you called to produce them: two implementations calling
> different functions and emitting identical bytes are equally conformant, and
> one calling the recipe below while emitting different bytes is not conformant
> at all. Making a language binding normative would be the mistake — it would
> freeze this document against every future runtime whose formatter changes, and
> Java's did change in JDK 19.
>
> So the table is a shortcut, and **§6.4.11's vectors are the acceptance test.**
> If your output matches them you are conformant however you got there; if it
> does not, the recipe is irrelevant. An implementer reading "non-normative" and
> concluding the only concrete guidance in this section could not be trusted was
> reading it exactly backwards, which is a failure of this paragraph's absence
> rather than of their reading.

The Python and JavaScript rows are verified by the fuzz described in §6.4.2.4.
The Go and Rust rows are derived from those languages' documented formatting
semantics and have **not** been fuzz-verified here — treat them as candidates to
check against §6.4.11's vectors, not as guarantees.

| language   | integral branch (step 3b)         | non-integral branch (step 5)          |
| ---------- | --------------------------------- | ------------------------------------- |
| Python     | `str(int(n))`                     | `repr(n)`                             |
| JavaScript | `String(n)`                       | `String(n)`                           |
| Go         | `strconv.FormatInt(int64(n), 10)` | `strconv.FormatFloat(n, 'f', -1, 64)` |
| Rust       | `format!("{}", n as i64)`         | `format!("{}", n)`                    |

Python's `repr` on an **integral** float gives `"9007199254740991.0"` — the
trailing `.0` is wrong here, which is why the integral branch converts to `int`
first. Go's `'f'` verb with precision `-1` is the correct non-integral recipe
because it is shortest-round-trip _and_ never emits an exponent; `'g'` would.

**Explicitly NOT safe:**

- **Java `Double.toString`** — switches to scientific notation outside
  `[1e-3, 1e7)`, so `0.0001` renders `"1.0E-4"` and `12345678.5` renders
  `"1.23456785E7"`. Both are inside our accepted band. It also emitted more
  than the shortest digits before JDK 19. An implementer who reaches for it
  passes every integer fixture and silently diverges on `30.2672` and `0.0001`.
  Use `BigDecimal.valueOf(n).stripTrailingZeros().toPlainString()` and verify
  against §6.4.11's vectors.
- **.NET default `ToString()`** — culture-sensitive (see §6.4.2.3) and switches
  to `E` notation at the extremes. Use `n.ToString("R", CultureInfo.InvariantCulture)`
  and re-render if it produces an exponent.
- **Any fixed-precision formatter** — `printf("%g")`, `printf("%.17g")`,
  `String.format("%f")`. `%.17g` is round-trip-safe but not _shortest_, so it
  emits `30.267199999999999` for a value that must encode `30.2672`.

Whatever recipe an implementation picks, §6.4.11's vectors are the arbiter.

**Negative zero.** In JavaScript this normalization is redundant
(`String(-0) === "0"`). It is normative because it is **not** redundant
elsewhere: Python's `repr(-0.0)` is `"-0.0"`, so a Python implementation that
skips step 2 commits different bytes for the same value.

**Integral floats.** JSON does not distinguish `2` from `2.0`. A language that
parses `2.0` into a float MUST still encode it `"2"` — step 3 tests the
mathematical value, not the source token or the host type.

### 6.4.3 Path encoding

A leaf's path is the sequence of steps from the payload root.

```
step(s)  := LP("k" || key)      when s is an object key
         := LP("i" || decimal)  when s is an array index
encodePath(path) := step(path[0]) || step(path[1]) || ...
```

The encoded path of the root (an empty path) is the empty string. Array indices
render as plain decimal digits with no padding.

The `k`/`i` discriminator is what stops an array being confused with an object
carrying numeric-string keys: `{"a":[7]}` and `{"a":{"0":7}}` commit
differently. Each step is length-prefixed and the steps are concatenated, so
`{"a.b":1}` and `{"a":{"b":1}}` also commit differently.

**`encodePath` is the only path form that is hashed or sorted.** Reference
implementations also carry a human-readable path renderer (dotted segments,
`<root>` for the empty path) used solely in error messages. That form is
**not** normative and MUST NOT appear in any preimage.

### 6.4.4 Terminal values and type tags

A **terminal** is a value that gets its own leaf. Exactly six kinds exist:

| value                    | `type` | encoded value         |
| ------------------------ | ------ | --------------------- |
| `null`                   | `null` | `""`                  |
| string                   | `str`  | the string, unchanged |
| number                   | `num`  | `encodeNumber(n)`     |
| boolean                  | `bool` | `"true"` / `"false"`  |
| array with zero elements | `arr0` | `""`                  |
| object with zero keys    | `obj0` | `""`                  |

Everything else MUST be **rejected** where a terminal is expected: a non-empty
array, a non-empty object, and any value that is not one of the six kinds above.

**"Plain object", defined structurally.** A plain object is **a string-keyed
mapping as produced by the JSON object production, and nothing else**. Its keys
are strings and its values are `JsonValue`s; it carries no class identity, no
custom serializer, and no behaviour. A date, a map, a set, a byte buffer, a
decimal, or any class instance is **not** a plain object and MUST be rejected
rather than silently committed as `obj0` — producers serialize such values to a
string or an object explicitly before sealing (§6.4.10). An implementation in a
language whose object model admits richer values MUST make this test structural;
a host-specific proxy for it (a prototype check, an `instanceof` chain) is an
implementation detail of that language, not the rule.

**Strings are committed verbatim.** They are **not** Unicode-normalized. This
differs from v1.0 §4, which specifies NFC for the `canonicalHash` payload form.
A payload carrying `caf` + `U+00E9` (NFC) and one carrying `caf` + `U+0065 U+0301` (NFD) are different
payloads under §6.4 and commit different roots — written as code points because
the two render identically. Keys are equally unnormalized, since `encodePath`
carries them verbatim too. Implementations MUST NOT normalize, in either
direction, or they will disagree with every root Sigil has sealed.

Verbatim is not unconditional. A string with **no UTF-8 encoding at all** — one
containing an unpaired surrogate — is REFUSED rather than committed, because
committing it means substituting bytes it does not have. See §6.4.1.1; that rule
governs keys as well as values.

> **The Compliance Test Suite teaches the opposite rule, and its scope is not
> obvious.** `fixtures/canonical/pairs/nfc-strings` asserts that two differently
> normalized strings hash EQUAL. That fixture is correct and it governs the v1.0
> §4 `canonicalHash` form ONLY. It does not govern §6.4, where the same two
> strings hash DIFFERENTLY and must. An implementer who reads the fixture set as
> one uniform rule about strings will carry NFC into the payload root and produce
> a wrong root for every decomposed string, while passing the canonical-form half
> of the suite. The two forms coexist deliberately; nothing before this paragraph
> labelled the boundary.

**The type tag is load-bearing.** `1` / `"1"` / `true` / `"true"` / `null` /
`"null"` / `""` / `{}` / `[]` all share a string form or an empty encoded value.
The tag is the only thing keeping their leaves apart.

### 6.4.5 Leaf hash

```
leafHash(path, value):
  (type, encoded) := describe the terminal per §6.4.4
  return SHA256( LP(LEAF_DOMAIN)
              || LP(encodePath(path))
              || LP(type)
              || LP(encoded) )
```

Both the path and the value are length-prefixed, so a field-name/field-value
boundary cannot be forged: `{"ab":"c"}` and `{"a":"bc"}` cannot collide.

Worked example — `{"stops":[{"city":"Chicago"},{"city":"Laredo"}]}`, the leaf at
`["stops", 1, "city"]`:

```
encodePath = "6:kstops" || "2:i1" || "5:kcity" = "6:kstops2:i15:kcity"
preimage   = "21:sigil-payload-leaf:v1" || "19:6:kstops2:i15:kcity" || "3:str" || "6:Laredo"
```

Note the double prefixing of the path: the steps carry their own lengths inside
the encoded path, and the encoded path as a whole carries one more.

### 6.4.6 Traversal and leaf collection

A payload MUST be a plain object (§6.4.4) at the top level. Anything else — an
array, a scalar, `null` — MUST be rejected.

**"Absent", defined.** The walk below turns on this word, so it is defined here
rather than left to the reader. A value is **absent** when the key is not
present in the mapping at all, or when the host language represents "no value"
with a distinct token that is not `null` (JavaScript's `undefined`, Python's
absence from a `dict`). **Absent is not `null`.** JSON has no absent-value
token, so for a JSON-in implementation the rule reduces to: _a key that is not
in the object is absent, and every key that is in the object — including one
whose value is `null` — is present_. The `undefined`-shaped rules exist because
Sigil's producer runs in a language that has one; they are vacuous over pure
JSON and an implementation need not model them. They are stated because getting
`null` and absent backwards changes every root that has an optional field
(§6.4.6's absent-key rule).

Collect leaves by walking the payload:

```
collect(value, path, out):
  if length(path) > MAX_PAYLOAD_DEPTH: REJECT       # checked on DESCENT

  if value is an array AND its length > 0:
      for index in [0, length):
          element := value[index]
          collect(element, path || [index], out)      # an absent element is null
      return

  if value is a plain object:
      keys := the object's own keys, minus any whose value is absent
      if keys is non-empty:
          for key in keys:
              collect(value[key], path || [key], out)
          return
      value := {}                                     # fall through as obj0

  if out already holds MAX_PAYLOAD_LEAVES leaves: REJECT
  out.append( leaf(path, value) )                     # per §6.4.4 / §6.4.5

payloadLeaves(payload):
  REJECT unless payload is a plain object
  out := []
  for key in payload's own keys, skipping any whose value is absent:
      collect(payload[key], [key], out)
  sort out per §6.4.7
```

Traversal order does **not** affect the root — §6.4.7's sort erases it. It is
written out only so an implementation can be compared step for step.

**Arrays are walked by index over `[0, length)`, never by an iterator that skips
absent elements.** An absent element (a hole in a sparse array) commits as
`null`, exactly as JSON serialization renders it. Because every index in
`[0, length)` therefore gets a leaf, the array's length is committed by the
dense index set itself: two arrays of different length at the same path always
produce different leaf sets, hence different roots.

**Empty containers get a marker leaf.** `{}`, `{"a":{}}` and `{"a":[]}` are
three distinct commitments. A **nested** object whose every key was dropped as
absent commits `obj0` at its own path — not nothing — because emitting no leaf
would collide it with the empty payload. The **top-level** payload object is not
itself a value in the walk: a payload with no surviving keys yields zero leaves
and the §6.4.8 empty sentinel, never an `obj0` leaf.

**The absent-key rule (load-bearing; relied on in production on 2026-07-26).**

> An optional field that is **absent** contributes **no leaf**. Adding an
> optional field to a payload schema therefore leaves every already-sealed
> row's `payloadRoot` **byte-identical**, and no backfill is needed — which
> matters because a backfill of a sealed row is indistinguishable from the
> rewrite the chain exists to detect.
>
> An **explicit `null` is not absent**: it commits a `null` leaf and changes the
> root. Producers MUST NOT write an explicit null in place of an absent optional
> field, and MUST NOT drop an explicit null a caller supplied.
>
> Sigil relies on this: `RATE_CON_AGREED` gained an optional `rateProvenance`
> after rows had already sealed without it. Absent means no leaf, so every
> pre-existing root still verifies.

**Leaf cap.** `MAX_PAYLOAD_LEAVES` is **8192**. A producer MUST **refuse** a
payload that would exceed it rather than truncate: truncating drops fields out
of the commitment, which is the one failure mode worse than refusing to seal.
The cap is counted per leaf collected — not against any single array's declared
length — so it bounds the leaf count however the leaves are spread across nested
arrays. An implementation that guards one array's `length` instead passes every
flat vector and accepts 8281 leaves spread over 91 arrays of 91; that shape is a
fixture. A payload with exactly 8192 leaves is accepted.

The leaf cap bounds the leaf count and **not** the depth of the walk — see the
depth cap below, which is the other half.

> **Why a cap at all.** The index walk costs one leaf hash per index. A single
> number setting an array's length to 10^6 buys work the producer must do while
> costing the caller nothing to send — the shape of a seal-path denial of
> service. 8192 is far above any real load event; the rate-con payload
> production seals has three leaves.

**Depth cap.** `MAX_PAYLOAD_DEPTH` is **64**. A payload MUST be **refused** if
any leaf's path would carry more than 64 steps. The check is made on **descent**,
before recursing, not at the leaf.

> **Why the leaf cap does not already cover this, stated because the leaf cap's
> own rationale reads as though it does.** The anti-DoS argument above is about
> the WALK, and the leaf cap does not bound the walk — it bounds the OUTPUT of
> the walk. A payload nested 100,000 levels deep has **exactly one leaf**, so
> `MAX_PAYLOAD_LEAVES` never fires while the recursion runs 100,000 frames deep.
> The two caps are not redundant; the leaf cap was doing half the job its own
> paragraph claimed.
>
> **The interoperability half matters more than the DoS half.** Recursion depth
> is where host languages differ most sharply and most silently. Measured on the
> two references: the TypeScript walk survives to roughly 9,000 levels, and
> CPython raises `RecursionError` near 950 under its default limit. Every payload
> in that window **seals in one language and is refused by the other** — not a
> different root, an actual disagreement about whether a root exists — and the
> spec authorized neither answer. A conforming implementation must not have its
> verdict decided by its interpreter's stack, so the cap is stated in the form
> and set far below the shallower ceiling.
>
> There is a second-order hazard worth naming, because it costs more than the
> failure it hides: CPython's `RecursionError` is **not** a `TypeError`, a
> `ValueError` or an `OverflowError`, so a test harness catching those three
> lets it escape, and a harness that dies takes every cross-language check in the
> run with it. The compliance suite's Python harness now catches it explicitly as
> a backstop, with the depth cap firing first.
>
> 64 is far above any payload Sigil seals — the rate-con shape the fixtures
> record is FLAT, every leaf path one step — and far below either language's
> ceiling, so the cap, not the host, is what decides. `fixtures/payload-root/limits.json` pins both sides of the
> boundary: a 64-step chain is accepted, a 65-step chain is refused.

### 6.4.7 Leaf order

Leaves are sorted by their **encoded path** (§6.4.3), compared as **UTF-8
bytes**: unsigned byte-wise lexicographic comparison, with a proper prefix
sorting before the longer string. Distinct paths always produce distinct
encoded paths, so no two leaves tie and the order is total.

Sorting is what makes the root independent of key insertion order. Two
implementations that disagree about the order produce different roots for
identical payloads, so this rule MUST be read literally. Three traps:

1. **The decimal length prefix is part of the sort key.** `z` encodes to `2:kz`
   and `aaaaaaa` to `8:kaaaaaaa`, so the encoded order puts `z` **first** while
   a raw-key sort puts `aaaaaaa` first. The two rules genuinely disagree.
   Sorting raw keys yields a different leaf order and a different root.
2. **UTF-8 byte order is code-point order, which is not UTF-16 code-unit
   order.** Above the Basic Multilingual Plane the two disagree: a lead
   surrogate sorts below `U+FFFD` in UTF-16, while `U+10000` sorts above
   `U+FFFD` by code point. A default string comparison in JavaScript, Java, or
   C# gives the UTF-16 order and is therefore **wrong** here.
3. **Array indices sort in NUMERIC order here, and the length prefix is why.**
   A step encodes as `LP("i" || decimal)`, so index 2 is `2:i2` and index 10 is
   `3:i10`: the comparison decides on `'2' < '3'` before it ever reaches the
   digits. The digit-count prefix groups indices by width and puts every shorter
   index ahead of every longer one, and within a group the decimals are equal
   width, so a plain byte comparison **is** numeric comparison. The full
   sequence sorts

   ```
   2:i0  2:i1  2:i2  2:i9  3:i10  3:i11  3:i99  4:i100  4:i101
   ```

   The order that puts `i10` before `i2` is what a **raw-key** sort gives — trap
   1, applied to indices. An implementation "corrected" to it produces a wrong
   root for every array with more than ten elements.

   The property holds because the length prefix is a **single character**, which
   is true for encoded step lengths 2 through 9, i.e. indices up to
   `99999999`. At `100000000` the prefix becomes `10:` and `'1' < '9'` would put
   it ahead of an eight-digit index — and `MAX_PAYLOAD_LEAVES` (§6.4.6) makes
   that unreachable, because the largest index any conforming payload can carry
   is 8191, four digits. Within the accepted domain there is no exception.

> **This trap said the opposite until 2026-07-26, and the fixtures always
> disagreed with it.** The text claimed indices "sort as text, not as numbers"
> and that `i10` sorts before `i2`; `sort-order.json` has recorded the numeric
> fold order since the day it was generated, because it was generated by running
> the code rather than by typing what the prose said. Prose that nobody executed
> is how a normative document becomes the source of the bug — an implementer who
> trusted the warning over the vectors would have broken every multi-element
> array. Errata §0 files it as a prose correction: the normative rule (sort the
> encoded path as UTF-8 bytes) never changed.

### 6.4.8 The fold

```
PAYLOAD_EMPTY_ROOT := SHA256( LP(PAYLOAD_DOMAIN) || LP("empty") )
                    = SHA256("16:sigil-payload:v15:empty")
                    = d4391ac29c2663aa4f8710030aaf365c4824948b71dc376586d09b639db539b4

payloadRoot(leaves):
  if leaves is empty:  return PAYLOAD_EMPTY_ROOT
  level := [ leaf.hash for leaf in leaves ]        # in §6.4.7 order
  while level has more than one element:
      next := []
      for i in 0, 2, 4, ... over level:
          l := level[i]
          r := level[i+1] if it exists, else l     # odd node out is duplicated
          next.append( node(l, r) )
      level := next
  return level[0]
```

- **Empty payload.** An empty payload commits `PAYLOAD_EMPTY_ROOT`, its **own**
  sentinel — deliberately not §10.2's empty-checkpoint sentinel, so an empty
  payload root and an empty anchor checkpoint root are never the same value.
- **Single leaf.** The loop does not run, so the root **is** that leaf's hash.
  An implementation MUST NOT apply a node hash to a lone leaf, and MUST NOT
  treat the resulting empty inclusion proof as malformed.
- **Odd node.** A level with an odd count duplicates its last node and folds it
  with itself. The node preimage is the plain concatenation shown in §6.4.1 —
  pipe-delimited, over the lowercase hex strings, **not** length-prefixed, and
  not over raw digest bytes.

> **On duplicate-odd-node malleability.** The classic attack on this fold shape
> needs two distinct leaf sequences to fold to one root, which needs a repeated
> leaf. Payload leaf sets are duplicate-free by construction — distinct paths
> give distinct leaf digests, and §6.4.7 guarantees distinct paths — so a longer
> leaf set cannot be collapsed onto a shorter one here.

### 6.4.9 Selective disclosure

A **field proof** is a disclosure plus the authentication path binding it to a
root:

```
proof := {
  disclosure: { path: [step, ...], value: <terminal> },
  proof:      [ { sibling: hex(64), siblingIsRight: boolean }, ... ]
}
```

#### 6.4.9.1 How a path is carried on the wire

§6.4.3 encodes a step as `k`-prefixed or `i`-prefixed because the walker knows
whether it is inside an object or an array. On the wire that knowledge is gone,
so the distinction MUST be carried by the JSON type of the step itself:

- a **JSON string** step is an **object key** → encodes `LP("k" || step)`;
- a **JSON number** step is an **array index** → encodes `LP("i" || step)`.

`["a", 0]` and `["a", "0"]` are therefore **different paths** naming different
leaves — the first an element of an array, the second a numeric-looking key on
an object. An implementation that coerces a step's type on deserialization (a
JSON library that turns object keys into integers when they look numeric, or a
path parser that splits a dotted string) reintroduces exactly the array/object
confusion the `k`/`i` tags exist to prevent, at the one place the path crosses a
trust boundary. Comparison of two paths MUST be type-aware: `0` never equals
`"0"`.

The human-readable dotted rendering mentioned in §6.4.3 **cannot** represent this
distinction and MUST NOT be used as a wire form.

**What a number step is allowed to be.** "A JSON number is an array index" fixes
the step's type and says nothing about its value, and `decimal` in §6.4.3 is
undefined for `0.5`, `-1`, `1e21` or `NaN`. Stated normatively:

> An array-index step MUST be a **non-negative integral binary64 no greater than
> `2^53 - 1`**. Anything else — negative, non-integral, non-finite, or beyond the
> safe-integer range — names no leaf and MUST be **refused**. The value is
> interpreted under §6.4.2.1's binary64 domain, so `1` and `1.0` are the **same**
> index and MUST behave identically; a language whose JSON parser distinguishes
> `int` from `float` MUST NOT let that distinction reach the path. `decimal` in
> §6.4.3 is that integer rendered in base ten with no padding and no sign, i.e.
> INTEGER FORM (§6.4.2.3) restricted to non-negative values.

> **Why this is normative rather than obvious.** Both reference implementations
> independently invented exactly this rule, which is good evidence it is the
> right one and no evidence at all that a third implementation would arrive at
> it — the text authorized nothing, at the one place this section says the path
> crosses a trust boundary. And "both implementations agreed" was not even true
> in detail: one of them encoded `1.0` as index `1` in the path while a second
> function in the same file tested the host type and refused it, so
> `["a", 1.0]` produced a valid proof in one language and an error in the other
> for byte-identical JSON. Two functions independently deciding the same fact
> drift; the rule belongs here, once.

#### 6.4.9.2 Producing a proof

Compute the canonical sorted leaves (§6.4.6, §6.4.7) and find the index of the
leaf whose path equals the requested path. A path naming no terminal leaf MUST
be refused — an interior container is not discloseable as a unit; disclose its
leaves.

The authentication path is **not** "the standard Merkle path": it depends on the
odd-node rule of §6.4.8, which the fold defines but which also governs the proof.
Written out in full:

```
authPath(leafHashes, index):
  proof := []
  idx   := index
  level := leafHashes                              # in section 6.4.7 order
  while level has more than one element:
      isRightNode := (idx is odd)
      siblingIdx  := isRightNode ? idx - 1 : idx + 1
      if siblingIdx < length(level):
          sibling := level[siblingIdx]
      else:
          sibling := level[idx]                    # THE ODD NODE IS ITS OWN SIBLING
      proof.append({ sibling, siblingIsRight: NOT isRightNode })
      level := fold one level per section 6.4.8
      idx   := floor(idx / 2)
  return proof
```

**The self-sibling case, stated plainly.** When `idx` is even and there is no
`idx + 1` — that is, the node is the odd one out at its level — its sibling is
**itself**, and `siblingIsRight` is **`true`**. Verification then computes
`node(acc, acc)`, which is exactly what the fold did. Both halves of the pair
are the same digest, so the flag's value is not observable at that step alone;
it is fixed here so two implementations serialize the identical proof.

> **Why this needs writing down rather than "standard".** The self-sibling only
> occurs at a level with an odd count above one. Leaf counts 4 and 8 never
> produce one; 6 produces one at a single interior level; **5 and 9 produce one
> at more than one depth**. An implementer who tests with 2, 4, or 8 fields
> ships a producer that passes everything they wrote and is wrong for most real
> payloads — and the failure is invisible on their side, because their own
> verifier makes the same mistake. §6.4.11's `merkle-shapes.json` covers leaf
> counts 1 through 9 and proves every leaf of each.

The proof's length is the number of fold levels, which is `ceil(log2(n))` for
`n` leaves, and **0** for a single-leaf payload (§6.4.8).

To **verify** one against a sealed `payloadRoot`:

1. **Recompute** the leaf from `(path, value)` per §6.4.5. A verifier MUST NOT
   accept a caller-supplied digest as a leaf.
2. Fold upward: `acc := node(acc, sibling)` when `siblingIsRight`, else
   `acc := node(sibling, acc)`.
3. The proof holds when the accumulator equals the root.

If step 1 refuses the disclosed value — a non-terminal, a number outside
§6.4.2's band, or a string with no UTF-8 encoding (§6.4.1.1) — verification MUST
return **false**, not propagate an error.

**A structurally malformed proof MUST also return `false`, not throw.** The wire
schema above is normative and MUST be enforced rather than assumed: `sibling` is
a 64-character hex string and **`siblingIsRight` is a JSON boolean**. A step
missing either field, or carrying a `siblingIsRight` that is not a boolean, is
malformed, and a malformed proof proves nothing — which is `false`, the same
verdict a wrong proof gets. A `proof` that is not an array is malformed in the
same way.

> **Why the flag's TYPE is called out rather than left to "obviously".** Read by
> truthiness instead of by type, the same bytes get **opposite verdicts** from
> the two reference implementations, and both were shipping that way until
> 2026-07-26. JavaScript reads a missing field as `false` and reads `[]` and `{}`
> as `true`; Python raises on the missing field and reads both containers as
> `false`. So a proof with the flag omitted verified **true** in one and
> **false** in the other — and for a leaf whose genuine flags are all `false`
> (the last leaf of a four-leaf tree, say) the omitted-flag proof is
> indistinguishable from the genuine one under coercion.
>
> This is not a forgery on its own: the accumulator still has to land on the
> sealed root, and a coerced flag that changes the fold simply fails. It is an
> **interoperability** break, which is the failure mode a published pair of
> verifiers can least afford — a caller holding one proof and two verifiers has
> no way to know which verdict to believe. `disclosure.json`'s
> `malformedProofStep` family pins every case, including the string `"true"`,
> which both languages read as truthy and so is the one where they agree on the
> wrong answer.

> **Why step 1 is the whole security of this.** The fold in step 2 is
> leaf-agnostic: it will happily fold an interior node up to the root. Any
> implementation that passes an attacker-controlled digest straight into it is
> forgeable by presenting an interior node as a leaf. Recomputing from
> `(path, value)` is what makes that impossible, and the `LEAF_DOMAIN` tag is
> what guarantees an interior node's preimage can never be produced by
> §6.4.5.

**Security property of unsalted leaves (normative to understand, not to
implement).** Payload leaves carry **no salt**. Two consequences, both
load-bearing:

- A sealed value is **not recoverable** from the root or from a leaf digest.
- A **candidate** value **is checkable**: recompute `leafHash(path, candidate)`
  and test membership in the leaf set. Sigil uses exactly this to check a live
  record against an event whose raw payload was never persisted, with no
  backfill required.
- Therefore a **low-entropy field is brute-forceable by enumeration** by anyone
  holding the leaf digests. A three-valued provenance enum, a currency code, or
  a rate in a plausible range is effectively public to such a holder. An
  implementation that needs hiding for a field MUST NOT rely on the payload
  root for it — use the §6.3.2 salted commitment instead.
- A **single-field payload's root IS its leaf hash** (§6.4.8), so for that shape
  the brute-force reduces to holding `payloadRoot` alone, with no leaf set and
  no proof required.

**Metadata a disclosure leaks.** Sibling digests are opaque and reveal nothing
about the paths or values they cover. The proof's **shape** is not opaque: its
length bounds the payload's leaf count to `(2^(n-1), 2^n]`, and the
`siblingIsRight` flags spell out the disclosed leaf's rank in the sorted order.
A disclosure therefore leaks roughly how many fields existed and where this one
sorted. Pad the leaf set to a fixed power of two if that matters.

### 6.4.10 Producer profile

§6.4.1 through §6.4.9 define the canonical form. This subsection records what a
Sigil producer does to a payload **before** invoking it, so a third party
reproducing a root from a served Sigil payload gets the same bytes. It is
descriptive of the Sigil producer, not a requirement on a verifier.

- Absent object properties are dropped at every depth and absent array elements
  become `null`, per the definition of "absent" in §6.4.6. (That definition is
  normative and lives there; this bullet only records that Sigil's producer
  applies it before sealing rather than after.)
- A date is serialized to its ISO-8601 string.
- A number outside §6.4.2's accepted band is carried as the **string**
  `"sigil:num:" || <shortest round-trip exponential form, "e+" reduced to "e">`
  (e.g. `sigil:num:3e-5`, `sigil:num:1e21`), so the value stays bound under a
  `str` leaf instead of failing a driver's capture at the dock.

  > **This string is producer-side and is NOT byte-determined across languages.
  > Do not read it as a canonical form.** Only the `e+` → `e` reduction is
  > specified, and that is nowhere near enough to fix the bytes: for one value,
  > `3e-5`, Python's `repr` gives `3e-05`, JavaScript's `toExponential()` gives
  > `3e-5`, and `String()` gives `0.00003`, which is not exponential at all. All
  > three are defensible readings of "shortest round-trip exponential form", and
  > this paragraph previously implied a guarantee it never delivered.
  >
  > **It does not need to be reproducible, and that is the point.** By the time
  > the payload is served, the value is already a `str` leaf whose bytes are in
  > the served JSON. A verifier reads that string verbatim and hashes it; it
  > never performs this conversion, so the root a third party reproduces from a
  > served Sigil payload matches regardless. What is NOT available is
  > round-tripping in the other direction: two producers in different languages
  > handed the same double may emit different `sigil:num:` strings and therefore
  > different roots. **An implementation MUST NOT treat this transform as part of
  > the canonical form**, and a producer that needs cross-language agreement on
  > an out-of-band number must fix the string itself — integer cents, or an
  > agreed decimal spelling — rather than rely on this bullet.

- A value the canonical form cannot describe at all is committed as its string
  form rather than refused. **This has one gap and it is not a repair:** a string
  containing an unpaired surrogate (§6.4.1.1) has no UTF-8 encoding, and neither
  does its string form, so there is nothing this bullet can do with it. Such a
  value is refused by the canonical form, and a producer that must bind one has
  to choose an explicit encodable spelling first. Sigil has never sealed one —
  production payloads are ids, currency codes and integers — so this is stated as
  a boundary of the profile, not as behaviour claimed for it.
- A payload that is not an object is wrapped as `{ "value": <payload> }`; an
  absent payload becomes `{}`.

A payload already inside the canonical form passes through structurally
unchanged, so its root equals the root of the payload as served.

### 6.4.11 Test vectors

`protocol/compliance-suite/fixtures/payload-root/` carries machine-checkable
vectors for every rule above. They are generated by the production
implementation
(`protocol/compliance-suite/gen-payload-root-fixtures.mjs`), so reproducing them
is evidence of agreement with what Sigil actually seals rather than with whoever
typed the fixtures.

| file                       | covers                                                                                                                                               |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `leaves.json`              | §6.4.4, §6.4.5 — one row per terminal kind, plus the nine values the type tag keeps apart                                                            |
| `roots.json`               | §6.4.6, §6.4.8 — whole-payload roots with their canonical leaf order; equality and inequality sets                                                   |
| `absent-key.json`          | §6.4.6 — the absent / present / explicit-null triple on the real `RATE_CON_AGREED` shapes                                                            |
| `sort-order.json`          | §6.4.7 — length-prefix dominance, UTF-8 vs UTF-16, lexicographic array indices                                                                       |
| `rejected.json`            | §6.4.2 — the rejected band including the `1e-5` form divergence, plus the values that MUST pass                                                      |
| `limits.json`              | §6.4.6 — the 8192-leaf cap at and over, leaves spread across nested arrays, and the 64-step depth cap on both sides                                  |
| `merkle-shapes.json`       | §6.4.8, §6.4.9.2 — leaf counts 1–9 with every leaf proved; the self-sibling at every odd level; proofs that MUST be rejected                         |
| `disclosure.json`          | §6.4.9 — valid and forged proofs, malformed proof steps, the §6.4.9.1 wire-path distinction, unsalted-leaf membership                                |
| `unpaired-surrogates.json` | §6.4.1.1 — the refusals, the accepted `U+FFFD` and astral controls, the leaf collision, and the proof and membership forgeries reproduced end to end |

**`fixtures/composition/payload-to-entry.json` is the only vector binding §6.4
to §6.3**, and a producer that skips it has tested nothing about how the two fit
together. Every other §6.3 and bundle fixture carries a **placeholder**
`payloadRoot`, and every file in the table above stops at the root — so an
implementation can reproduce 100% of `payload-root/` and 100% of
`load-chain-v2/` and still seal a chain a verifier calls BROKEN, by emitting the
root uppercase, as raw bytes, or computed over the served rather than the sealed
payload. The composition vector carries one real payload with its leaves, its
root, the resulting entry hash, the served entry and the bundle it sits in, and
asserts the whole path in both languages. `payloadRoot` is **lowercase hex**, and
that is asserted explicitly there because an uppercase root survives every other
fixture in this corpus.

A conforming implementation MUST reproduce every digest in these files and MUST
agree with every `expectedValid`, `expectedMember`, `expectedError` and
`expectedRefusal` marker.
`merkle-shapes.json` is the one to run first when a root disagrees at some leaf
counts and not others.

Two provenance notes, because "generated by the production implementation" is
not true of every value in the set and the difference matters when reading a
failure:

- **`unpaired-surrogates.json` is hand-maintained** and carries no generated
  digests. Every expectation in it is a refusal or a boolean verdict, neither of
  which a production run can provenance — an implementation either refuses these
  inputs or it does not. The one digest it does carry (the encodable control's
  leaf) is compared **between** the two reference implementations by the suite's
  parity gate, which is what a recorded digest would have bought.
- A handful of cases inside the generated files are **declared rather than
  echoed**, and say so at their emission site: the depth and nested-array
  refusals in `limits.json`, and the `malformedProofStep` family in
  `disclosure.json`. Each asserts a MUST about input that must be refused, so
  there is no digest to generate; echoing would also defeat the purpose, since
  those families exist precisely because implementations disagreed about them.

## 10. External chain-head anchoring

### 10.0 Why

Chains live in the operator's own database, so "the chain verified" proves only
that the database is self-consistent. It does not stop the operator from
rewriting an event and recomputing every subsequent hash. Tamper-evidence against
the **operator** requires an operator-independent commitment made **before** any
dispute.

A checkpoint folds every org's current chain head into one Merkle root and has
that root RFC-3161 timestamped by an external TSA. Publishing only the root keeps
the tenant set private.

### 10.1 Leaf

```
LEAF_PREFIX := "sigil-anchor-leaf:v1"
leaf(head)  := SHA256( LEAF_PREFIX || "|" || orgId || "|" || index || "|" || headHash )
```

Binding `index` is what defeats a rewind: without it, a checkpoint covering head
#21 could later be presented as covering head #6.

**`index` renders in INTEGER FORM, and its domain is bounded (normative).** It
MUST be an integral binary64 within the safe-integer range (|index| ≤ 2⁵³ − 1),
and it renders with no fractional part, no exponent and no leading zeros. Head
#6 is the single byte `6` — never `6.0`, never `06`, never `6e0`. A value
outside that domain MUST be REFUSED, not rendered.

This is the same rule §6.3.1 fixes for `version` and `chainIndex`, stated here
because `index` is the one hashed number in this document that **no other check
constrains**. `chainIndex` must equal its position and `version` must be `2`, so
a malformed value there is caught before it is ever hashed; nothing bounds
`index` except this sentence.

> **Both references disagreed about this, and neither said so, 2026-07-27.**
> `String(1e21)` is `"1e+21"`; Python rendered `"1000000000000000000000"`. Both
> then produced a leaf, and **the leaves differed** — two conforming producers,
> two published roots for one checkpoint, and nothing in the corpus able to
> adjudicate. That is §10.2's single-leaf defect one level down, and it survived
> Wave 3. The parity gate could not see it because `verifyBundle`'s own position
> checks make the chain-side path unreachable, leaving only the exported §10.1
> library call — which no fixture exercises. **Refusing costs nothing:** above
> 2⁵³ a binary64 does not denote a unique integer, so no value that ever had a
> defined leaf gets a different one. Blast radius when found: production's
> largest audit-chain index was **601**.

**Delimiter constraint (normative).** Unlike §6.3 this construction is
pipe-delimited rather than length-prefixed, so `orgId` MUST NOT contain `"|"`.
Producers MUST reject any identifier that does. Sigil satisfies this because
org ids are CUIDs drawn from `[a-z0-9]`; an implementation that admits
operator- or user-chosen org identifiers MUST enforce it explicitly or adopt
§6.3.1 length prefixing instead.

### 10.2 Root

```
NODE_PREFIX := "sigil-anchor-node:v1"
node(l, r)  := SHA256( NODE_PREFIX || "|" || l || "|" || r )
```

Leaves are ordered by `orgId`, compared as **UTF-8 bytes** (§11.1). At each
level, pairs fold left-to-right; an odd node out is duplicated and folded with
itself. An empty checkpoint has the fixed root `SHA256(NODE_PREFIX || "|empty")`.

**A single-leaf checkpoint's root IS that leaf.** An implementation MUST NOT
apply `node()` to a lone leaf, and the resulting **empty** inclusion proof MUST
NOT be treated as malformed.

> **This sentence pair is the whole of §6.4.8's rule for the sibling tree, and
> §10.2 shipped without it.** Read literally, "an odd node out is duplicated and
> folded with itself" makes a lone leaf the odd node out, giving
> `root = node(leaf, leaf)`. Every implementation instead stops the loop
> (`while (level.length > 1)`), so the root is the bare leaf. Measured: for one
> head the implementations publish `2d4785dc…` and the literal text gives
> `8036fa3a…`.
>
> **Two conforming producers therefore published different roots for the same
> single-head checkpoint**, and the case that hits it is a **single-tenant
> deployment** — the most likely first third-party adopter and every self-host
> demo. It is invisible to a proof-folding verifier, because a self-consistent
> producer's one-step self-sibling proof verifies against its own root, and it
> was invisible to the corpus, which carried only a five-head vector.
> `fixtures/anchor/valid/single-leaf.json` pins the single-leaf end. The EMPTY
> end has no fixture file and does not need one: the suite asserts
> `merkleRoot([]) == EMPTY_ANCHOR_ROOT` as a self-check in both languages, which
> is a stronger test than a fixture, because a fixture would only pin a root
> somebody typed. **This sentence used to cite a `empty-checkpoint.json` that
> does not exist**, so a reader checking the evidence for a normative claim found
> nothing. Corrected 2026-07-27 after clean-room run 2 followed the citation.

### 10.3 Inclusion proof

A proof is an ordered list of `{ sibling, siblingIsRight }` steps. Fold the leaf
upward, combining as `node(acc, sibling)` when the sibling is on the right and
`node(sibling, acc)` otherwise. The proof holds when the accumulator equals the
published root.

**The wire shape is normative and MUST be enforced rather than assumed**, on
exactly the terms §6.4.9 already states for the payload proof: `sibling` is a
64-character hex string and `siblingIsRight` is a **JSON boolean**. A step
missing either member, or carrying a `siblingIsRight` that is not a boolean, is
malformed; a `proof` that is not an array is malformed the same way; and a
malformed proof MUST return **false** rather than throw, because a malformed
proof proves nothing and that is the same verdict a wrong proof gets.

An **empty** proof is not malformed. §10.2 makes the single-leaf checkpoint's
proof empty, and that is the single-tenant deployment.

> **This paragraph is here because the identical rule was written for §6.4.9
> and never for §10.3, and the omission was exploitable, 2026-07-27.** The two
> proofs have the same wire shape and only one had a shape contract, so each
> reference invented its own for the anchor: `for…of` on a non-array throws in
> JavaScript while iterating a dict yields keys in Python, and `[]` is truthy in
> one language and falsy in the other. Measured, both directions:
> `"proof": {}` was `VERIFIED` with `inclusionValid: true` in Python and BROKEN
> in TypeScript; `siblingIsRight: []` was the reverse.
>
> The sharpest case is not a divergence but a forgery. `sibling` absent renders
> as the literal string `"undefined"` under JavaScript interpolation, so a proof
> step carrying no sibling at all folds deterministically — and against a root
> folded with that same string, `verifyAnchorInclusion(head, [{siblingIsRight:
true}], root)` returns **true**. A step that supplies no sibling authenticates.
> That is not a refusal that happened to look untidy; it is an inclusion proof
> with a hole in it, and the corpus never contained the input that shows it.

### 10.4 Inclusion verification

A verifier MUST **recompute** the leaf from the head via §10.1 rather than trust
a caller-supplied leaf. Verifying a supplied leaf proves only that _some_ leaf is
in the tree, which is not a claim about this org's head at all.

### 10.5 Anchored chain verification

To report a chain as anchored, a verifier MUST establish all three of:

1. the chain verifies (v1.0 §3.4, or §6.3.6 for a v2 load chain);
2. the anchor evidence's head **is this chain's head** — matching on both `index`
   and `headHash`;
3. the head is included under the published root (§10.4);
4. **the root was obtained INDEPENDENTLY of the party serving the evidence** —
   from the publication channel of §10.7, not from the bundle being checked.

**Condition (2) is NOT establishable from a served load bundle, and a verifier
MUST report that rather than guess.** §6.3.8 gives the bundle an `anchor` and a
load chain and nothing that ties them: anchor checkpoints fold **org** chain
heads, a v2 load chain reaches the same guarantee only transitively through the
`LOAD_EVENT_SEALED` cross-reference on its org's chain (§6.2), and the bundle
carries no org chain. So a verifier holding only a bundle has no referent for
"this chain's head".

The outcome of (2) is therefore reported as **`headBoundToChain`**, with three
values and not two:

| value   | meaning                                                             |
| ------- | ------------------------------------------------------------------- |
| `true`  | the evidence head IS this chain's head, on both `index` and `hash`. |
| `false` | an evidence head is present and is demonstrably NOT this chain's.   |
| `null`  | **not establishable from this artifact.** The bundle path.          |

Only `false` may force `BROKEN`. `null` is not a failure and MUST NOT be
reported as one — it is the honest and expected value for every served bundle,
and it means the reader holds an inclusion claim about an org head rather than a
binding to the chain in front of them.

> **A verifier that guessed here would have reported BROKEN on every document
> Sigil serves, and the corpus would have agreed with it, 2026-07-27.** Every
> anchored bundle fixture had been generated with `anchor.head` built from the
> LOAD chain's last entry, while the producer fills it from `checkpoint.orgHeads`
> — the org head. The two chains share no index space and no hash space. So the
> natural reading of condition (2), implemented literally, passed 15/15 on the
> corpus and would have taken production to zero on deploy. The fixtures were
> corrected; this clause exists so the next reader does not have to rediscover
> why they look the way they do.
>
> Making (2) establishable in-band needs a new envelope member carrying the
> `LOAD_EVENT_SEALED` entry and its position. That is protocol work for a later
> revision, not an erratum, and it is the same call §6.3.8 already made about the
> §6.4.9 field proof.

**A supplied root and NO anchor is answered, not ignored.** When a caller
supplies a root out of band and the bundle carries no `anchor` section, a
verifier MUST still emit the anchor block, with `inclusionValid: null` —
nothing was checked, which is not a failed check — `rootCorroborated: false`,
and a reason saying there was no anchor to check against. Condition (4) then
fails in the ordinary way and the bundle is `BROKEN`, because the caller asked
for corroboration and did not receive it.

> **All four verifiers dropped the question silently until 2026-07-28.** A
> caller who passed a root they had fetched themselves to an anchorless bundle
> got `VERIFIED`, `anchor: null`, no flag and no reason — the strongest
> available verdict over a request that was never answered. `anchor: null` is
> correct under §6.3.9 point 3 (the section IS absent), which is exactly why the
> silence was invisible. Clean-room run 6, D5. Note that `--expect-root` has
> never shipped in any published package, so no existing invocation can reach
> this path and no already-sealed artifact verifies differently.

**An ENVELOPE `expectedRoot` and no anchor is also answered — and it stays
`VERIFIED`.** When the envelope carries `expectedRoot` (§6.3.8) and the bundle
carries no `anchor` section, a verifier MUST emit the anchor block on the same
terms as the paragraph above — `inclusionValid: null`, `rootCorroborated:
false`, `headBoundToChain: null`, and a reason saying the envelope asserts a root
that this document does not substantiate. The **verdict is unchanged**: the
bundle is `VERIFIED` if everything else holds.

**The two halves get opposite verdicts, and the asymmetry is the whole of
§11.9.** A caller-supplied root is out-of-band evidence and a **question asked**;
answering `VERIFIED` while dropping it answers a question nobody put. An envelope
root **arrived with the artifact**, was written by the party being checked, and
corroborates nothing — so it can no more fail the document than corroborate it.
§11.9 rule 3 makes envelope **disagreement** `BROKEN`, and disagreement requires
two values in one document; with no anchor served there is no second value.
**Unsubstantiated is not contradicted.**

> **Refusing here would buy nothing, and that is what settles it.** An attacker
> stripping the anchor to hide it strips `expectedRoot` in the same edit and gets
> `VERIFIED` with `anchor: null`, which §6.3.8 point 1 makes a complete,
> conformant document. So `BROKEN` would cost honest producers a false negative
> and deny an attacker nothing. **What was missing was the report, not the
> verdict** — and the report is what §6.3.9 point 4 already requires, since a
> root named in the document and absent from the report is exactly the silence
> that let `rootCorroborated` mean two things.
>
> All four verifiers returned `VERIFIED` with `anchor: null` here until
> 2026-07-28, correct under §6.3.9 point 3 and silent about a root the document
> puts in front of the reader. `fixtures/bundles/envelope-root-no-anchor.json`
> pins it, and pins the **flag** rather than the status, because the status is
> `VERIFIED` either way. Clean-room run 8, D5.

A verifier MUST report the outcome of (4) as **`rootCorroborated`**. With (1)
through (3) met and (4) unmet, the correct verdict is an **inclusion claim**, not
an external-timestamp claim: it establishes that the head sits under _a_ root the
server handed over, and nothing about when that root existed or who else saw it.
A verifier MUST NOT report such a result as "anchored" without qualification.

**"(4) unmet" here means the caller supplied NO independent root — not that they
supplied one which disagreed.** Those two look alike in this sentence and are
opposites in evidence. No root supplied is a gap in what the reader did, and the
bundle still verifies. A supplied root that disagrees is a conflict between the
operator's evidence and an independent observation of the same checkpoint, and
under §6.3.9 point 1 condition (4) the bundle is **BROKEN**. Reporting the second
as an uncorroborated inclusion claim would file the operator-minted-root attack
as a caveat.

> **Condition (4) is not an addition to §10.5, it is what condition (3) always
> meant.** "The published root" carries the entire §10.5 guarantee, and both
> published references already implement (4) as `expectedRoot` /
> `rootCorroborated` — the word "published" was doing work no clause explained.
> An operator who rewrites a chain and mints a fresh root over it satisfies (1),
> (2) and (3) jointly, which is the exact attack §10.0 exists to defeat.
>
> A third party building a §10.5-conformant verifier from the previous text
> emitted `anchored: true` where our own reference emits
> `rootCorroborated: false` and warns against quoting it. **Two conforming
> verifiers disagreed about the strongest claim in the protocol, and the spec
> authorized the weaker one.**

Condition (2) is load-bearing and MUST NOT be omitted. Conditions (1) and (3) are
each independently true of artifacts that have nothing to do with one another: a
valid chain paired with a valid inclusion proof of some _other_ head satisfies
both, and reporting that as "anchored" asserts coverage that was never proven.

Anchor checkpoints fold **org** chain heads. A v2 load chain reaches the same
guarantee transitively through the `LOAD_EVENT_SEALED` cross-reference on its
org's chain (v1.0 §6.2): verify the load chain under §6.3.6, then anchor the org
chain under §10.5.

### 10.6 Timestamping the root

The checkpoint root is timestamped by an external RFC-3161 TSA. That is the only
operator-independent fact in the whole of §10, and until now the byte it turns on
existed in one line of unpublished service code.

Normatively:

- The `MessageImprint.hashAlgorithm` is **SHA-256**, OID
  `2.16.840.1.101.3.4.2.1`.
- The `MessageImprint.hashedMessage` is the **32 raw bytes obtained by
  hex-decoding the published root**. It is NOT `SHA256` of anything, and in
  particular NOT `SHA256` of the 64-character ASCII hex string a feed serves.
- A verifier MUST compare on those decoded bytes.
- The token on the wire is the **base64 of the DER `TimeStampToken`** (the CMS
  `SignedData`), not the enclosing `TimeStampResp`.

> **Get this wrong and each side concludes the other forged the checkpoint.** The
> obvious reading of "the root is RFC-3161 timestamped" is
> `imprint = SHA256(root-as-served)`. A producer doing that mints a token whose
> `hashedMessage` never matches ours; a verifier doing that against our token
> computes `SHA256("c0cf04…")` and finds it differs from the imprint. Neither
> side can tell which of them is wrong, because until this section no document
> stated the convention. The root is **already** a digest — it is imprinted as
> the digest it is, not re-hashed.

### 10.7 Checkpoint publication

§10.5 condition (4) requires a root obtained independently of the party serving
the evidence. This section defines what that channel serves. Endpoint **paths**
are operator-defined; the **object shapes** below are normative, and both the
checkpoint and its inclusion proof MUST be retrievable **without
authentication** for the root and token — a corroboration channel behind the
operator's own auth corroborates nothing.

**Checkpoint**

| field            | type                                | notes                                                       |
| ---------------- | ----------------------------------- | ----------------------------------------------------------- |
| `id`             | string                              | Stable identity; what a bundle's `anchorCheckpointId` names |
| `root`           | lowercase hex, exactly 64 chars     | The §10.2 root                                              |
| `leafCount`      | non-negative integer                | Number of org heads folded; `0` is the §10.2 empty root     |
| `tsaAuthority`   | string \| null                      | The TSA the root was timestamped against; null if none      |
| `timestampToken` | base64 DER `TimeStampToken` \| null | §10.6; null when the TSA was unavailable                    |
| `timestampedAt`  | RFC 3339 UTC \| null                | Operator-observed grant time, a **convenience only**        |
| `createdAt`      | RFC 3339 UTC                        | When the checkpoint was built                               |

**Inclusion proof** — `checkpointId`, `root`, `orgId`, `index`, `headHash`,
`leaf`, and `proof` (the §10.3 path), plus `tsaAuthority` and `timestampToken`.

A feed serves a newest-first list and a by-id read.

#### 10.7.1 Sigil's feed, named

Paths are operator-defined, so **an operator MUST publish its own**. A channel
whose location is undiscoverable is not a publication channel, and §10.5
condition (4) — the only operator-independent fact in this protocol, and the one
the neutrality claim rests on — is unreachable without it.

Sigil's, unauthenticated, on the retrieval host of §1.1:

| what              | where                                               | auth |
| ----------------- | --------------------------------------------------- | ---- |
| newest-first list | `https://sealedby.com/anchors`                      | none |
| by-id read        | `https://sealedby.com/anchors/{anchorCheckpointId}` | none |

`anchorCheckpointId` is the §6.3.8 point 4 envelope member. Both return the
**checkpoint** object above — `root`, `leafCount`, `tsaAuthority` and the base64
`timestampToken`. That is exactly what §10.5 condition (4) needs and all it
needs: the reader already holds the inclusion **proof**, which arrived in the
bundle, and it is the **root** that has to come from somewhere else.

> **The inclusion-proof read is a separate endpoint and it is NOT
> unauthenticated**, which contradicts this section's own opening paragraph.
> Registered in §11.0.1 rather than quietly softened here. The short version:
> §10.0 keeps the tenant set private on purpose, so serving arbitrary org heads
> to anonymous callers works against a different guarantee, and the two rules
> were written without reference to each other.

> **This paragraph exists because a clean-room implementer could not find the
> feed, 2026-07-28.** §11.8.1 names its concrete endpoint (`GET /seals/{sha256}`)
> while this section said only "operator-defined" and stopped — so run 9 probed
> `/checkpoints`, `/anchor/checkpoints` and `/protocol/checkpoints`, got the SPA
> catch-all on all three, and correctly reported that **§10.5 condition (4) is
> not reachable by a third party against Sigil from published text.** The feed
> was live at `/anchors` the whole time and is referenced by that path in
> `EXPECTED_ROOT_NOTE` in the operator's own bundle response, which is not a
> published document.
>
> The finding stands exactly as filed: the text was insufficient, and a
> capability that exists but cannot be located is indistinguishable from one that
> does not exist. It is the second time a §10 guarantee has been unreachable for
> want of a published surface rather than for want of a rule — the first was §8's
> seal-record lookup, and that one was also invisible until somebody tried to
> use it from outside.

> **`timestampedAt` is not evidence and MUST NOT be relied on as a time.** It is
> the operator's own clock, recorded by the operator. The authoritative,
> operator-independent time lives **inside** the token, and a verifier that reads
> `timestampedAt` instead of parsing the token has reintroduced exactly the trust
> §10 exists to remove.
>
> **Why the tenant set is absent, deliberately.** §10.0's "publishing only the
> root keeps the tenant set private" is why `leafCount` is a count and not a
> list: the checkpoint proves how many heads were folded without naming whose.

## 11. Amendments to v1.0

v1.0 is immutable (v1.0 §Status). This section is the only mechanism by which a
v1.0 section is corrected for v1.1 producers and verifiers. Each subsection names
the v1.0 section it amends, carries an anchor-stable id, and has a matching row in
§0.

A v1.0 producer remains conformant to v1.0 as published. These amendments bind
v1.1 implementations only.

An amendment here MUST NOT change a canonical form. That is not a stylistic
limit: v1.0's forms are the preimages of already-sealed evidence, and a section
that could rewrite them would make this document a tool for invalidating the
artifacts it exists to protect. A correction requiring a canonical-form change
gets a new protocol version (§0), never an amendment.

### 11.0 Precedence, amending v1.0 Appendix A

v1.0 Appendix A states that where the specification disagrees with both reference
implementations, the implementations are correct. For v1.1 producers and
verifiers that rule is narrowed as follows, and the narrowing is normative:

1. The reference implementations resolve **ambiguity** — a passage admitting two
   defensible readings. They do NOT override an explicit normative rule. Where a
   MUST is unambiguous and an implementation disagrees with it, the
   implementation is non-conformant.
2. The tiebreak is **void where the two reference implementations disagree with
   each other.** Such a disagreement is a specification defect and MUST be filed
   under [`GOVERNANCE.md`](../GOVERNANCE.md); it is resolved by amending this
   document, never by designating one implementation as correct.
3. No unpublished implementation is ever authoritative. `packages/crypto` is
   Sigil's producer and is not a reference implementation for any purpose.
4. **Known divergences are enumerated, not discovered.** Where a published Sigil
   implementation is known to diverge from an explicit rule in this document or
   in v1.0, the divergence MUST be listed in §11.0.1 with the rule it violates
   and its resolution path. An **unlisted** divergence is a defect in the
   implementation. A **listed** one is a defect in this document's rollout. Both
   are visible, which is the point.

Rationale, stated because the original rule reads reasonably: a specification
whose ambiguities resolve to source code an implementer cannot obtain is not
implementable from its text, which is the entire property this document exists to
provide.

Clause 1 has a consequence stated here rather than left to be found: it makes
Sigil's own shipped implementations formally non-conformant on every item in
§11.0.1, effective immediately. That is the honest reading and it is preferred to
the alternative, in which the same divergences exist unrecorded and an adopter
discovers them by building against the text and failing.

#### 11.0.1 Register of known divergences

Each row names an explicit rule, the published Sigil behaviour that disagrees
with it, and how it resolves. A row leaves this table only when the divergence is
closed or the rule is amended — never by re-describing it.

| Rule                 | What the rule requires                                                                                                                       | What Sigil publishes                                                                                                                                                                                                                                                                                                                                                      | Resolution                                                                                                                                                                                                         |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| v1.0 §5              | "one **PAdES-B-LTA** conformant signature"                                                                                                   | **B-B.** The sealer applies a CAdES-detached signature with no DSS, no embedded validation data and no document timestamp.                                                                                                                                                                                                                                                | **CLOSED 2026-07-27** by §11.6. §5 now states B-B as the floor and the sealer produces **B-T**. B-LT/B-LTA deliberately unclaimed — B-LTA is a standing operational obligation, not a sealing feature.             |
| v1.0 §5.1            | The RFC-3161 token is "attached as an Unsigned Attribute (`id-aa-timeStampToken`)"                                                           | A **real** token is obtained and stored beside the seal record; nothing attaches it to the CMS. The signature therefore carries no signature-timestamp, and the token's binding to the document is Sigil's database, not the seal.                                                                                                                                        | **CLOSED 2026-07-27** by §11.6. A signature timestamp is now attached as `id-aa-timeStampToken`, imprinted over the SIGNATURE VALUE. The pre-existing document timestamp is kept — the two prove different things. |
| v1.0 §6.1            | `geo` is a member of `payload` and is "hashed as part of `payloadHash`"                                                                      | The v1 sealer folds `latitude`, `longitude`, `accuracyMeters` and `capturedFrom` directly into the entry preimage as four newline-delimited fields, and additionally commits `actorUserId`, which no section defines.                                                                                                                                                     | **CLOSED 2026-07-27** by the §11.4 erratum. Erratum only — see the note below; the form itself cannot change.                                                                                                      |
| v1.0 §6.1            | `capturedFrom` is one of `DEVICE`, `IP_GEOIP`, `NONE`                                                                                        | The sealer admits three more: `DEVICE_ATTESTED_IOS`, `DEVICE_ATTESTED_ANDROID`, `CELL_TOWER`.                                                                                                                                                                                                                                                                             | **CLOSED 2026-07-27** by the §11.4 erratum, which lists all six values.                                                                                                                                            |
| v1.0 §8              | `recipients[]` carries `role` and `status`                                                                                                   | Neither is emitted. The response carries `name`, `email`, `completedAt` and `signedByAgent`.                                                                                                                                                                                                                                                                              | **CLOSED 2026-07-27** by §11.7. Both emitted, and `role` is pinned to the structural type rather than the free-text label — the ambiguity §8 alone could not resolve.                                              |
| v1.1 §6.3.4          | "A v2 verifier MUST reject an entry whose `payloadHash` and `payloadRoot` differ, and MUST report it as a broken chain" (erratum 2026-07-26) | **Neither published reference verifier implemented this check.** npm 1.3.0 and PyPI 1.4.0 report a chain carrying a rewritten `payloadHash` as VERIFIED. It was enforced only in `apps/api/src/loads/chain-seal-v2.ts`, which under clause 3 is not a reference implementation — so a MUST added by erratum was enforced by no published implementation.                  | **CLOSED 2026-07-27** by npm/PyPI **2.0.0**. Implemented in both references and pinned by `fixtures/load-chain-v2/tampered/payload-hash-mismatch.json`.                                                            |
| v1.1 §6.3.7.2        | "The three openings are independent and MAY be disclosed individually"; an absent opening is UNDISCLOSED and MUST NOT affect the verdict     | **The published verifiers refuse a partial disclosure.** npm 1.3.0 raises a TypeError and PyPI 1.4.0 a `KeyError` on an openings map missing any of the three keys, so selective disclosure — the reason the three values are committed separately — is unreachable through either published artifact.                                                                    | **CLOSED 2026-07-27** by npm/PyPI **2.0.0**. Pinned by `fixtures/bundles/partial-openings.json`.                                                                                                                   |
| v1.1 §6.3.8          | A verifier SHOULD accept a caller-supplied load id and MUST prefer it over the envelope's                                                    | **Closed 2026-07-27.** Both references previously read only the envelope's self-asserted `loadId`, so a transplant that rewrote it too was undetectable from the artifact. `--expect-load` / `expectedLoadId` and a `loadIdCorroborated` field now exist in both.                                                                                                         | **CLOSED 2026-07-27** by npm/PyPI **2.0.0**. Pinned by the `bundles/transplant-*` pair.                                                                                                                            |
| v1.0 §6.1 (verifier) | A published verifier should be able to check a sealed v1 load chain                                                                          | **Neither reference implemented the v1 per-load form at all** — `verifyLoadEntry` was v2-only, so the 91 sealed v1 entries in production could not be checked by any published verifier.                                                                                                                                                                                  | **CLOSED 2026-07-27** by npm/PyPI **2.0.0**, as `verifyLoadChainV1` / `verify_load_chain_v1`, pinned by `fixtures/load-chain-v1/`.                                                                                 |
| v1.0 §4              | Object keys are emitted "in lexicographic **byte** order"                                                                                    | The **TypeScript reference verifier** (`canonical.ts`, npm `sigil-protocol-verifier`) sorts with `Array.prototype.sort`, which compares UTF-16 code units. The Python reference sorts by code point, which equals UTF-8 byte order. For a key outside the BMP the two produce **opposite** orders: TS ranks `U+1F600` before `U+FF21`, Python and §4 rank `U+FF21` first. | **CLOSED 2026-07-27** by npm **2.0.0** (§11.1). §4 is unambiguous, so under clause 1 the TS reference was non-conformant and Python was correct — this was never a §4 ambiguity and clause 2 never applied to it.  |

| v1.0 §8 (§11.8 rule 4) | `status = UNKNOWN` MUST be returned when a document has no matching public seal record | **`VALID`.** Sigil's verify endpoint performed the record lookup — it set `recognized` from it — and then did not let the answer reach `status`: `UNKNOWN` was applied only when the signature ALSO failed to verify. Demonstrated 2026-07-27 by sealing a PDF with a self-signed certificate reading `Totally Not Sigil, Inc.` / `Impostor LLC`: `POST /verify` returned `status: VALID`, `recognized: false`, and the public verify page headlined **"This document is genuine."** Both published reference verifiers return `VALID` and exit 0 on the same file. | **CLOSED 2026-07-27** in Sigil's API by the §11.8 precedence table, and pinned by a test that seals with an impostor certificate. The published references remain unable to reach rule 4 until a release ships the opt-in lookup; that is a capability gap, not a divergence — with no lookup the antecedent is unestablished and §11.8 says so. |

| v1.1 §10.1 / §6.3.1 | A hashed integer renders in INTEGER FORM, within the safe-integer range | **The two published references disagree with each other.** npm 2.0.0 renders `index` with a bare template literal, so `1e21` becomes `1e+21`; PyPI 2.0.0 renders `1000000000000000000000` and applies no range bound at all. Both produce a leaf, and the leaves DIFFER — one checkpoint, two published roots. Reachable through the exported §10.1 call, not through `verifyBundle`, whose position checks fire first. | **OPEN.** Fixed in both references in-tree on 2026-07-27 and pinned by a boundary table in each test suite; closes on the next npm/PyPI release. Clause 2 applies: the two references disagreeing voids the tiebreak, so this resolves by amending §10.1 rather than by designating either as correct. |

| v1.1 §6.3.3 | The four geo terms have one canonical encoding, whatever the serializer omitted | **The two published references disagree with each other.** npm 2.0.0 tests `=== null` alone, so an ABSENT `accuracyMeters` reaches the number encoder as `undefined` and THROWS; PyPI 2.0.0's `.get()` returns `None` and commits the same bytes as an explicit null. In the other direction PyPI subscripts `capturedFrom` and raises `KeyError` where npm concatenates. Same geo stamp, a crash in one language and a commitment in the other. | **OPEN.** Aligned in both references and in `packages/crypto` on 2026-07-27, and pinned by three new `geo/canonical.json` cases under the parity gate; closes on the next npm/PyPI release. Clause 2 again: the two disagreeing voids the tiebreak, so it resolves by amending §6.3.3. |

| v1.1 §11.8.2 | `documentSha256` is the SHA-256 of the document bytes, and is what §11.8.1's lookup is keyed by | **Both published references emit the SIGNED BYTE-RANGE digest under that name** — a different value. Measured on the published sample: the file hashes to `bdf1f59e…` (`known:true`), the references report `e3aeb36b…` (`known:false`). A third-party verifier built to §11.8 and wired to its own report gets `known:false` for EVERY genuinely sealed document, and rule 4 then forces `UNKNOWN` on all of them. | **OPEN.** Fixed in both references on 2026-07-27 — `documentSha256` is now the whole-file digest and the byte-range value moved to `signedByteRangeSha256` — and proven end to end against the live endpoint. Closes on the next release, which is a MAJOR bump because a published field's value changes. |
| v1.1 §11.8.2 | The document SHA-256 is computable "from the document bytes alone" and therefore always emitted | **Both published references emit `null` on the `UNKNOWN` and `INVALID` paths**, including for the literal input `not a pdf at all`, whose digest is perfectly computable. A Python test asserted the `null` — the defect written down as the specification. | **OPEN.** Fixed in both and the test corrected. Closes on the next release. |
| v1.1 §11.8.2 | The operator-independent fields are **top-level**, and `documentSha256` MUST be emitted on every path including `UNKNOWN` and `INVALID` | **Sigil's API carried the digest only at `document.sha256`.** That block is `null` for every document Sigil did not seal, so the report dropped the field on exactly the two paths where §11.8.1's lookup is what a caller would re-ask with — and a caller who cannot re-ask cannot distinguish _we have no record_ from _you asked with the wrong digest_. The placement paragraph asserting this is "what the published implementations do" was true of both references and false of the operator's own API on the day it was written. | **CLOSED 2026-07-27.** A top-level `documentSha256` is emitted on every path, computed from the bytes verified rather than read from the seal record — so it is present when there is no record, and on `TAMPERED` it deliberately differs from `document.sha256`. Additive: `document.sha256` stays for v1.0 §8 compatibility exactly as §11.8.2 permits. |
| v1.1 §11.6 point 4 | "A verifier **MUST** report the level it observed in the bytes" | **Neither published reference emits `pades` at all.** npm 2.0.0 emits five keys, PyPI 2.0.0 seven; neither is the level. Sigil's API does emit it. Under clause 4 an unlisted known divergence is a defect in the implementation, and this one was unlisted. | **OPEN.** Listed here on discovery, per clause 4. The references gain it in the next release. |

| v1.1 §6.3.9 point 6 | A verifier MUST NOT throw on malformed content; it MUST report | **npm 2.0.0 returns a FALSE `VERIFIED`** on `bundles/malformed-opening-not-an-object.json`, swallowing a non-object opening as "nothing disclosed"; PyPI 2.0.0 raises `AttributeError`. On the other two malformed vectors both emit no report, npm exiting 2 and PyPI 1. §0's row said "fixed in both" and named the vectors, which is true of the SOURCE and false of every released artifact — and a silent false pass is worse than the two crashes it replaced. | **OPEN.** Fixed in tree and pinned by three corpus vectors; closes on the next release. Listed on discovery, per clause 4 — the omission of this row is what clean-room run 4 actually reported. |
| v1.1 §11.8.4 | A verifier MUST report the in-band signature timestamp, and a stored one MUST carry `source` | **Neither published reference reads the `id-aa-timeStampToken`** on `fixtures/pdf/sealed-bt.pdf`, which carries one: both report `timestamp: null`. And **Sigil's API serves `timestamp` with no `source`** on a B-B seal — an OPERATOR*HELD document timestamp under the bare name §11.8.4 exists to forbid. §11.8.4 asserts "the references and Sigil's API carry `source`." None of the three do. | **API HALF CLOSED 2026-07-27** — `source` is emitted, `OPERATOR_HELD`. The reference half is fixed in tree (both now read the token) and closes on the next release. |
| v1.1 §11.9 | `rootCorroborated` is true only for a root obtained OUTSIDE the document | **All three verifiers reported `true` for a bundle that corroborated itself** — envelope `expectedRoot` equal to the served anchor root, every byte from the party being checked. The protocol's strongest guarantee was satisfiable by the operator alone. | **OPEN.** Fixed in both references with an out-of-band `--expect-root` seam and pinned by `fixtures/bundles/self-corroborated-root.json`; closes on the next release. |
| v1.1 §0 (3.0.0 rows) | §0 says the Python `snake_case` aliases were "removed in the 3.0.0 release" | **No 3.0.0 exists on either registry.** PyPI 2.0.0 still emits seven keys to npm's five. The row describes a release that has not happened. | **OPEN.** Accurate the moment 3.0.0 ships; until then it is a claim about an artifact nobody can install. |
| v1.1 §6.3.4.2 / §6.4.1.1 | Every §6.3 preimage term is a string **with a UTF-8 encoding** | **An unpaired surrogate COLLIDES the entry hash and the commitments in npm 2.0.0.** Measured: `computeEntryHashV2` over `loadId` carrying U+D800, U+DFFF and U+FFFD returns ONE digest for all three, and `commit()` does the same — so a genuine escrowed opening for one value verifies a disclosure of another that was never sealed, and two distinct chains seal to one entry. PyPI 2.0.0 raises `UnicodeEncodeError` instead, so the same bytes crash one reference and commit in the other. §6.4.1.1 states the rule and both references enforce it for PAYLOAD roots; nobody applied it to §6.3. | **OPEN.** Closed in tree by one shared structural definition in both languages, pinned in both suites and mutation-checked. Closes on the 3.0.0 release. |
| v1.1 §10.1 / §10.3 | The anchor head and proof have a wire shape, and a malformed one is reported rather than thrown (§6.3.9 point 6) | **THE WHOLE `anchor` SUB-MEMBER SPACE IS UNGUARDED IN 2.0.0, and the two packages fail DIFFERENTLY on identical bytes.** Measured 2026-07-28 across **eleven** shapes — `root`, `head` and `proof` each absent and each `null`, `anchor` as `{}`, `head` as `{}`, and `head.orgId` / `head.index` / `head.headHash` each absent: **PyPI 2.0.0 emits no report on ten of the eleven** (`KeyError`, `TypeError`) and **npm 2.0.0 on five** (exit 2). Where they both refuse, they refuse at **different exit codes — npm 2, PyPI 1** — so one caller reads *unreadable* and the other reads *tampered evidence*. Separately, `openings` served as an object is **`VERIFIED` in npm** and crashes PyPI, and **a proof step supplying NO SIBLING AUTHENTICATES A SERVED BUNDLE IN BOTH PACKAGES.** Each renders the missing member and folds the rendering, so a forger who builds the served root the same way lands on it: `sigil-verify chain` returns `VERIFIED`, `inclusionValid: true`, exit `0` — from **npm 2.0.0** where `sibling` is ABSENT (rendered `"undefined"`) or explicitly `null` (rendered `"null"`), and from **PyPI 2.0.0** where it is explicitly `null` (rendered `"None"`). Reproduced end to end 2026-07-28 across six shapes: either spelling, sibling-left and sibling-right, alone, at either position of a two-step proof, and mixed with a genuine step. **Two things this row previously understated.** It read as a library-level fold when it is reachable through the published CLI on a served document, and clean-room run 10 filed it as npm-only because it measured the ABSENT spelling, where Python raises `KeyError` — the same measure-one-instance-and-generalise error the register has now made four times, this time inherited rather than committed. An EMPTY proof remains valid (§10.2): its root legitimately IS the leaf. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. §10.3 carries the shape contract (§0). All eleven are enforced in both references — `BROKEN`, exit 1, byte-identical, each naming the member — with the empty proof still valid per §10.2. Executed on every run by `check:release-state` rather than asserted here, and the sibling forgery is now pinned in the corpus by `bundles/forged-sibling-absent.json` and `bundles/forged-sibling-null.json` — **two vectors because one cannot catch both packages**, each carrying the root folded with that language's rendering, each proven by mutation to kill the guard's deletion in one language and not the other. **This row has now been wrong THREE times in the same way.** It first described the IN-TREE behaviour and named the npm/PyPI halves the wrong way round (run 6, D6); was then rewritten from the three shapes that had been measured, describing a three-member class that is actually eleven (run 9, D1); and carried the sibling forgery as a fold in one package when it is a served-bundle forgery in both (run 10, D2). **All three are the same error: writing the general claim from the instances in hand.** The third has a fourth instance beside it worth recording, because it is where the error hides best: `check:release-state`'s own `proof-step-no-sibling` claim built the shape with the HONEST root, so the fold missed, the packages reported `BROKEN`, and the executed check therefore confirmed the OPPOSITE of the sentence in this row, from the day the claim was written. **An executed claim is only as general as the bytes it builds.** Run 6 D6, run 9 D1, run 10 D2. |
| v1.1 §11.9 (`--expect-root`) | A verifier MUST accept an out-of-band root; the references take `--expect-root` | **In 2.0.0, NEITHER published CLI accepted it.** Measured 2026-07-28: `unknown argument --expect-root`, **exit 2, from both**. §11.0.1 and the clean-room brief both named npm alone, so a reader was told the Python CLI could exercise §10.5 condition (4) when it cannot. The consequence is scoping rather than a wrong verdict: **condition (4) is unreachable through either published CLI**, and can only be exercised through the compliance-suite adapter seam or a library call. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. Both in-tree CLIs accept and honour the flag and document it in `--help`. Clean-room run 9, D3 — and the npm-only phrasing is the same one-instance-generalised mistake as the row above, committed in the sentence that corrects it. |
| v1.1 §6.3.8 point 7 (discoverability) | A verifier SHOULD accept a caller-supplied load id, which criterion 2 of the clean-room brief depends on | **PyPI 2.0.0 accepts and honours `--expect-load` but does not document it in `--help`.** npm's help does. So a reader who checks `--help` before scoring — which is the correct instinct and what the brief's own install guard teaches — concludes the flag does not exist on the Python side and records criterion 2 as unscoreable against it. The behaviour is right and undiscoverable, which is a divergence from the published text in the only direction a reader cannot detect by running it. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. The in-tree Python `--help` documents both `--expect-load` and `--expect-root`, verified by invocation. Clean-room run 9, D5. |
| v1.1 §6.3.9 point 4 | `loadIdCorroborated` is a corroboration flag | **2.0.0 sets it from whether a caller supplied an id, not from a comparison.** So it is `true` on an EMPTY chain for any id whatsoever, having compared nothing, and `true` on a transplant checked against the id the reader actually asked for — the case the seam exists for, where the honest answer is refutation. A test asserted the second as correct. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. §6.3.9 point 4 now defines corroborated as COMPARED AND MATCHED, matching §11.9's `rootCorroborated`. Status is BROKEN either way, so no sealed artifact verifies differently. |
| v1.1 §6.3.7 / §6.3.9 point 5 | The openings block reports what was actually checked | **2.0.0 reports `openings.valid: true` with a full `disclosed` list over a chain the same report calls BROKEN** — the commitments those openings were checked against sit in an entry whose hash does not bind them, so they are values an attacker can rewrite freely. Separately `checked` is fabricated on two branches: 3 openings over 2 entries reports `checked: 2` with `disclosed: []`, claiming two positions examined where none were. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. Openings are no longer evaluated over a broken chain and report a distinct not-evaluated state; `checked` now equals `disclosed.length` on every branch. |
| v1.1 §6.3.9 points 2 and 3 | `null` means ABSENT, never failed; `brokenAtIndex` and `reason` are non-null together | **2.0.0's catch-all guard emits three claims it never established**, including on the shape §6.3.8's own sketch prints: `chain.valid: false` over a chain it walked to completion and never rejected, and `openings: null` / `anchor: null` on a bundle that carried both — telling the reader they were not offered. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. Every shape that reached the guard is now refused by a named check before it, so the guard is structurally unreachable rather than softened. |
| v1.1 §6.3.4.2 (hex domains) | `payloadRoot`, `payloadHash` and the three commitments are lowercase 64-hex | **No implementation enforces the hex domain**, in any of the four artifacts. `"not-a-root-at-all"` verifies. Type validation exists; the domain does not. | **OPEN AND DELIBERATELY NOT FIXED.** Enforcing it would flip every already-sealed artifact from an uppercase-emitting producer from `VERIFIED` to `BROKEN`, which §0 forbids. The honest resolution is a FLAG on a `VERIFIED` document and no such report member is defined; defining one is protocol work for a later revision. Recorded here rather than silently enforced. |
| v1.1 §6.3.4.2 (`occurredAt`) | `occurredAt` is RFC 3339 UTC with exactly three fractional-second digits | **No implementation enforces the format.** A chain whose `occurredAt` is `not a timestamp at all` verifies in all four. | **OPEN AND DELIBERATELY NOT FIXED**, for the same §0 reason: a producer behind a re-serializing intermediary could plausibly have SEALED a non-conforming timestamp already. Needs the same flag member as the row above. |
| v1.1 §6.3.7.1 point 2 / §6.3.9 point 6 | An opening's `value` and `salt` are encodable strings, and a verifier MUST report rather than throw | **In 2.0.0, both published packages CRASHED with no report, at DIFFERENT exit codes.** Measured 2026-07-28 across eight shapes and both members (`123`, `null`, `[]`, `{}`, `true`, on `value` and on `salt`, and on a second field): **npm 2.0.0 exits 2** (`The "string" argument must be of type string`), **PyPI 2.0.0 exits 1** (`AttributeError: 'int' object has no attribute 'encode'`). Identical on every shape, so this is the general behaviour and not one instance generalised. The exit contract makes `2` \_I could not evaluate this* and `1` _BROKEN_, so the two packages tell one caller the file is unreadable and the other that it is tampered evidence. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. §6.3.7.1 point 2 now states the domain (§0). Enforced in tree by one shared `openingProblem` / `opening_problem` in both languages, reported with a reason naming the member, and pinned by `bundles/malformed-opening-{value,salt}-not-a-string.json` plus `bundles/malformed-opening-unencodable-value.json`. Mutation-checked, each half individually. |
| v1.1 §10.5 / §11.9 | An `expectedRoot` the reader can see is reported, whether or not it corroborates | **Before 3.0.0, all four verifiers dropped an ENVELOPE `expectedRoot` when the bundle carried no anchor**, returning `VERIFIED` with `anchor: null` — a root named in the document and absent from the report. Correct under §6.3.9 point 3, since the anchor section genuinely is absent, which is exactly why the silence was invisible. Same shape as the caller-supplied case rev15 closed, one field over; §11.9 settled what the envelope root MEANS and never said it had to be MENTIONED. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. The anchor block is now emitted with `inclusionValid: null`, `rootCorroborated: false` and a reason, and the verdict deliberately stays `VERIFIED` (§10.5). Pinned by `bundles/envelope-root-no-anchor.json` via `expectedRootCorroborated`, which distinguishes a block reporting `false` from no block at all — `expectedStatus` cannot see this. |
| v1.0 §5.2 step 4 / §11.8.7 | A verifier confirms the signer against a configured trust policy, and reports `signerTrusted` | **Before 3.0.0, no implementation had ever performed this check, in any of the four artifacts.** v1.0 shipped the MUST with "Sigil ships a permissive policy", so all four accept every certificate and none emits `signerTrusted` or `signerCertSha256`. Measured 2026-07-28: both published packages return `VALID` and exit 0 on `fixtures/pdf/demo-pod.pdf` with no anchor and no lookup, where §11.8.3 rule 6 now requires `UNKNOWN` and exit 1. The reachable consequence is already recorded one row up — a PDF self-signed as `Totally Not Sigil, Inc.` verifies as `VALID` — and this row names the rule that was supposed to stop it. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. §11.8.7 carries the rules and both references implement them, anchored on the DER certificate fingerprint rather than the subject CN, with `--trust-cert` in both CLIs. **A correct implementation therefore DISAGREES with both published packages on `status` for every genuinely sealed document**, which is the third row of that kind and the reason clean-room criterion 5 carries the "if yours matches theirs, that is the bug" note. **The reference half THE API HALF DOES NOT, and is recorded here rather than allowed to become an unlisted divergence the moment the rest closes** (clause 4). Sigil's verify endpoint emits neither `signerTrusted` nor `signerCertSha256`, which §11.8.7 rules 3 and 4 require. Its `status` is unaffected — it is the operator, it always performs the lookup, so rule 6's antecedent never holds and rule 3's needs a caller-supplied anchor it never receives — so this is a missing REPORT MEMBER and not a wrong verdict, which is the same shape as `documentSha256`'s placement row and resolves the same way. |
| v1.1 §11.9 (hex case) | A caller-supplied root is compared case-insensitively (§11.9 rule 4) | **Through 3.0.0-rc.1, both published packages compared EXACTLY, on both sides.** Measured 2026-07-28: an out-of-band root differing from the served root only in case returns `BROKEN` with `rootCorroborated: false` from npm and PyPI 2.0.0 and from 3.0.0-rc.1 — a false report of the operator-minted-root attack, produced by the reader's own paste format. The envelope side compares exactly too, which is correct and stays. | **CLOSED in 3.0.0**, published 2026-07-28 and verified against the BUILT artifact by `pnpm check:release-gate`. Both references now normalise the CALLER's side only, and the asymmetry is pinned by `bundles/out-of-band-root-uppercase.json` and `bundles/envelope-root-case-mismatch.json`, which resolve opposite ways over the same root — a verifier that normalises both passes the first and fails the second. Mutation-checked. |
| v1.1 §10.7 | The checkpoint **and its inclusion proof** MUST be retrievable **without authentication** | **Sigil's inclusion-proof read requires a bearer token and is scoped to the caller's own org.** Measured 2026-07-28: `GET /anchors` and `GET /anchors/{id}` are unauthenticated and serve the root and the RFC-3161 token, but `GET /anchors/{id}/inclusion` returns `401 Missing bearer token`. So the sentence in §10.7 is satisfied for the checkpoint and not for the proof. | **OPEN, and it is a genuine conflict between two rules rather than an oversight.** §10.0 keeps the tenant set private on purpose — it is why `leafCount` is a count and not a list — and serving arbitrary org heads and their positions to anonymous callers works directly against that. §10.5 condition (4) is unaffected either way: the reader already holds the proof, which arrived in the bundle, and needs only the ROOT from an independent source, which is unauthenticated. Resolving this means deciding whether §10.7's requirement should ever have covered the proof, which is protocol work for a later revision and not an erratum. Found while naming Sigil's feed for §10.7.1, after clean-room run 9 reported the feed unreachable. |
| v1.1 §6.3.9 point 7 (exit codes) | A structurally malformed entry is `BROKEN` and exit **1**, "NOT exit 2" | **Both published CLIs pre-check that `entries` is an array and exit 2**, which contradicts the rule the same section states. The library and the CLI therefore disagree about what a malformed document is, and the caller of the CLI never sees the reason the library produced. | **OPEN, but no longer undecided.** Settled 2026-07-28 in favour of the text, after two consecutive clean-room runs reported the contradiction and neither could score the exit contract because of it. Both in-tree CLIs now draw the line at PARSING: unreadable or unparseable is exit 2, anything the parser accepted gets a verdict and exit 1 with a report. Measured identical across nine inputs in both languages. This is a behaviour change to a published exit code, so it closes on the **3.0.0** release rather than as an erratum. |

> **Clause 2 has now fired three times in one day, and all three were found the
> same way.** The §10.1 and §6.3.3 rows above, and the §6.3.9 crash behaviour,
> were each found by asking what an implementation does with an input NO FIXTURE
> CONTAINS — an out-of-range integer, an omitted member, a wrongly-shaped entry.
> The parity gate compares verdicts on inputs somebody thought to write down, so
> it is structurally blind to the inputs nobody did. That is the successor to
> "parity proves interoperability, never conformance": **parity also proves
> nothing about inputs the corpus does not contain**, and the corpus is written
> by the same people who wrote the implementations.
>
> **The §10.1 row is the first time clause 2 has actually fired.** Every earlier
> register row had the two references agreeing with each other and both
> disagreeing with the text, or one plainly wrong against an unambiguous MUST.
> Here they disagreed with each other and the text was silent, which is exactly
> the state clause 2 says cannot be settled by picking a winner. It was found by
> asking what §10.1 does with a number, not by any test — the parity gate cannot
> see it, because `verifyBundle` constrains `chainIndex` to its position and
> `version` to `2` before either reaches a hash, leaving only a library call no
> fixture makes.

> **This row was found by making §8 implementable, not by anyone reporting it.**
> While the lookup was missing, rule 4 could not be implemented by anybody, so
> nothing distinguished "we do not obey it" from "it cannot be obeyed". Defining
> the record separated the two and left exactly one of them. It is the fifth
> finding in this program where the defect was invisible because no test could
> have been written for it, and the first where the blocker was a missing
> **published surface** rather than a missing rule.

> **Why the §6.1 rows resolve by erratum and MUST NOT be resolved in code.** The
> v1 entry preimage is the input to every sealed v1 load entry in existence.
> Changing the producer to match §6.1's text would not fix a divergence; it would
> break every chain already sealed, which is the one outcome this protocol exists
> to prevent (§11). The correct resolution is a **Normative correction** (§0)
> that states the v1 form as it actually is — the rule described no
> implementation's behaviour, and no sealed artifact verifies differently after
> the text is fixed. This is the clearest case in the register of clause 1 being
> right and the remedy still being a document change.

> **The §4 row is what clause 1 is for, and it was found by writing this table.**
> It is worth separating from the §4 key-order question Wave 2 addresses. There
> are two different problems in §4 and only one of them is an ambiguity. Where
> §4's text is unambiguous — "lexicographic **byte** order" — an implementation
> that disagrees is simply wrong, and the reference status of the TypeScript
> verifier does not launder that under clause 1. Under the **old** Appendix A
> rule this case was not merely unresolved, it was unresolvable: the tiebreak
> requires both references to agree, and here they produce opposite orders.
>
> Practical blast radius is nil today — production payload keys are ids, currency
> codes and integers, none of which leave the BMP — but that is a mitigation, not
> a conformance argument, and it is recorded as one.

The register carries the rows above, and **the count and the open/closed state
are deliberately NOT restated here.** A summary sentence is a second copy of the
table, and this one had already drifted: it read "as of `v1.1-rev4` … the ten
rows above, all of them now closed" while the document was at rev6, the register
had thirteen rows across four tables, and two were OPEN. The one sentence in
this specification whose entire subject is that divergences are enumerated
rather than discovered had misstated its own revision, its own count and its own
open state. Read the tables. Corrected 2026-07-27 after clean-room run 3
followed it. It is not asserted to
be exhaustive of all divergence; it is asserted to be exhaustive of **known**
divergence, and a divergence found later is filed here under
[`GOVERNANCE.md`](../GOVERNANCE.md) before it is fixed, not after.

### 11.1 Object key ordering, amending v1.0 §4

v1.0 §4 step 1 says "Objects: emit keys in lexicographic **byte** order", and
then says the algorithm "is RFC 8785 (JCS) with two clarifications". **Those two
statements disagree.** JCS §3.2.3 mandates sorting by **UTF-16 code units**,
which is not byte order, and key ordering is not one of the two named
clarifications — so a reader cannot tell which governs. Above the BMP the rules
invert.

**Byte order governs. This is a THIRD clarification and it overrides JCS.**

Keys sort by their **UTF-8 byte sequence**, compared bytewise as unsigned
octets. For the values this document admits, that is identical to code-point
order. It is the same rule §6.4.7 already uses for the payload root, so the two
canonical forms in this protocol now order keys the same way.

> **Why byte order and not JCS.** Three reasons, in order of weight. §4's own
> normative sentence already says byte order; the JCS sentence is descriptive
> prose about lineage. The Python reference already implements it. And §6.4.7,
> the newer and more carefully specified of the two forms, uses UTF-8 byte
> order — adopting JCS here would leave one protocol with two key-ordering rules
> and no way to remember which applies where.
>
> **The TypeScript reference was wrong** and is corrected. `Object.keys().sort()`
> compares UTF-16 code units, so for an object keyed `U+FFFD` and `U+1F600` it
> emitted `{"\u{1F600}":…,"�":…}` → `ee25c272…` where Python emitted
> `{"�":…,"\u{1F600}":…}` → `8beaa064…`. The same payload, two digests, from
> the two verifiers a third party is told to trust. Registered in §11.0.1.

### 11.2 Number rendering, amending v1.0 §4

v1.0 §4 step 1 says numbers are "decimal, no leading zeros, no trailing `.0`, no
exponent", and then concedes that "the clarifications matter only at edge cases
the v1.0 fixture set does not include". **Both halves are wrong.** Both published
verifiers emit `1e+21` for `1e21`, so both violate the no-exponent clause; and
they disagree on integral floats, which the no-trailing-`.0` clause was supposed
to settle — TypeScript renders binary64 `2.0` as `2`, Python as `2.0`. JSON does
not distinguish `2` from `2.0`, so **any payload carrying an integral float — a
rate, a count, a latitude that lands on a whole degree — got two different
digests from the two shipped verifiers.**

**§4 adopts §6.4.2 wholesale.** A number in a §4 canonical form renders under
**§6.4.2.3** (INTEGER FORM / FRACTION FORM) and is admitted only within the band
of **§6.4.2.4**; a value outside the band MUST be **refused**, not rendered with
an exponent and not silently approximated.

This is stated as a reference and not restated, deliberately: §6.4.2 works the
rule out over four subsections and its band was verified by fuzzing 420,014
binary64 values. A second copy here would be a second source of truth for the
highest-risk rule in the protocol.

> The hedge about edge cases is **deleted**, not softened. It was the sentence
> that made it reasonable to stop reading.

### 11.3 NFC applies to object keys, amending v1.0 §4

v1.0 §4 step 1 says "Strings: UTF-8 NFC". **An object key is a string**, and
neither published reference normalizes one — both normalize string _values_ only
and pass the key through verbatim. So two payloads differing only in the Unicode
spelling of a key produce different digests, where §4 says they are the same
payload.

Normatively, for v1.1 producers and verifiers:

1. **Object keys are normalized to NFC**, exactly as string values are.
2. **Normalization happens BEFORE ordering.** Normalizing after sorting would
   let two spellings of one key sort into different positions, which is the same
   defect in a different place.
3. **If two keys in the same object normalize to the same string, the payload
   MUST be refused.** They are distinct keys before normalization and one key
   after it, and there is no correct merge — silently keeping one would seal a
   value the other key's holder never agreed to. This is a domain restriction
   under §0: such a payload never had a single defined canonical form.

> **Both references were wrong identically here, which is why nothing caught
> it.** The cross-language parity gate compares the two implementations against
> each other; two implementations that share a mistake agree perfectly. This is
> the third finding in this program with that shape, after the §6.3.4
> `payloadHash` MUST and §6.3.7.2 partial disclosure. **Parity proves
> interoperability, never conformance.**

> **Blast radius, measured rather than assumed.** All three amendments in this
> section are errata rather than a new protocol version because a read-only scan
> of production on 2026-07-27 found **zero** sealed payloads in any affected
> class — 2,803 payloads, no non-BMP key, no non-NFC string or key, no
> non-integral or unsafe-range number. Full method, results and the three limits
> on that evidence are in
> `docs/reports/2026-07-27-wave-2-production-scan.md`. Had a single artifact
> fallen in one of these classes, §0 would have required a new version instead.

### 11.4 The v1 per-load entry form, amending v1.0 §6.1

v1.0 §6.1 is titled "Per-entry GPS binding" and says `geo` **MAY** appear inside
`payload`, and that it "is hashed as part of `payloadHash` (§3.1)".

**No implementation has ever done that.** The v1 sealer folds the four geo
fields **directly into the entry preimage** as separate newline-delimited terms,
and additionally commits `actorUserId`, which no section of either specification
defines. A producer that followed §6.1 literally — putting `geo` in the payload
and hashing it into `payloadHash` — computed a different entry hash and had its
chains reported BROKEN.

The v1 per-load entry preimage, stated as it actually is:

```
v1LoadEntry(e) := SHA256( UTF8(
     chainIndex   || "
" || occurredAt        || "
" || kind
  || "
" || payloadHash  || "
" || prevHash || "
" || envelopeId
  || "
" || actorUserId  || "
" || latitude || "
" || longitude
  || "
" || accuracyMeters || "
" || capturedFrom ) )
```

- Fields are joined with `U+000A`. There is **no length prefixing** — this is the
  newline-delimited form v1.1 §6.3 exists to replace, and it is why a value
  containing a newline can forge a field boundary here. That weakness is not
  repaired; repairing it would change the form.
- `envelopeId`, `actorUserId`, `latitude`, `longitude` and `accuracyMeters`
  encode as the **empty string** when absent or null.
- Numbers render as the host language's default decimal rendering, which is the
  unfixed hazard §6.4.2 later addressed. It is recorded, not corrected.
- `capturedFrom` is one of **`DEVICE`, `DEVICE_ATTESTED_IOS`,
  `DEVICE_ATTESTED_ANDROID`, `CELL_TOWER`, `IP_GEOIP`, `NONE`.** §6.1 lists only
  the first, fifth and sixth; the other three were added to the producer without
  the document following.

> **This is a Normative correction (§0) and it CANNOT be resolved in code.** The
> v1 preimage is the input to every sealed v1 load entry in existence — 91 rows
> in production alone. Changing the producer to match §6.1's text would not fix
> a divergence, it would break every chain already sealed, which is the one
> outcome this protocol exists to prevent. So the document moves and the code
> does not. This is the clearest case in the register of §11.0 clause 1 being
> right about who outranks whom while the remedy is still a document change.

### 11.5 v1 entries carry no in-band version marker

A v1 per-load entry has **no version term in its preimage**. v1.1 §6.3.4 begins
with `LP(version)`; §11.4's form begins with `chainIndex`. There is therefore no
byte inside a v1 entry that says which form produced it, and a verifier selects
the form **out of band** — in Sigil's case from a nullable `chainVersion` column
that is NULL on all 91 v1 rows.

A verifier MUST NOT infer the version from whether a v1 or v2 recomputation
happens to match: trying both and accepting either is a downgrade oracle. It
MUST be told which form to apply, and MUST treat an unrecognised or absent
declaration as an error rather than defaulting.

> This is stated rather than fixed. Adding a version term to the v1 preimage
> would change every sealed v1 entry hash, so v1 chains are permanently
> version-ambiguous in-band and the marker lives beside them forever. **v2 fixed
> this by construction** — `LP(version)` is the first term of §6.3.4, and
> v1.1 §6.3.6 already requires an unrecognised version to be reported BROKEN
> rather than re-read as v1, which is the shape a downgrade attack takes.

### 11.6 The PAdES profile, amending v1.0 §5

v1.0 §5 says a sealed PDF carries "one **PAdES-B-LTA** conformant signature".
**It never has.** The sealer applied a CAdES-detached signature with no DSS, no
embedded validation data and no document timestamp — **B-B**. §5.1 separately
said the RFC-3161 token is "attached as an Unsigned Attribute
(`id-aa-timeStampToken`)"; a real token was obtained and stored **beside** the
seal record, and nothing attached it to the CMS.

For v1.1 producers and verifiers:

1. A sealed PDF carries a **PAdES baseline signature of at least B-B**.
2. A producer **SHOULD** attach an RFC-3161 signature timestamp as the
   `id-aa-timeStampToken` unsigned attribute, reaching **B-T**. Its message
   imprint is **SHA-256 of the SignerInfo `signature` octets** — the signature
   value, NOT the document digest.
3. A producer **MUST NOT** fail a seal because a TSA is unreachable. §5.1's
   tolerance stands: the seal degrades to B-B.
4. A verifier **MUST** report the level it observed **in the bytes**, and MUST
   NOT infer it from configuration or from this document. Sigil publishes it as
   `pades.level` on the verification report.
5. **`B-LT` and `B-LTA` are not claimed.** No document may assert a level the
   producer does not deliver.

> **Why the imprint is over the signature and not the document, which is the
> whole distinction.** Sigil already timestamped the document digest and kept
> the token on the seal record. That proves when the BYTES existed. It does not
> answer the question a court asks about a signature — _was this signed while
> the certificate was valid_ — because nothing binds that token to the
> signature. Only a token imprinted over the signature value does, and only
> that one is visible to Acrobat and every other PAdES reader **without
> trusting the operator's database**. Both are kept; they prove different
> things, and conflating them is how §5.1 came to describe something that was
> never done.

> **Why B-LTA is refused rather than deferred.** B-B, B-T and B-LT are
> properties of a sealing operation: do the work once and the artifact has them
> forever. **B-LTA is a standing operational obligation** — archive timestamps
> re-applied on a schedule so the evidence outlives algorithm aging. A claim of
> B-LTA is therefore a claim about a job that must still be running, and it
> decays into a false statement the moment that job stops, silently, on
> documents already in third parties' hands. For a utility whose entire product
> is that you need not trust the operator, that is the worst available failure
> mode. It gets claimed when the job exists and is alarmed, and not before.

> **The level is DERIVED, never stored, and nothing is backfilled.** A stored
> level is a claim about bytes; a derived one is a fact about them. This also
> settles the migration question by construction: every document sealed before
> signature timestamping existed reports `B-B` correctly and permanently.
> Re-sealing to "upgrade" one would change its bytes, which is the single thing
> this protocol forbids.

### 11.7 The report's recipient tuple, amending v1.0 §8

v1.0 §8 specifies `recipients: [ { name, email, role, status, completedAt } ]`.
Sigil emitted `name`, `email`, `completedAt` and a `signedByAgent` extension.
**`role` and `status` were absent.** §8 says "at least the following fields", so
the extension was conformant and the two omissions were not.

Both are now emitted, and this section fixes what `role` MEANS, because the word
alone admits two readings and picking wrong is not detectable by a verifier:

1. **`role` is the STRUCTURAL role** — one of `SIGNER`, `VIEWER`, `CC`. It is
   what changes how a reader interprets the artifact: whether a listed person
   was required to sign, or was merely copied.
2. **`role` is NOT a display label.** An implementation MAY hold a free-text
   role against a recipient; Sigil does, and it is caller-supplied. Such a label
   is operator-defined, so it is neither interoperable nor safe to publish, and
   it MUST NOT be sent as this field.
3. **`status` is per recipient** — `PENDING`, `NOTIFIED`, `COMPLETED` or
   `DECLINED`.

> **`DECLINED` is the reason `status` is not cosmetic.** A report carrying only
> `completedAt` cannot distinguish _everyone signed_ from _someone refused_, and
> those are different documents to anyone reading the evidence. A null
> `completedAt` collapses "has not signed yet" and "declined to sign" into one
> indistinguishable absence.

> **Why the ambiguity is called out rather than quietly resolved.** Sigil's own
> schema carries BOTH a `type` (`SIGNER`/`VIEWER`/`CC`) and a nullable free-text
> `role`. An implementer reading §8 against a similar schema has no way to know
> which this document means, and a verifier cannot tell them apart — both are
> strings in a JSON field. That is the same class of silence as §6.3.3's absent
> geo: three defensible readings, no observable difference, and the corpus
> unable to adjudicate.

### 11.8 The public seal record, amending v1.0 §8

v1.0 §8 makes it a MUST that a verifier return `status = UNKNOWN` when a document
has **"no matching public seal record"**. That phrase appears exactly once in the
published corpus and is defined nowhere: no shape, no location, no way to ask.

The consequence is the reason this section exists. Every other field §8 names is
computable from the PDF by anyone — `signatureValid`, `signerCommonName`,
`detail`, the document digest, the timestamp, the §11.6 PAdES level. Two are
not: `recognized`, and the `document` block. Both are a row in the operator's
database. So **§8 was the one section of this protocol that structurally
required trusting the operator**, and it did so inside a MUST, which meant a
third-party verifier could not be conformant at all rather than merely less
informed.

#### 11.8.1 The seal record

A **public seal record** is the operator's answer to one question: _did you seal
the document with this digest, and when._ It is retrieved by the SHA-256 of the
document bytes and carries exactly two fields:

| field         | type                   | notes                                           |
| ------------- | ---------------------- | ----------------------------------------------- |
| `known`       | boolean                | The operator sealed a document with this digest |
| `finalizedAt` | RFC 3339 UTC \| `null` | When. `null` whenever `known` is `false`        |

Endpoint **paths** are operator-defined, as in §10.7; the object shape above is
normative. Retrieval MUST be possible **without authentication** — a recognition
oracle behind the operator's own auth leaves the MUST exactly as unimplementable
as no oracle at all — and it MUST accept a digest in either hex case.

An unmatched digest MUST be a successful response carrying `known: false`. The
negative IS the answer §8's rule consumes; returning it as an error makes "no
such record" indistinguishable from "the operator is unreachable", and those
must not produce the same verdict. A malformed digest — anything that is not
exactly 64 hex characters — MUST NOT be answered `known: false`, because that
turns the caller's own formatting bug into an authoritative _this is not a Sigil
seal_.

Sigil's is `GET https://sealedby.com/seals/{sha256}`.

> **What this deliberately does not publish.** Existence and time, never the
> parties. `finalizedAt` is the only field here not already derivable from the
> PDF, and it is redundant with the signature's own timestamp. A SHA-256 is not
> guessable, so the only caller who can ask a meaningful question already holds
> the document or was handed its digest on purpose. Subject and the recipient
> list stay behind the verification code, where they were.

#### 11.8.2 The two halves of a §8 report

**Operator-independent — every conforming verifier MUST produce these**, from the
document bytes alone: `status` (per §11.8.3), `signatureValid`,
`signerCommonName`, `detail`, and `documentSha256`.

**`documentSha256` is the SHA-256 of the WHOLE FILE** — every byte the caller
handed the verifier — and it is the digest §11.8.1's lookup is keyed by. It is
computable for any input at all, so it MUST be emitted even when `status` is
`UNKNOWN` or `INVALID`; there is no input for which it is unavailable.

It is **NOT** the digest of the bytes the signature covers. Those are the
`/ByteRange` slices with the `/Contents` hole excised, they are a different
value, and a verifier that publishes them SHOULD do so under a distinct name —
the references use `signedByteRangeSha256`.

It is equally **NOT** the digest an operator has on record for the document.
That distinction is invisible for every intact artifact, because the two agree,
and it becomes the whole finding for exactly one verdict: on `TAMPERED` the
bytes in hand no longer hash to the digest that was sealed. `documentSha256`
MUST be **what the verifier hashed**, so that a reader comparing it against
`document.sha256` — where an operator emits that optional block — sees the
mismatch that produced the verdict. An implementation that populates the
top-level field from its own record instead is correct on every input except the
one the field exists to expose, which is the failure mode this section has
already produced once (§11.0.1, `documentSha256` as the byte-range digest).

> **This distinction is the single most consequential sentence in §11.8, and its
> absence made the whole section unusable, 2026-07-27.** Both published
> reference verifiers emitted the byte-range digest under the name
> `documentSha256`. A third party who built exactly to this section, took that
> field from their own report and called §11.8.1 therefore got `known: false`
> for **every genuinely sealed Sigil document**, and §11.8.3 rule 4 then
> required them to report `UNKNOWN` on all of them. §11.8.3's illustration of
> rule 4 — "a document nobody sealed" — became the verdict on every document
> everybody sealed.
>
> It was invisible to the corpus, which contains no sealed production record; to
> the parity gate, because both references agreed with each other and both were
> wrong; and to the compliance suite, which never performs a lookup. It was
> reachable only by an outsider holding a live endpoint and a published verifier
> and comparing what they said about one file. Found by clean-room run 3.

**`timestamp` is NOT in that list, and moving it there was the second half of
the same mistake.** See §11.8.4.

**Where these fields LIVE in the report.** v1.0 §8 shows the document digest at
`document.sha256`, inside a block this section makes OPTIONAL — so a verifier
that omits `document` would have nowhere to put a MUST field. Resolve it as the
published implementations do: the operator-independent fields are **top-level**,
named `documentSha256`, `signatureValid`, `signerCommonName`, `detail` and
`pades`. An operator MAY additionally carry the digest inside `document` for
v1.0 compatibility; Sigil's API does. Until 2026-07-27 the flat name appeared in
exactly one published place, `specReportKeys` inside a corpus fixture.
(Clean-room run 3, B10.)

> **"As the published implementations do" was true of two of the three, and the
> third was the one being described, 2026-07-27.** Both reference verifiers
> emitted `documentSha256` top-level when this paragraph was written. Sigil's
> API did not: it carried the digest only at `document.sha256`, inside the block
> this same paragraph makes optional and which it sets to `null` for every
> document Sigil did not seal. So the API satisfied the MUST on the one path
> where the value was least needed and dropped it on the two — `UNKNOWN` and
> `INVALID` — where re-asking §11.8.1 is the entire point.
>
> It is the fifth defect in this program of the form _a sentence asserting what
> implementations do, written without running them_, and the fourth written by
> the author of the section it describes. It was **held back deliberately**
> after discovery to see whether clean-room run 4 would find it from the
> published text alone. It did not, which is the more useful half of the
> result: this paragraph does not make the placement checkable by a reader who
> has only a report in front of them, because a report with a populated
> `document` block satisfies both readings at once. Closed in the API, and
> recorded in §11.0.1 rather than resolved by softening the rule.

The §11.6 `pades` level is deliberately NOT in that list. It is an extension
Sigil emits under §8's "at least the following fields", not something §8
requires, and a MUST list is not the place to quietly promote one operator's
extra field into everyone's obligation.

**Operator-dependent — conditional on a seal-record lookup:**

- `recognized` becomes `boolean | null`. **`null` means no lookup was
  performed.** A verifier that performs no lookup MUST report `null` and MUST NOT
  report `false`: `false` asserts that a record was asked for and did not exist,
  which is a different and much stronger claim than _I did not ask_.
- The `document` block is **OPTIONAL**. It is the operator's own record, not a
  property of the artifact, and no verifier is non-conformant for omitting it.

#### 11.8.3 `status`, and the collision v1.0 left unresolved

§8's `status` conflates two independent findings — _is this signature good_ and
_is this a record of ours_ — into one enum. That conflation is why the MUST could
not be implemented separately from the operator, and unpicking it exposes a
precedence question v1.0 never answers: §8 requires `TAMPERED` when the §5.2
step 2 hash mismatches, AND requires `UNKNOWN` when there is no matching seal
record. **A modified Sigil document satisfies both antecedents at once**, because
editing the bytes is exactly what makes the digest miss every record.

Precedence is therefore normative, highest first:

| #   | Condition                                             | `status`   |
| --- | ----------------------------------------------------- | ---------- |
| 1   | No PAdES signature dictionary                         | `UNKNOWN`  |
| 2   | §5.2 step 2 hash mismatch                             | `TAMPERED` |
| 3   | Trust anchor supplied and the fingerprint **differs** | `UNKNOWN`  |
| 4   | Lookup performed and returned `known: false`          | `UNKNOWN`  |
| 5   | Signature does not verify                             | `INVALID`  |
| 6   | **No trust anchor supplied AND no lookup performed**  | `UNKNOWN`  |
| 7   | Otherwise                                             | `VALID`    |

Rules 3 and 6 are §5.2 step 4, and §11.8.7 states them. **A conforming verifier
computes `status` through this table and nowhere else** — every terminal in both
references now routes through one function, because the first version of this
section was written at each call site and the sites drifted.

**Rule 2 outranks rules 3 and 4, and getting that backwards is the expensive
mistake.** Editing a sealed document is exactly what makes its digest miss every
seal record, so a tampered Sigil document satisfies those antecedents at once. It
must report _changed after sealing_; under the inverted order it would report
_not recognized_, which reads as **nothing to see here** on the one artifact
where something did happen. The stronger, intrinsic finding wins.

**Rules 3 and 4 outrank rule 5** so that a document nobody sealed is not
described as having a broken Sigil signature — `INVALID` implies the artifact
purported to be one of ours, and an unrecognized document makes no such claim.

**RULE 6 SITS BELOW RULE 5, AND THAT IS THE ONE ORDERING HERE THAT IS NOT
OBVIOUS.** Rules 3 and 4 are ACTIVE negatives: somebody asked a question and got
_no_. Rule 6 is an ABSENCE — nobody asked at all — and an absence must not erase
a finding the verifier actually computed. So it withholds `VALID` and never
demotes an `INVALID` signature or a `TAMPERED` document into _nothing to see
here_. This is the distinction §11.9 already drew between a root the caller asked
about and one the envelope merely asserted: **unsubstantiated is not
contradicted.**

> The corpus accepted either order. It was settled by threat model and then given
> a witness: `pdf/sealed-pod.json`'s `signature-byte-flipped` case carries no
> trust anchor, so rule 6's antecedent holds, and it expects `INVALID`. Place
> rule 6 above rule 5 and that vector fails. **It only became a witness when
> every terminal was routed through the table** — the early returns computed
> `INVALID` directly and the ordering had nothing testing it, which is the
> "defense with no witness" class this specification has now recorded four times.

**Rule 4 is §8's original MUST, now implementable**, and it binds any verifier
that performed a lookup, including the operator's own. A verifier that performed
no lookup never reaches rule 4: the antecedent is unestablished. It does not
thereby reach `VALID` — rule 6 catches it — unless a trust anchor vouched for the
signer instead. `VALID` with `recognized: false`, from a verifier that never
asked, remains wrong for the reason it always was.

**The two vouchers are ALTERNATIVES.** A seal-record lookup answering
`known: true` satisfies rule 6 on its own, because the operator asserting _I
sealed this document_ is a stronger statement than _this is the certificate I
told you about_. So a verifier folding a lookup into an already-computed report
MUST recompute `status` from the cryptographic verdict rather than patch the one
it has: a patch that only demotes cannot lift rule 6's `UNKNOWN` back to `VALID`,
and a document the operator has explicitly vouched for would stay reported as
unrecognized.

> **Why rule 4 is not cosmetic.** Verified against the reference implementations
> on 2026-07-27: a PDF sealed with a self-signed certificate whose subject reads
> `Totally Not Sigil, Inc.` / `Impostor LLC` returns `status: VALID`,
> `signatureValid: true`, exit 0 — from both published verifiers and from Sigil's
> own. Nothing is wrong with those verdicts as _cryptography_: the signature
> really does verify against the certificate embedded beside it. The defect is
> that `VALID` is read as an endorsement, and rule 4 is the only thing in §8 that
> was ever supposed to withhold it. It could not, because nobody could implement
> it.

#### 11.8.4 `timestamp` is operator-dependent, because a B-B seal has none

v1.0 §8's `timestamp` names two different artifacts, and until 2026-07-27 this
document put it on the operator-independent side of the split as though it named
one.

- **The in-band signature timestamp** (§5.1's `id-aa-timeStampToken`) lives in
  the bytes and is derivable by anyone. A **B-B** seal does not have one, and
  §11.6 point 3 makes B-B seals legitimate by construction: a producer MUST NOT
  fail a seal because a TSA was unreachable.
- **A document timestamp the operator obtained and stored** is real evidence —
  its RFC-3161 token verifies out of band — but it is not in the file, so no
  reader of the bytes can produce it.

Therefore: **`timestamp` is OPERATOR-DEPENDENT**, alongside `recognized` and the
`document` block. A verifier reading bytes alone MUST report the in-band
signature timestamp, or `null` when the seal carries none, and MUST NOT be
considered non-conformant for reporting `null` on a B-B seal.

An operator that publishes a stored document timestamp MUST make the two
distinguishable with a `source` member, one of `IN_BAND` or `OPERATOR_HELD`. A reader cannot otherwise tell whether the time
came from the artifact or from the party serving it, and those carry opposite
evidentiary weight.

**The TSA's identity is not part of the required shape.** It lives in the token
and both references report the token's presence, but reading the signing
certificate's subject CN needs a CMS parse one reference's library refuses, and
walking the DER for the first common-name OID finds the certificate's ISSUER
first. Two references reporting different authorities for one token is a worse
outcome than an absent field, so the field is absent.

> **Measured on the published sample, 2026-07-27.** `/verify/3w76SqBKAP62`
> reports `pades: { level: "B-B" }` — no timestamp in its bytes — while the
> operator's report carries a complete token from `https://freetsa.org/tsr`. Both
> published verifiers report `timestamp: null` for the same file. So two
> conforming verifiers gave different answers about when that document was
> timestamped, and the field driving the disagreement was listed as derivable
> from the bytes alone. This is the same conflation as `documentSha256` in
> §11.8.2: one name over two artifacts. Found by clean-room run 3.

#### 11.8.5 What "§5.2 step 2 hash mismatch" actually compares

v1.0 §8 requires `TAMPERED` "when §5.2 step 2 produces a hash that does not match
the signed bytes", and §11.8.3 rule 2 inherits that phrasing. **Read literally it
is circular** — a hash computed over the signed bytes always matches the signed
bytes — and §5 never names the value on the other side of the comparison.

The comparison is: the SHA-256 of the reconstructed `/ByteRange` bytes against
the **`messageDigest` signed attribute** (RFC 5652 §11.2, OID
`1.2.840.113549.1.9.4`) carried inside the CMS `SignedAttributes`. A mismatch
means the document changed after signing, and that is rule 2's antecedent.

Two failures that are NOT rule 2, because they are about the signature rather
than the document:

- the `SignerInfo` signature does not verify over the DER re-encoding of the
  signed attributes — that is rule 5, `INVALID`;
- the `/Contents` value cannot be decoded at all (it is not valid hex, or the
  DER will not parse) — **also rule 5**. There is no signature to fail; there is
  a blob that cannot be read. Rule 1 does not apply, because the signature
  DICTIONARY is present, and the distinction matters: rule 1 says _this is not a
  Sigil seal_, while an undecodable signature says _this claims to be one and is
  malformed_.

> Both points were inferred rather than read by clean-room run 3 — the first from
> the standard CAdES construction, the second from a corpus fixture whose byte
> flip lands inside the hex string. They reproduce the fixture's TAMPERED and
> INVALID split exactly, which is the evidence that the inference was right and
> also the reason it should not have had to be an inference: it is the rule the
> whole top half of §11.8.3's precedence table turns on.

#### 11.8.6 An unreachable operator is not an answer

§11.8.1 requires the negative to be a successful response so that "no such
record" and "the operator is unreachable" cannot produce the same verdict.
§11.8.2 then gives `recognized` three values, none of which says _I asked and
could not find out_.

A verifier whose lookup fails MUST NOT report `recognized: false` — that asserts
a record was sought and did not exist. It reports `recognized: null` and MUST
make the failure visible some other way: the reference CLIs **exit 2** and print
the reason to stderr rather than emitting a report at all, which is the only
unambiguous signal available in the current shape.

> **`null` is doing double duty here and this document is not pretending
> otherwise.** It means both _no lookup was attempted_ and _a lookup was
> attempted and failed_, and those differ. The honest fix is a fourth state, and
> that is a v1.2 shape change rather than an erratum, because adding a value to a
> published field changes what every existing reader must handle. Recorded now so
> the limitation is stated rather than discovered. (Clean-room run 3, B7.)

#### 11.8.7 The trust policy, amending §5.2 step 4

v1.0 §5.2 step 4 requires a verifier to "confirm the signing cert's subject CN
matches a configured trust policy (the verifier's responsibility — Sigil ships a
permissive policy for the reference verifiers)". **Both halves of that sentence
are defects**, and together they made the only step in §5.2 that could withhold
an endorsement do nothing at all.

**Matching the subject CN provides ZERO security here.** Sigil's production
signing certificate is SELF-ISSUED, so there is no CA chain to validate against
and nothing constrains what an impostor puts in a subject line. An attacker
self-issues a certificate whose CN reads `Sigil Document Signing`, signs a
document with it, and passes a CN check exactly. This is not hypothetical: it is
the same artifact §11.8.3 already records, a PDF whose subject read
`Totally Not Sigil, Inc.` returning `VALID` from all three verifiers. A defense
that an attacker satisfies by typing a string is not a defense.

**And a permissive default is indistinguishable from no policy.** A rule whose
shipped configuration accepts everything has never once been exercised by the
implementations that ship it.

The rules:

1. **The policy is a SET of SHA-256 digests over the DER encoding of accepted
   X.509 signing certificates.** This is what `openssl x509 -fingerprint -sha256`
   prints. An impostor cannot produce a certificate with a given fingerprint
   without inverting SHA-256, so unlike the CN this one holds.

   **A supplied fingerprint is NORMALISED before comparison: case is folded and
   `:` separators are removed.** A verifier MUST accept
   `03:9D:76:48:…` and `039d7648…` as the same policy entry.

   > **Because the sentence above tells the reader to paste what `openssl`
   > prints, and what it prints is `SHA256 Fingerprint=03:9D:76:48:…` —
   > uppercase and colon-separated.** Every published verifier already accepts
   > that spelling and none of them said so, so a verifier built strictly from
   > this rule REJECTED the exact string the rule pointed at, and reported
   > `signerTrusted: false` on genuine evidence because of a paste format.
   >
   > This is the THIRD caller-supplied hex in the document and was the only one
   > whose normalisation was unstated: §11.9 rule 4 settles the caller-supplied
   > root and §11.8.1 settles the seal-record digest, both on the reasoning that
   > **the party who chose the formatting is not the party being accused**. The
   > trust policy is the same shape and now says so. Clean-room run 12, A4.
   >
   > The rule is asymmetric in the same way §11.9 rule 4 is, and for the same
   > reason: this is a value the READER supplies, so the verifier absorbs the
   > formatting. Nothing here changes how a value carried INSIDE an artifact is
   > compared.

2. **A SET, not a value.** Signing certificates rotate, and a document sealed
   under a retired certificate stays genuine forever — the seal is historical.
   A single-value policy reports `UNKNOWN` on every document sealed before the
   last rotation, which is a false disavowal of real evidence.
3. **A verifier MUST report `signerTrusted`, `true | false | null`, where `null`
   means NO POLICY WAS SUPPLIED.** This is the §11.8.6 reasoning that governs
   `recognized`, and it binds for the same reason: `false` asserts that a policy
   was supplied and this certificate is not in it, which is a much stronger claim
   than _I was given nothing to check against_. An EMPTY policy is `false`, not
   `null` — it was supplied and it matches nothing.
4. **A verifier SHOULD report `signerCertSha256`**, the fingerprint it observed,
   or `null` where no certificate could be read.
5. **There is no default policy, and a verifier MUST NOT ship one.** Supplying
   nothing means nothing is trusted, and §11.8.3 rule 6 is what that costs.

**The fingerprint MUST arrive from outside the document, and this is the §11.9
trap one section over.** `signerCertSha256` is reported so a caller can COMPARE
it against a pin they already hold. A caller who reads it out of a report and
hands it straight back as a policy has checked the artifact against itself,
which is precisely what `rootCorroborated` exists to refuse. An operator
publishes its fingerprints the same way it publishes checkpoint roots: out of
band, at a stable location, ahead of any dispute.

> **What this costs, stated plainly rather than discovered.** Every OFFLINE
> verification of a genuine Sigil document — no trust anchor, no network — now
> reports `UNKNOWN` where it reported `VALID`, and the reference CLIs exit 1
> where they exited 0. That is the intended effect and not a side effect: a
> verifier holding only the bytes genuinely cannot tell Sigil's self-issued
> certificate from an impostor's, and `VALID` is read as an endorsement. The
> cryptographic finding is not lost — `signatureValid` stays `true` and the
> `detail` says which question went unanswered — and either a `--trust-cert`
> fingerprint or a seal-record lookup restores `VALID`.
>
> Two corpus vectors carry the cost so it cannot be reintroduced quietly:
> `pdf/sealed-pod.json`'s `genuine` and `pdf/sealed-bt.json`'s `genuine-b-t`
> both moved from `VALID` to `UNKNOWN`. **The second was missed when the change
> was planned** — a B-T seal carries an external RFC-3161 timestamp, which fixes
> WHEN the bytes existed and says nothing about WHOSE certificate signed them,
> so it does not substitute for a trust anchor.

**What the corpus pins, and what it does not.** The three `genuine*` cases in
`pdf/sealed-pod.json` are BYTE-IDENTICAL and differ only in the caller's input,
so no hardcoded answer survives the set; `genuine-trust-anchor-differs` anchors
on a REAL second Sigil certificate whose CN reads `Sigil Document Signing (dev)`,
which is what catches an implementation that matched on the name. **Not pinned:
the combination of a signature that fails to verify AND a fingerprint that
differs** — rule 3 above rule 5 — because the corpus has no PDF with a readable
certificate and an unverifiable signature. Stated here rather than left to be
assumed covered.

### 11.9 `expectedRoot` in the envelope corroborates nothing, amending §10.5

§10.5 condition (4) requires a checkpoint root **obtained independently of the
party serving the evidence**. §6.3.8 lists `expectedRoot` as a member of the
served envelope. Those cannot both be satisfied by the same value: an envelope
member arrives WITH the artifact, which is precisely the class §6.3.8 point 7
already rules out for the load id — _a value that arrived with the artifact
cannot corroborate the artifact_.

The rules, and they mirror `expectedLoadId` exactly because the reasoning is
identical:

1. **`rootCorroborated` is `true` ONLY when the verifier was given a root from
   OUTSIDE the document** — the §10.7 feed, or one parsed out of the RFC-3161
   token. A verifier MUST accept such an input; the references take
   `--expect-root` / an `expectedRoot` option.
2. **An envelope `expectedRoot` equal to the served anchor root sets nothing.**
   The operator agreeing with itself is not evidence.
3. **An envelope `expectedRoot` that DISAGREES with the served anchor root is
   `BROKEN`** (§6.3.9 point 1 condition 4). That is the operator contradicting
   itself, which is a malformed document rather than a corroboration failure.
4. **A CALLER-SUPPLIED root is compared CASE-INSENSITIVELY; an ENVELOPE root is
   compared as BYTES.** The rule is provenance: hex the reader supplies is
   normalised, hex the document carries is not.

   §11.8.1 already states the reader half for the seal-record digest — "MUST
   accept a digest in either hex case" — and this generalises it to the other
   input a reader types. §6.3.4.1 point 3 already states the document half for
   `payloadHash` against `payloadRoot`, on the reasoning that a document field
   has a producer who can be held to §6.3.4.2's lowercase domain. A reader has no
   producer.

   > **The distinction is not tidiness, and the reason is who gets accused.**
   > §6.3.9 point 1 condition 4 makes a disagreeing out-of-band root `BROKEN`,
   > and rule 3 below frames that as the operator-minted-root attack arriving in
   > the open. Under an exact comparison **the party who chose the formatting is
   > not the party being accused**: a reader who pasted the root uppercase
   > produced the gravest accusation this protocol makes, against an operator
   > that had done nothing. §10.6's imprint is 32 RAW BYTES, and openssl and most
   > C and Java tooling hex-encode uppercase — so the reader most likely to trip
   > it is the one who did exactly what §10.5 condition (4) asks and obtained the
   > root independently.
   >
   > The envelope half resolves the other way for the mirror-image reason. Both
   > values were emitted by the SAME producer in ONE document, so a case
   > disagreement between them is that producer being internally inconsistent
   > about its own §6.3.4.2 domain, and §6.3.9 point 7's reason names the member
   > so it can be fixed. Nobody else is accused of anything.
   >
   > Nothing is given up by normalising the reader's side. Hex is
   > case-insensitive as a value, folding creates no collisions, and an attacker
   > gains nothing an exact match denied them.
   >
   > Pinned by `bundles/out-of-band-root-uppercase.json` and
   > `bundles/envelope-root-case-mismatch.json`, which resolve OPPOSITE ways over
   > the same root: **a verifier that normalises both sides passes the first and
   > fails the second**, which is how the pair was mutation-checked. Both
   > published packages compared exactly on both sides. Clean-room run 11, D7.

5. `rootCorroborated: false` is not a failure. It means the reader has an
   inclusion claim rather than an external-timestamp claim, and §10.5 already
   requires that it not be reported as "anchored" without qualification.
6. **An envelope `expectedRoot` with NO anchor is REPORTED and is not a
   failure.** Rule 3 is a self-consistency test between two members of one
   document, so it needs both; with no anchor served there is nothing to
   disagree with. The anchor block is emitted with `inclusionValid: null`,
   `rootCorroborated: false` and a reason (§10.5), and the bundle stays
   `VERIFIED`. **Unsubstantiated is not contradicted**, and the mirror case in
   §10.5 — a CALLER root with no anchor — is `BROKEN` for the one reason that
   does not apply here: somebody asked.

> **Measured, 2026-07-27.** A bundle whose envelope carried `expectedRoot` equal
> to its own served anchor root was reported `rootCorroborated: true` by **all
> three verifiers** — both published references and Sigil's — over a document in
> which every byte a reader could compare came from the party being checked. So
> the protocol's strongest guarantee, the one §10.0 exists to provide and the one
> the neutrality claim rests on, was satisfiable **by the operator alone**.
>
> Wave 3 added condition (4) and the reporting flag but put the input in the
> envelope, and the load-id defence built later got the seam right without anyone
> noticing the anchor had not. Found by clean-room run 4, which minted a
> checkpoint over its own chain and served it to itself.
> `fixtures/bundles/self-corroborated-root.json` is the vector, and it pins the
> FLAG rather than the status, because the status is `VERIFIED` either way and
> the entire defect lived in the flag.

## Appendix A: reference implementations

- TypeScript: `protocol/reference-verifier-typescript/` (npm `sigil-protocol-verifier` 3.0.0)
- Python: `protocol/reference-verifier-python/` (PyPI `sigil-protocol-verifier` 3.0.0)

Both implement §6.3 and §10 independently of `@sigil/crypto` and of each other,
and both are run over the same corpus by the Compliance Test Suite, which fails
on any divergence between them. The suite's v2 and anchor vectors are generated
by the production sealer, so passing them is evidence of agreement with what
Sigil actually seals rather than agreement with whoever wrote the fixtures.
