Skip to content

Seal contract: verify a receipt without calling Planisphere

Every Planisphere receipt carries a seal: an Ed25519 signature over a hash of the receipt's fields. This page gives every rule needed to recompute that hash and check that signature on your own machine, with no Planisphere server involved. The rules are read from the code that seals production receipts (src/planisphere_api/evidence.py, the canonical_hash function of the pinned Planisphere core, and routes/v1/record.py for metadata commitments). The test vectors are minted by that same code and a test in the API repository regenerates them on every run, so this page and production cannot drift apart unnoticed.

You need two things:

  1. The receipt, in the shape /v1/record returns as verify_request (the same body POST /v1/verify accepts): action_key, href, status, decision, review_route, payload_hash, packet_hash, vertical and seal.
  2. A saved copy of the published keys, https://api.planisphere.ooo/.well-known/planisphere-keys.json. Save it once and keep it; checking a receipt later needs no network at all.

The official SDKs implement everything below: planisphere_sdk.verify_local(receipt, keys) (Python, standard library only) and verifyLocal(receipt, keys) from planisphere-sdk (TypeScript, WebCrypto).

1. Canonical JSON and canonical_hash

Every hash on this page is canonical_hash(value): the lowercase hex SHA-256 of the UTF-8 bytes of the canonical JSON text of value. The text is exactly what Python produces with

json.dumps(_json_safe(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True)

Spelled out:

  • No whitespace. Items are separated by ,, keys from values by :.
  • Object keys are sorted by Unicode code point (Python string order). This is not JavaScript's default Array.prototype.sort(), which compares UTF-16 code units: U+FF61 sorts before U+1F600 by code point but after it by code unit. Uppercase ASCII sorts before lowercase ("B" before "a").
  • Strings are wrapped in " and escaped as follows. Every other character in U+0020 to U+007E is written as itself (including /).
Character Written as
" \"
\ \\
U+000A, U+000D, U+0009, U+0008, U+000C \n, \r, \t, \b, \f
Any other character below U+0020, U+007F (DEL), and everything above U+007E \uXXXX with four lowercase hex digits
A character above U+FFFF its UTF-16 surrogate pair, each half as \uXXXX (U+1D11E is 饾劄)
A lone surrogate \uXXXX of that code unit

The resulting text is pure ASCII, so its UTF-8 bytes equal its characters. - Literals: true, false, null. - Arrays keep their order. - Numbers. No field inside a seal's signed bytes is a number; numbers only appear inside metadata values (section 5). Integers are written as plain decimal digits of any length. Floats are written as Python's repr: the shortest digits that round-trip, in fixed notation when the decimal exponent is from -4 to 15 (always with a fractional part, so one is 1.0), and otherwise as d.ddd + e + sign + at least two exponent digits (1e-05, 1e+16, 1.5e+300). Python also writes NaN, Infinity and -Infinity, which are not valid JSON. A JavaScript verifier working from JSON.parse output cannot tell 1 from 1.0 or keep integers beyond 2^53; the SDK writes safe integers as integers and everything else in the float form, and the python_only vectors mark the cases it cannot reproduce.

The str() fallback. Before encoding, _json_safe first tries to encode the whole value. Only if that raises TypeError does it rebuild the value: mappings become objects whose keys are str(key), lists and tuples become arrays, and any other leaf that JSON cannot encode becomes the string str(leaf). No field of a seal and no value that arrives as JSON ever reaches this path, so a verifier that starts from JSON never needs it. For completeness, the edge cases it produces when a Python caller hashes non-JSON objects:

  • A datetime becomes str(dt) with a space, not T ("2026-01-02 00:00:00+00:00"); a date becomes "2026-01-02".
  • Decimal("1.10") becomes the string "1.10"; b"x" becomes "b'x'"; a set becomes str(set), whose order is not stable.
  • Once the fallback runs for a mapping, keys go through str(), so True becomes "True" and None becomes "None". Without the fallback, json writes those keys as "true" and "null".
  • Keys of mixed types ({1: "a", "b": 2}) make sorting raise TypeError, which triggers the fallback: keys become strings and sort as strings.
  • An object with only integer keys does not trigger it: json sorts the integers numerically and then writes them as strings, so {10: "a", 2: "b"} becomes {"2":"b","10":"a"}.

2. The values a verifier recomputes

Inputs come from two places: the receipt's top level and its seal. The table in section 3 says which.

Key id. key_id = "ps-seal:" + canonical_hash({"algorithm": "ed25519", "public_key": seal.public_key})[:16], where seal.public_key is used exactly as written (lowercase hex of the raw 32-byte Ed25519 public key).

Packet hash.

packet_hash = canonical_hash({
  "action_key", "href", "status", "decision", "review_route",
  "payload_hash", "raw_content_stored": false })

using the receipt's top-level values. review_route may be null. payload_hash is the hash of the request fields the endpoint committed when it sealed the receipt; the seal treats it as an opaque value (the vectors include one committed payload as an example).

Attestation root. attestation_root = canonical_hash(content), where content holds the fields listed for the seal's contract in section 3.

Signature. seal.signature is the lowercase hex of a 64-byte Ed25519 signature (RFC 8032, plain Ed25519: no pre-hash, no context) by seal.public_key over the 32 raw bytes of attestation_root, that is, the hex string decoded to bytes, not the hex text itself.

Stamp id. stamp_id = "stamp:" + canonical_hash({"attestation_root": seal.attestation_root, "key_id": seal.key_id, "signature": seal.signature})[:24].

3. Seal contracts v1 to v4

The seal names its own contract in seal.seal_contract, and verification recomputes that contract's construction. A seal with no seal_contract field is read as v2 (the API schema's default). Old contracts stay valid forever: when Planisphere re-derives a stored receipt it uses the contract the receipt was minted with, never today's default.

