# Independent verification of ConversaSure evidence

ConversaSure describes its evidence as tamper-evident. This document explains how anyone, including a regulator, auditor or insurer, can test that claim without a ConversaSure account and without trusting ConversaSure's software.

## What you need

- **The evidence manifest** (`evidence-manifest-<case>.json`). The firm downloads it from the case page (*Evidence manifest* button) and sends it with the evidence pack.
- **The evidence documents** (PDFs), if you want to check them too.

## Three ways to check

| Tool | Where | Trust required |
|---|---|---|
| Web verifier | `https://www.conversasure.co.uk/verify` | Runs in your browser; files are never uploaded. It is still ConversaSure code. |
| Offline verifier | `/verifier/conversasure-verifier.html` | A single file. Save it, disconnect from the network, open it. |
| Python reference verifier | `/verifier/verify_manifest.py` | About 300 lines of standard-library Python, written separately from the platform code. Read it, then run `python3 verify_manifest.py manifest.json pack.pdf`. |

All three implement the rules below. ConversaSure's test suite (`scripts/smokeIndependentVerifier.ts`) runs both the TypeScript and Python implementations on the same manifests, including records written by the production platform, and fails if they disagree.

## What is checked

### 1. The manifest itself
`manifest_digest = sha256(stableStringify(manifest without manifest_digest and signature))`. A mismatch means the manifest was edited after issue.

If `signature` is present, it is an Ed25519 signature over the UTF-8 bytes of `manifest_digest`, made with the key published at `/api/public/verify/keys`. Compare the key fingerprint (SHA-256 of the DER public key) with the published one. With OpenSSL:

```sh
jq -r .signature.public_key manifest.json | base64 -d > key.der
openssl pkey -pubin -inform DER -in key.der -out key.pem
jq -r .signature.value manifest.json | base64 -d > sig.bin
jq -j .manifest_digest manifest.json > digest.txt
openssl pkeyutl -verify -pubin -inkey key.pem -rawin -in digest.txt -sigfile sig.bin
sha256sum key.der    # compare with fingerprint_sha256 at /api/public/verify/keys
```

### 2. Decision Integrity Records (the sealed decision position)
For each record, in `record_sequence` order (1, 2, 3, … with no gaps):

- `record_hash = sha256(stableStringify(body))`, where `body` is these fields of `record`: `case_id`, `decision_integrity_version`, `dependency_status`, `dependency_conflicts`, `summary`, `evidential_position`, `decision_challenge`, `governance_position`, plus `source_assessments`, `generated_by` and `generated_at` taken from `record.provenance`.
- `prev_hash` is the previous record's `record_hash` (`null` for the first).
- `chained_hash = sha256(record_hash + (prev_hash or ""))`. The two hex strings are concatenated directly.

### 3. Governance records (each governance evaluation)
Ordered by `record_sequence`, with records that have no sequence last:

- `row_hash = sha256(JSON.stringify({case_id, previous_state, next_state, actor, actor_role, timestamp, outcome, signal_codes, metadata: {escalation_level, execution_permission, triggered_by, snapshot_hash}}))`, with keys in exactly that order. `timestamp` is `evaluated_at` as UTC ISO-8601 with milliseconds and a `Z` suffix; `outcome` is `governance_outcome`; `signal_codes` is sorted.
- `chained_hash = row_hash` for the first chained record. Otherwise it is `sha256(<previous record's row_hash> + ":" + row_hash)`.
- **Older records.** Records with no `row_hash` predate hash chaining and are listed for completeness. Records without `actor`, `actor_role` or the state fields predate hash provenance: the values originally hashed were never stored, so their content cannot be re-hashed. Their chain link is still checked.

### 4. Outcome records (what actually happened, against what the platform predicted)
In the order given:

- `record_hash = sha256(JSON.stringify(canonicalise(payload)))`. `canonicalise` rebuilds every object with its keys inserted in sorted order. Note that JavaScript's `JSON.stringify` then emits integer-like keys first.
- `prev_hash` is the previous record's **`chained_hash`** (`null` for the first).
- `chained_hash = sha256(record_hash + (prev_hash or ""))`.

### 5. Documents
`documents[].file_sha256` is the SHA-256 of the exact PDF bytes ConversaSure stored and issued. Hash the file you were given, for example with `sha256sum pack.pdf`, and compare.

The **Content Hash** printed on a pack's *Document Verification* page covers only the pages before that page, so it cannot be reproduced from the final file. Use `file_sha256` or the register to check a file. Documents issued before file fingerprints were recorded (7 October 2026) carry only the printed content hash.

## Canonical JSON (`stableStringify`)
Objects are serialised with keys sorted by UTF-16 code unit and no whitespace. Arrays keep their order. Strings and numbers use JavaScript `JSON.stringify` rules: shortest round-trip number form, and `\uXXXX` escapes only for control characters and lone surrogates. Non-ASCII text is emitted as-is, encoded as UTF-8.

## Confirming origin: the public register
Hash chains prove that a manifest is internally consistent. They cannot prove who produced it, because anyone can build a new, self-consistent chain. To confirm origin, either check the signature against the published key, or look up a hash in ConversaSure's register:

```
GET https://www.conversasure.co.uk/api/public/verify?hash=<sha256>
```

The register accepts a PDF's SHA-256, a printed content hash, or any record or chained hash from a manifest. It says whether ConversaSure recorded that hash, when, and whether a later record has since **superseded** it (for example, the case's decision was re-sealed after this pack was produced). It never returns case content, case identifiers or names.

## What a pass means and does not mean
A pass means the records are exactly as they were when hashed, and that none has been removed, inserted or reordered within its chain. A valid signature or a register match means ConversaSure issued them. It does not mean the decision recorded was correct; that is for your own review of the evidence.

## Known limitations
- **Unsigned manifests.** The signature is present only when the deployment has a signing key (`EVIDENCE_SIGNING_PRIVATE_KEY`). Without one, confirm origin with the register.
- **The register is operated by ConversaSure.** A register match is ConversaSure's own statement. The signature, once enabled, is checkable offline against a key you have recorded independently.
- **Governance pointer anomaly (explained).** A production-wide check on 7 October 2026 found 46 governance records whose `previous_row_hash` field does not match their predecessor, although their `chained_hash` links correctly. All 46 are orphaned rows from a deleted test case, with `previous_row_hash` set to zeros by the chain-tamper test (`scripts/smokeGovernanceChainIntegrity.ts`), which now cleans them up. No customer case was affected, and the rows were removed on 8 October 2026. Verifiers report this pattern as a warning ("declared pointer inconsistent; cryptographic link intact") rather than as tampering, because the cryptographic link is intact.
