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

# Sigil Protocol v1.1 — Compliance Test Suite (CTS)

A vendored corpus of fixtures that other implementations run against to
claim "Sigil-Protocol compatible".

This suite is **versioned with the spec**. Adding a fixture to a cut version
means the fixture exercised a behaviour that wasn't previously witnessed — it
does not change what conformance means.

## Structure

```
fixtures/
  chain/                  — v1.0 §3 per-org chains
    valid/                — chains that MUST verify
    tampered/             — chains that MUST NOT verify (broken at a given index)
  load-chain-v2/          — v1.1 §6.3 per-load chains, v2 canonical form
    valid/                — chains that MUST verify
    tampered/             — one edited field per fixture, so a pass shows THAT
                            defense fired and not a side effect of a broad edit
    openings/             — §6.3.7 disclosures that MUST (or MUST NOT) open
  anchor/                 — v1.1 §10 external chain-head anchoring
    valid/                — inclusion proofs that MUST verify
    tampered/             — wrong root, rewound index; MUST be rejected
  anchored/               — v1.1 §10.5 chain + anchor together; the foreign-head
                            case MUST NOT be reported as anchored
  served-shapes/          — one event in equivalent serializations; all MUST
                            hash identically (omitted vs null key, integral
                            floats), and an EMPTY id MUST differ from an absent one
  geo/                    — §6.3.3 canonical geo strings from the production
                            sealer; every implementation MUST match byte for byte
  payload-root/           — v1.1 §6.4 payload roots (see the table below)
  bundles/                — whole served bundles, the input `sigil-verify chain`
                            reads; each records its expected VERIFIED/BROKEN
  canonical/
    pairs/                — equivalence classes; canonical hashes MUST match
```

`anchored/`, `served-shapes/` and `geo/` were added after an adversarial review
found two live TypeScript/Python divergences this corpus structurally could not
see: `verifyChainAnchored` and `canonicalizeGeo` were never compared across
languages at all, and the only null-`envelopeId` fixture wrote the key
explicitly, so both harnesses took the same branch. Each family exists because a
real defect slipped past everything else.

The `load-chain-v2/` and `anchor/` fixtures are **generated by the production
sealer** (`gen-v2-fixtures.mjs`, in the Sigil repository).

That provenance is the point: passing them is evidence of agreement with what
Sigil actually seals, not agreement with whoever typed the fixtures.

The `payload-root/` fixtures have the same provenance
(`gen-payload-root-fixtures.mjs`) — with two stated exceptions, both of which are digest-free by nature.
`payload-root/unpaired-surrogates.json` is hand-maintained and the generator does
not emit it; and a few cases inside the generated files are DECLARED rather than
echoed (the depth and nested-array refusals in `limits.json`, the
`malformedProofStep` family in `disclosure.json`). Each of those asserts that an
input MUST be refused or that a malformed proof MUST NOT verify, so there is no
digest for a production run to provenance, and echoing would defeat the purpose:
those families exist precisely because implementations disagreed about them. Spec
§6.4.11 says the same thing; the emission sites in the generator say it a third
time, next to the code.

## §6.4 payload roots

**What each file covers is stated once, in [spec §6.4.11](../../docs/protocol/v1.1/SPEC.md).**
It is not restated here; a second copy is a second thing to keep in sync, and the
one that drifts is always the copy. What follows is what the RUNNER does with
them, which the spec does not and should not describe.

`merkle-shapes.json` is the one to run first when a root disagrees at some leaf
counts and not others. The runner **produces** each authentication path and
compares it to the recorded one rather than only verifying the recorded one:
verification computes `node(acc, acc)` either way at a self-sibling step, so it
cannot observe whether `siblingIsRight` was serialized the way §6.4.9.2 fixes it.

Two notes on how this family is compared across languages, both learned the hard
way:

- **A verdict carries only hex strings, booleans and integers — never a raw
  payload number.** The reason given here until 2026-07-26 was that
  `stableStringify` renders JavaScript's `-0` as `0` while Python's
  `json.dumps(-0.0)` emits `-0.0`. **That claim is false and testable in one
  line:** the runner `JSON.parse`s the Python harness's stdout before comparing,
  and `JSON.parse('-0.0')` is JavaScript's `-0`, which stringifies to `0` —
  identical to the TypeScript side. The rule outlived its stated reason, which is
  the worst way for a rule to survive.
  The real hazard is `NaN`. `json.dumps(float('nan'))` emits a bare `NaN` token,
  which is not valid JSON, so `JSON.parse` on the harness output throws and the
  runner reports ONE generic "harness did not emit valid JSON" failure and skips
  **every** parity check in the run. One echoed non-finite number does not cost
  one verdict; it costs the whole cross-language gate, and it does so while the
  suite still prints a tidy failure count. `Infinity` has the same shape.
