CELESTEEvidence verifier
Protocol

Verification protocol

Exactly what verifier.celeste7.ai checks when you drop a sealed export (a sealed CelesteOS PDF), so that anyone can reproduce the result independently. Everything below runs in the browser with public SHA-256 maths; no secret, key or network call is involved. For a plain-language summary see what a pass means.

Scope of a pass. A pass means the content matches the integrity digests recorded in the same file. The verifier does not validate the digital signature or the timestamp, so it does not confirm who sealed the export or when. See Limitations.

1. Input: receipt.json

Only PDF files are accepted. The verifier reads the PDF's embedded file attachments (PDF/A-3 associated files), takes the first attachment with content, the sealed receipt.json (the receipt), decodes it as UTF-8 and parses it as JSON. A PDF with no embedded attachment, or one whose attachment is not valid JSON, is rejected. Text printed on the PDF pages is not part of the check (see Limitations).

The parsed JSON is classified into one of four shapes:

ShapeRuleTreatment
v1 envelopeA JSON object with schema_version exactly "v1", a string receipt_id, a payload object whose records and ledger_events are both arrays, and an integrity object with non-empty string payload_sha256 and chain_root.All checks in section 3.
Legacy v0A top-level JSON array of events (early receipts, no integrity block).Structural checks only; always amber.
Malformed v1An object carrying any of schema_version, receipt_id, integrity or a payload object, but not a valid v1 envelope.Red: the integrity block is missing or cannot be recomputed.
UnrecognisedAnything else.Rejected as not a CelesteOS receipt.

Fields used

2. Canonical JSON

Digests are computed over a canonical serialisation of the parsed JSON value:

This is byte-identical to Python's json.dumps(value, sort_keys=True, ensure_ascii=False) once whole-number floats are converted to integers, which is what the reference script below does.

{"records": [{"id": "r1", "name": "thing"}], "ledger_events": [...]}
  → {"ledger_events": [...], "records": [{"id": "r1", "name": "thing"}]}

3. Checks on a v1 envelope

3.1 Integrity counts

integrity.record_count must equal the length of payload.records, and integrity.event_count the length of payload.ledger_events. Mismatch: fail.

3.2 Event boundary timestamps

For each event take event_timestamp, else created_at. The smallest and largest of these (compared as strings) must equal integrity.first_event_at and integrity.last_event_at. Mismatch: fail. With no events, or no timestamps, the check is informational only.

3.3 Hash-chain walk

Walking payload.ledger_events in array order: every proof_hash must match ^[0-9a-f]{64}$, no proof_hash may repeat, and timestamps (created_at, else event_timestamp) must be non-decreasing as strings. Any violation: fail. The proof_hash values themselves are taken as given. They are computed over identifiers that are not in the file, so they cannot be recomputed publicly.

3.4 Payload digest: payload_sha256

Build the object {"records": payload.records, "ledger_events": payload.ledger_events}, canonicalise it (section 2), and take SHA-256 of the UTF-8 bytes. The lowercase hex digest must equal integrity.payload_sha256. Mismatch: fail, with one exception: if generated_at parses as a date before 2026-05-05T12:42:41Z, a mismatch is reported as a caution (amber), because receipts from before that date used an earlier number serialisation. Such a file is treated as unverified, never as a pass.

3.5 Merkle root: chain_root

A binary SHA-256 Merkle tree over the events' proof_hash values:

leaves = [hex_decode(e.proof_hash) for e in payload.ledger_events
          if e.proof_hash is a non-empty string]        # array order, 32 bytes each
if leaves is empty:
    root = SHA-256("empty")                              # the 5 ASCII bytes
else:
    level = leaves
    while len(level) > 1:
        next = []
        for i in 0, 2, 4, ...:
            left  = level[i]
            right = level[i+1] if it exists else left    # odd node: paired with itself
            next.append(SHA-256(left || right))          # raw 32+32 bytes, not hex
        level = next
    root = level[0]
chain_root == lowercase_hex(root)

Mismatch with integrity.chain_root: fail.

3.6 Envelope identity digest: envelope_sha256 (newer receipts)