Contract string Version What it adds to the signed bytes
planisphere.evidence_packet v1 The receipt's content only.
planisphere.evidence_packet/2 v2 When and whose: recorded_at (Planisphere's clock at seal time, RFC 3339 UTC ending in Z) and tenant.
planisphere.evidence_packet/3 v3 Which policy: policy_ref (pack id, pack version, policy hash, registry schema) and metadata_commitment_root over one commitment per metadata key.
planisphere.evidence_packet/4 v4 No new field. Each metadata leaf gains its own random 128-bit salt (section 5), so a disclosed value cannot be used to guess its siblings. The contract string itself is in the signed bytes, so v3 and v4 roots differ for identical inputs.

New receipts use the server's configured contract (v3 unless configured otherwise); GET /v1/verify/kit reports it as current_seal_contract.

The attestation content object, per contract:

Field Value v1 v2 v3 v4
seal_contract seal.seal_contract yes yes yes yes
algorithm the constant "ed25519" yes yes yes yes
key_id seal.key_id yes yes yes yes
vertical top-level vertical; if empty or missing, the part of action_key before the first :; if action_key has no :, "law" yes yes yes yes
action_key, href, status, decision, review_route top level yes yes yes yes
signed_hash seal.signed_hash yes yes yes yes
packet_hash seal.packet_hash yes yes yes yes
raw_content_stored the constant false yes yes yes yes
tenant seal.tenant (may be null) no yes yes yes
recorded_at seal.recorded_at (may be null) no yes yes yes
policy_ref seal.policy_ref; an empty object {} is signed as null no no yes yes
metadata_commitment_root seal.metadata_commitment_root if it is a string, otherwise null no no yes yes

Key order in the table does not matter: canonical JSON sorts keys. Fields a contract does not list are absent from content, not null.

Fields the signature does not cover

Changing these does not break a seal, so do not rely on them as evidence: seal.dev_key, seal.claim_boundary, seal.raw_content_stored, any extra field, and the receipt's own claim_boundary. On a v1 seal, tenant, recorded_at, policy_ref and metadata_commitment_root are not signed even if present; on a v2 seal, policy_ref and metadata_commitment_root are not signed. seal.signature and seal.public_key are covered as text by the stamp id and key id, so they must stay in the lowercase hex they were minted in.

4. The eight checks

A receipt verifies when all eight checks pass. They are the same checks, with the same names, that POST /v1/verify returns.

Check Passes when
signature_valid the Ed25519 signature checks out (section 2)
signed_hash_matches_payload seal.signed_hash equals top-level payload_hash
packet_hash_matches_input seal.packet_hash equals top-level packet_hash
packet_hash_recomputed top-level packet_hash equals the recomputed packet hash
attestation_root_recomputed seal.attestation_root equals the recomputed attestation root
stamp_id_recomputed seal.stamp_id equals the recomputed stamp id
key_id_matches_public_key seal.key_id equals the key id recomputed from seal.public_key
seal_contract_valid seal.seal_contract is one of the four contract strings

Signature details that match the API's verifier (pyca/cryptography on OpenSSL): hex strings are decoded as Python's bytes.fromhex does (ASCII whitespace allowed between byte pairs, either case); the public key must be 32 bytes and the signature 64 bytes; the scalar S must be below the group order; anything malformed is simply a failed signature_valid. The API rejects a receipt with HTTP 422 before checking anything when status is not pending_review, blocked or recorded, when seal.algorithm is present and not ed25519, or when a required field is missing; the SDKs raise an error in the same cases.

5. Metadata commitments (v3 and v4)

A record's metadata is committed key by key, so one declared value can later be shown and checked against the seal without revealing the others. Every metadata key gets a leaf, retained or not, so the set of keys cannot be edited afterwards either.

  1. Order: keys sorted by Python string order (Unicode code points).
  2. Leaf digest, one per key:
  3. v3: canonical_hash({"key": key, "value": value})
  4. v4: canonical_hash({"salt": salt, "key": key, "value": value}), where salt is 32 lowercase hex characters (16 random bytes from secrets.token_hex(16)), chosen independently for each leaf.
  5. Tree (construction merkle-rfc6962-strict-sha256 in the core library):
  6. leaf hash = SHA-256(0x00 || the 64 ASCII characters of the leaf digest); the digest's hex text is hashed, not its raw bytes;
  7. node hash = SHA-256(0x01 || raw 32 bytes of left || raw 32 bytes of right);
  8. pair nodes left to right; an odd node at the end of a level moves up unchanged;
  9. one leaf: the root is its leaf hash.
  10. metadata_commitment_root is the root as lowercase hex. A record with no metadata has metadata_commitment_root: null.

Checking one disclosed value: recompute its leaf digest from (salt, key, value), rebuild the root from the full list of leaf digests (or walk an inclusion proof), compare it with seal.metadata_commitment_root, and then verify the seal itself. The SDKs expose metadata_leaf_digest / metadataLeafDigest and merkle_root / merkleRoot for this.

Two limits, stated plainly:

  • The seal does not name its tree construction. The core library switched new trees from duplicating an odd last node (merkle-rfc6962-sha256) to promoting it on 2026-07-28, the same day contract v3 was introduced. A v3 record sealed by a server still running the older library would have a duplicate-last root. The two constructions agree when the number of leaves is a power of two (1, 2, 4, 8 ...). The SDK functions accept either name.
  • The leaves and salts are stored with the record. This page defines how to check a disclosed leaf; it does not define where a disclosure is handed out.

6. Published keys

GET https://api.planisphere.ooo/.well-known/planisphere-keys.json returns {"keys": [...], "note": ...}. Each entry has key_id, public_key_hex, public_key_b64_raw32, algorithm (ed25519), status, origin, not_before, not_after, superseded_by and warning. Public key material only; private keys are never published.

A seal carries its own public key, so a forged seal can be internally consistent. What makes a receipt authentic is that it verifies and its key matches a published entry: compare the seal's public_key (lowercased) with public_key_hex, or its key_id with key_id. Only report a key status for a receipt that verifies.

status Meaning
production the active production signing key
production_retired a former production key. Rotated keys stay listed, with not_before, not_after and superseded_by, so every receipt they sealed stays authentic. Retirement is not revocation.
sandbox the test-workspace key; receipts it signs are not production evidence

The live API can also answer unavailable (its signer could not load) and unknown (no match); an offline check with no matching entry reports unknown.

As of 2026-10-21 the set holds one production key, ps-seal:f76dd228b2c903b7 (c59dae0d944ed6297ef22018ac4f7cf940e70ffe1aa757dde4fc0bdd47112b53, valid from 2026-07-10T11:31:44Z), and the sandbox key ps-seal:170c7cf919d900c7 (239929e203380b355bf9b296a47169388ab846ca77933ea87e7f561b0c772def). Trust the document you saved, not this paragraph.

Validity windows. For a retired key, also check that seal.recorded_at (v2 and later) falls inside [not_before, not_after). The API's verifier does not apply this check today; the SDKs report it as recorded_within_key_window (true, false, or null when there is no window or no recorded_at).

7. What a verified seal does and does not show

It shows that these exact fields were signed by that key and have not changed since. It does not show that the act described happened, or the content behind payload_hash (you need the committed payload to check that). It does not show the record's current standing: a record can later be expired by a sealed disposal, which only the registry knows (seal_standing on POST /v1/verify, or GET /trust/records/{action_key}). recorded_at is Planisphere's clock, not the time of the act.

Test vectors

All files are in seal-vectors/ next to this page. Each is signed by a test key derived from the public string planisphere-seal-vectors-test-key/1 (private seed = SHA-256 of that ASCII string). It is neither the production key nor the sandbox key.

File Contents
v1.json, v2.json, v3.json, v4.json One sealed receipt per contract, with every intermediate canonical JSON text and hash (payload, key id, packet hash, attestation root, signature input, stamp id, and for v3/v4 each metadata leaf and the root) and the expected checks
v3_gate_no_policy.json A v3 seal with policy_ref and metadata_commitment_root signed as null
negative.json Edited copies of those receipts with the API verifier's own result for each, including two edits that still verify (missing seal_contract, policy_ref: {})
canonical-json.json Canonical JSON edge cases: input JSON text, expected canonical text and hash
keys.json The test key, in the published key-set shape, with status test_vector

Pass keys.json as the key set; every positive vector then verifies with key_status: "test_vector". The API repository regenerates the files with scripts/generate_seal_vectors.py and tests/test_seal_vectors.py fails if any byte differs.

Not covered here

The four-file PLANIVERSE-SEAL bundle (POST /evidence-packets/seal-bundle) and the inclusion bundles behind /trust/epochs are separate formats with their own verifiers.