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.
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:
| Shape | Rule | Treatment |
|---|---|---|
| v1 envelope | A 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 v0 | A top-level JSON array of events (early receipts, no integrity block). | Structural checks only; always amber. |
| Malformed v1 | An 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. |
| Unrecognised | Anything else. | Rejected as not a CelesteOS receipt. |
Fields used
payload.records(array of objects) andpayload.ledger_events(array of objects). From each event:proof_hash(64 lowercase hex characters),event_timestamporcreated_at.integrity.record_count,integrity.event_count,integrity.payload_sha256,integrity.chain_root,integrity.first_event_at,integrity.last_event_at, and optionallyintegrity.envelope_sha256.generated_at(the sealing time stated in the file; used for display and for the legacy rule in check 4; it is not validated).- Display only:
receipt_id,receipt_kind,domain,vessel_ref,requested_by_ref,requested_by_role,scope_descriptor. Identifiers in the file are one-way references.
2. Canonical JSON
Digests are computed over a canonical serialisation of the parsed JSON value:
- Object keys are sorted in ascending order at every depth.
- Separators are
", "between items and": "between a key and its value (a space after each), with no other whitespace. - Strings are written as JSON strings with non-ASCII characters left unescaped (only
",\and control characters are escaped). - Numbers are written as ECMAScript
JSON.stringifywrites them, so a whole-number value has no decimal point (8.0is written8). true,falseandnullas literals; array order is preserved.- The canonical string is encoded as UTF-8 before hashing. Digests are lowercase hex.
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: each event's
proof_hash(events without a non-empty stringproof_hashare skipped), in the order the events appear inpayload.ledger_events, hex-decoded to 32 raw bytes. Leaves are not re-hashed. - Parent:
SHA-256(left || right)over the concatenated raw bytes (64 bytes in, 32 bytes out). There is no prefix or domain-separation byte. - Odd node: when a level has an odd number of nodes, the last node is paired with itself.
- Single leaf: the root is that leaf. No leaves: the root is
SHA-256("empty")over the five ASCII bytes.
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)
- Embedded record present: always shown once
receipt.jsonis parsed. - Events in chronological order (
event_timestamp, elsecreated_at): caution if not. - Every event carries a
proof_hash: caution if not. - Digital signature: reports only whether a signature field is present. It is informational and never validated; it does not affect the verdict.
4. Verdict
| Result | Rule |
|---|---|
| Red: failed | Any check fails (including a malformed v1 file). |
| Amber: cautions | No failures, but any caution (legacy v0 file, pre-2026-05-05 digest mismatch, unordered events, missing proof_hash). |
| Green: content matches its seal | No failures and no cautions. Informational rows never change the verdict. |
5. Limitations
- No signature validation. The PDF signature is detected but not validated, so a pass does not establish who sealed the export. Because every check uses public maths and no secret, a party who edits the content and recomputes all digests would also pass.
- No timestamp validation. The RFC 3161 timestamp is not validated, so a pass does not establish when the export was sealed.
generated_atis shown as stated in the file. - Self-signed certificate. The certificate currently published at celeste7.ai/.well-known/verify.pem is a self-signed staging certificate, not issued by a certificate authority, and its subject name is not a verified identity. This verifier does not use it. A CA-issued certificate is a planned follow-up.
- Cover page not sealed. Text printed on the PDF pages, including the vessel name and "exported by" name shown by the verifier, is not covered by any digest. The verifier labels such values "From the cover page — not covered by the seal".
- proof_hash not recomputed. Per-event
proof_hashvalues are checked for form, uniqueness and Merkle inclusion, not recomputed from event content. - Older receipts. Without
envelope_sha256the outer identity fields are not bound; legacy v0 files have no integrity block at all. - PDF only. Other file types are not accepted.
- Integrity, not truth. A pass says nothing about whether the recorded content is accurate or complete, or that any work was done or approved.
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)
| Input | payload_sha256 | chain_root |
|---|---|---|
| No records, no events | 9988dfc5…6c759c3b | 2e1cfa82…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…38805db6 | b3a41903…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.