If integrity.envelope_sha256 is present: remove the payload and integrity keys from the top-level object, canonicalise everything that remains, and take SHA-256. It must equal integrity.envelope_sha256; mismatch: fail. This binds the outer fields (domain, receipt_kind, requested_by_role, generated_at, scope_descriptor and so on). If absent (older receipts), the check is informational and those outer fields are not bound by any digest.

3.7 Structural rows (all shapes)

4. Verdict

ResultRule
Red: failedAny check fails (including a malformed v1 file).
Amber: cautionsNo failures, but any caution (legacy v0 file, pre-2026-05-05 digest mismatch, unordered events, missing proof_hash).
Green: content matches its sealNo failures and no cautions. Informational rows never change the verdict.

5. Limitations

6. Reproduce it yourself

Extract the embedded receipt.json from the PDF (for example with pdfdetach -saveall file.pdf from Poppler, or any PDF library that lists embedded files), then run this standard-library Python 3 script. It recomputes checks 3.1, 3.4, 3.5 and 3.6 and prints one boolean per check.

#!/usr/bin/env python3
"""Recompute the integrity digests of a CelesteOS receipt.json (stdlib only).

Usage: python3 verify_receipt.py receipt.json
Extract receipt.json from the sealed PDF first, e.g. "pdfdetach -saveall file.pdf".
"""
import hashlib, json, sys

def norm(v):
    # JSON numbers are re-serialised as ECMAScript does: 8.0 -> 8.
    if isinstance(v, float) and v.is_integer():
        return int(v)
    if isinstance(v, list):
        return [norm(x) for x in v]
    if isinstance(v, dict):
        return {k: norm(x) for k, x in v.items()}
    return v

def canon(v):
    # Keys sorted at every depth, ", " and ": " separators, non-ASCII unescaped.
    return json.dumps(norm(v), sort_keys=True, ensure_ascii=False)

def sha256_hex(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

def merkle_root(events):
    leaves = [e.get("proof_hash") if isinstance(e, dict) else None for e in events]
    level = [bytes.fromhex(h) for h in leaves if isinstance(h, str) and h]
    if not level:
        return sha256_hex("empty")
    while len(level) > 1:
        nxt = []
        for i in range(0, len(level), 2):
            left = level[i]
            right = level[i + 1] if i + 1 < len(level) else left  # odd: pair with itself
            nxt.append(hashlib.sha256(left + right).digest())
        level = nxt
    return level[0].hex()

def verify(env):
    p, i = env["payload"], env["integrity"]
    recs, evs = p["records"], p["ledger_events"]
    out = {
        "counts": i.get("record_count") == len(recs) and i.get("event_count") == len(evs),
        "payload_sha256": sha256_hex(canon({"records": recs, "ledger_events": evs})) == i["payload_sha256"],
        "chain_root": merkle_root(evs) == i["chain_root"],
    }
    if i.get("envelope_sha256"):
        ident = {k: v for k, v in env.items() if k not in ("payload", "integrity")}
        out["envelope_sha256"] = sha256_hex(canon(ident)) == i["envelope_sha256"]
    return out

if __name__ == "__main__":
    with open(sys.argv[1], encoding="utf-8") as f:
        result = verify(json.load(f))
    print(json.dumps(result))
    sys.exit(0 if all(result.values()) else 1)

Test vectors (synthetic)

Inputpayload_sha256chain_root
No records, no events9988dfc5…6c759c3b2e1cfa82…0be8f10d (SHA-256 of "empty")
Records [{"id": "r1", "name": "thing"}]; three events with proof_hash of 64×a, 64×b, 64×c (see note)2b1914ad…38805db6b3a41903…46a76fbf

Note: the second vector's events are {"id": "e1", "proof_hash": "aaa…", "created_at": "2026-04-15T08:00:00Z", "actor_ref": "aa", "vessel_ref": "vv", "entity_ref": "e1", "action": "create"} and likewise e2/b/2026-04-16T08:00:00Z/update and e3/c/2026-04-17T08:00:00Z/close.

← Back to the verifier