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:
- The receipt, in the shape
/v1/recordreturns asverify_request(the same bodyPOST /v1/verifyaccepts):action_key,href,status,decision,review_route,payload_hash,packet_hash,verticalandseal. - 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
datetimebecomesstr(dt)with a space, notT("2026-01-02 00:00:00+00:00"); adatebecomes"2026-01-02". Decimal("1.10")becomes the string"1.10";b"x"becomes"b'x'"; asetbecomesstr(set), whose order is not stable.- Once the fallback runs for a mapping, keys go through
str(), soTruebecomes"True"andNonebecomes"None". Without the fallback,jsonwrites those keys as"true"and"null". - Keys of mixed types (
{1: "a", "b": 2}) make sorting raiseTypeError, which triggers the fallback: keys become strings and sort as strings. - An object with only integer keys does not trigger it:
jsonsorts 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.
- Order: keys sorted by Python string order (Unicode code points).
- Leaf digest, one per key:
- v3:
canonical_hash({"key": key, "value": value}) - v4:
canonical_hash({"salt": salt, "key": key, "value": value}), wheresaltis 32 lowercase hex characters (16 random bytes fromsecrets.token_hex(16)), chosen independently for each leaf. - Tree (construction
merkle-rfc6962-strict-sha256in the core library): - leaf hash =
SHA-256(0x00 || the 64 ASCII characters of the leaf digest); the digest's hex text is hashed, not its raw bytes; - node hash =
SHA-256(0x01 || raw 32 bytes of left || raw 32 bytes of right); - pair nodes left to right; an odd node at the end of a level moves up unchanged;
- one leaf: the root is its leaf hash.
metadata_commitment_rootis the root as lowercase hex. A record with no metadata hasmetadata_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.