- **Only the REFUSAL rides the parity gate for `rejected.json` and
  `limits.json`, not the error identity.** Those fixtures record `expectedError`
  as `TypeError` / `RangeError` and `expectedMessage` with JavaScript's own
  number rendering (`1e+21`, `10000000000000000`). Neither is reproducible in
  another language — Python has no `RangeError`, and `repr(1e16)` is `1e+16`. The
  TypeScript side asserts the exact class and message; both sides assert the
  language-neutral half, which is that the value is refused. A third-party
  implementation should read `expectedError` as "this MUST be refused", not as a
  required exception type.

### `limits.json` describes its payloads by CONSTRUCTION

A cap fixture would otherwise be an 8192-element array written out longhand, so
`limits.json` gives a `build` recipe instead of a `payload`. Three shapes, and
this is the normative reading of each — they were an undocumented mini-language
until clean-room run 2 had to reverse-engineer them from the data:

| `build.type`          | fields                             | payload it denotes                                         |
| --------------------- | ---------------------------------- | ---------------------------------------------------------- |
| `integer-array`       | `key`, `length`, `element`         | `{ [key]: [element × length] }`                            |
| `nested-arrays`       | `key`, `outer`, `inner`, `element` | `{ [key]: [ [element × inner] × outer ] }`                 |
| `nested-object-chain` | `key`, `depth`, `element`          | `element` wrapped in `depth` nested `{ [key]: … }` objects |

Why each exists, since the shapes are not interchangeable:

- `nested-arrays` spreads the leaves across many SHORT arrays. An implementation
  that guards one array's declared `length` instead of counting leaves as it
  collects them seals this payload and is refused by nothing.
- `nested-object-chain` has exactly **one** leaf at any depth, so the leaf cap
  can never fire on it. Only §6.4.6's depth cap can refuse it, which is the whole
  reason both caps exist rather than one.

`leafCount` is what the walk MUST have collected.

**Read the expectation from the explicit marker, never from the presence of
`root`.** `root` is present only on cases expected to succeed, but the converse
does NOT hold, and inverting it silently breaks the family's only
over-rejection control:

| marker                   | meaning                                                           |
| ------------------------ | ----------------------------------------------------------------- |
| `expectedError` present  | MUST be refused (the error identity is not normative — see above) |
| `expectedRefusal: true`  | MUST be refused                                                   |
| `expectedRefusal: false` | MUST be **accepted**                                              |
| no marker                | MUST be accepted; compare `root` and `leafCount`                  |

`at-the-depth-cap` carries `expectedRefusal: false` and no `root`. A runner
keyed on `root` presence scores it as a required refusal — converting the one
case that proves you do **not** over-reject into a case you pass **by**
over-rejecting. Found by clean-room run 3, which wrote exactly that runner.

### `pdf/*.json` describes its cases by MUTATION

Like `limits.json`'s `build`, the `mutate` member is a recipe rather than a
payload, and it was undocumented until clean-room run 3 recovered it from
`run-suite.mjs`. Three forms:

| `mutate`                   | what the harness feeds the verifier                     |
| -------------------------- | ------------------------------------------------------- |
| `null`                     | the fixture PDF unchanged                               |
| `{ "offset": N }`          | the PDF with **`bytes[N] ^= 0xff`** — one byte inverted |
| `{ "replaceWith": "..." }` | those literal bytes instead of the PDF                  |

`expectedStatus` is the §8 `status` the case MUST produce. `expectedPadesLevel`,
where present, is the §11.6 point 4 level the verifier MUST observe in those
bytes — key presence alone would pass an implementation that hardcoded `B-B`.

### The fixture generators are NOT in this distribution, deliberately

`gen-v2-fixtures.mjs`, `gen-payload-root-fixtures.mjs` and
`gen-composition-fixture.mjs` live in the Sigil repository and are excluded from
the published tree and the archive. They are the production implementation of
§6.3 and §6.4 in executable form, so shipping them beside a specification an
implementer is meant to build from would hand over the answer key to the exact
sections under test. Nothing here needs them: `run-suite.mjs`, `py_harness.py`
and the adapter import none of them, and a third party has no fixtures to
regenerate.

If you are running a clean-room implementation, this is one fewer thing you have
to decide not to look at. It used to be a decision, which is a weaker guarantee
than an absence.

## How to claim compliance

Run your implementation against every fixture and pass:

- Every `*/valid/*.json` MUST verify.
- Every `*/tampered/*.json` MUST fail, with the expected `brokenAtIndex` where
  the fixture records one.
- For every pair in `canonical/pairs/*/{a,b}.json`, your canonical hash of
  `a.json` and `b.json` MUST be identical.
- For `anchor/*`, you MUST recompute the leaf from the head rather than trust
  the `leaf` field the fixture carries (§10.4).

## Running it

```bash
node protocol/compliance-suite/run-suite.mjs
```

The runner computes a verdict for every fixture with the **TypeScript**
reference, computes the same verdicts with the **Python** reference (via
[`py_harness.py`](./py_harness.py)), and fails on either a verdict that
contradicts a fixture's expectation or **any disagreement between the two
implementations**. A divergence between the references is a release blocker.

The TypeScript reference must be built first (`npm run build` in
`../reference-verifier-typescript`), since the runner imports its `dist/`.

A missing Python interpreter is a **failure, not a skip** — a cross-language
corpus that only ever runs one language proves nothing about interoperability,
which is the entire claim a published verifier makes. `SIGIL_CTS_SKIP_PYTHON=1`
acknowledges the gap deliberately and still prints, loudly, that parity went
unverified; such a run does not support a conformance claim.

## Compatibility checklist

A conforming implementation MUST:

- [ ] Pass every chain fixture (valid + tampered), v1.0 §3 and v1.1 §6.3.
- [ ] Match every canonical pair's hash.
- [ ] Reject a load-chain entry whose `version` is unrecognized, rather than
      re-reading it under another form (§6.3.6).
- [ ] Enforce the caller-supplied expected load id; the §6.3.4 binding alone
      does not stop a transplanted chain.
- [ ] Recompute anchor leaves from the head (§10.4), and refuse to report a
      chain as anchored unless the evidence's head IS that chain's head (§10.5).
- [ ] Produce a verification report with the §8 fields populated.

An implementation that **produces** a `payloadRoot`, or that **checks a payload
field disclosure**, MUST additionally (§1, §6.4):

- [ ] Reproduce every digest in `payload-root/`, and agree with every
      `expectedValid`, `expectedMember`, `expectedError` and `expectedRefusal`
      marker.
- [ ] Refuse any string — key or value, at any depth — that has no UTF-8
      encoding, i.e. one containing an unpaired surrogate (§6.4.1.1). Never
      substitute `U+FFFD` for it: substitution collides two payloads onto one
      root and lets a genuine proof authenticate a value that was never sealed.
      Both `U+FFFD` itself and well-formed astral pairs MUST still be accepted.
- [ ] Refuse a payload nested past `MAX_PAYLOAD_DEPTH` (64), checked on descent
      (§6.4.6). The 8192-leaf cap does not cover this — a 1500-deep payload has
      one leaf.
- [ ] Enforce the §6.4.9 proof-step schema rather than coercing it:
      `siblingIsRight` MUST be a JSON boolean, and a malformed step MUST make
      verification return false rather than raise.
- [ ] Refuse — never truncate, never fall back — any number outside §6.4.2's
      accepted band, and accept every boundary value the `accepted` block lists.
      `0` is the trap: integral, therefore decided before the magnitude band.
- [ ] Sort leaves by ENCODED path compared as UTF-8 bytes, not by raw key and not
      by UTF-16 code unit.
- [ ] Fold interior nodes with §10.2's pipe-delimited node function, NOT §6.3.1
      length prefixing. Getting this wrong still reproduces every single-leaf
      root correctly, which is the worst kind of partial agreement.
- [ ] Recompute the leaf from `(path, value)` when verifying a disclosure, and
      return false rather than raising when that recomputation refuses.
- [ ] Keep a string path step (object key) distinct from a number path step
      (array index) across the wire boundary (§6.4.9.1).

A verifier that only WALKS a chain needs none of the above: it treats
`payloadRoot` as an opaque 64-hex field inside the §6.3.4 preimage.

Capability tokens (§7) are **not covered by this corpus and not implemented by
either reference verifier** — there are no token fixtures, so the two rules
below cannot be checked here. They remain requirements on an implementation that
chooses to verify tokens, and are listed to say what is _not_ tested rather than
to imply it is:

- [ ] Reject any `alg` other than `EdDSA` for capability-token verification.
- [ ] Reject any `typ` other than `sigil-cap+jwt` for capability tokens.

**Structured device attestations are not covered** (§6.3.2.2). Every `attest`
opening in this corpus carries a flat string, which is what v1.1 specifies and
what is fully reproducible across implementations. Sigil's own producer also
accepts a **structured** attestation blob and serializes it with a sorted-key
stable stringify before committing; that serialization is not part of v1.1 and
has no fixtures here.

Be precise about what this does and does not break, because the intuitive
reading is wrong: the escrowed opening carries the already-serialized string, so
`commit(value, salt)` stays reproducible and such an opening verifies normally.
What is untested and unspecified is the step **before** it — deriving that string
from the original object. So the commitment is checkable and its binding to the
underlying attestation is not. Specifying a canonicalization would change every
`attestCommit` already sealed over a nested object, which is a canonical-form
change requiring a new protocol version rather than an erratum.

A conforming implementation SHOULD:

- [ ] Surface broken-at-index when a chain fails.
- [ ] Distinguish UNKNOWN ("not a Sigil seal") from INVALID ("malformed
      seal").
