The three schemas
| File | $id | Constrains |
|---|---|---|
| schemas/chapter.schema.json | urn:tandem:schema:chapter |
The off-chain manifest whose digest a MARK commits to |
| schemas/close.schema.json | urn:tandem:schema:close |
The off-chain manifest whose digest a CLOSE may commit to |
| schemas/agreement-envelope.schema.json | urn:tandem:schema:agreement-envelope |
A signed statement of one pipeline's whole view at one height |
Nothing validated by a schema on this page ever appears in a Bitcoin transaction. The chain carries 32-byte commitments and nothing else. A manifest that fails schema validation does not make a confirmed MARK invalid, and a manifest that passes does not make an invalid one valid. Keep the two layers apart in your code as firmly as the protocol keeps them apart.
Schema 1
1. Chapter manifest
A chapter manifest names one piece of content added to an object's history. Its SHA256
digest, normalized with RFC 8785 JCS, becomes the manifest_sha256 input
to the chapter commitment described in rule REC-8 and worked through in
guide example 7.
| Property | Required | Constraint |
|---|---|---|
schema | Yes | Exactly the constant urn:tandem:chapter |
title | Yes | String, 1 to 120 characters, no C0 control characters and no 0x7f |
content_type | Yes | String, 3 to 127 characters, a restricted lowercase media type |
content_sha256 | Yes | Exactly 64 lowercase hexadecimal characters |
uris | Yes | Array, at most 8 items, unique, each a constrained content-addressed URI |
encryption | No | If present, an XChaCha20-Poly1305 descriptor |
display | No | If present, presentation hints |
Why each constraint is there
title
The pattern excludes the entire C0 control range and the delete character. That removes newlines, tabs, and terminal escape sequences from a field that products will render directly. A title is not a place for formatting, and a manifest is untrusted input.
content_type
The pattern requires lowercase, a single slash, and a restricted character set for both
the type and subtype. Parameters are not permitted: there is no place for a
; charset= suffix or a quoted string. This is narrower than the media-type
grammar you may be used to, which is the point. It means two implementations cannot
disagree about whether two spellings of the same type are equal, and it removes an
injection surface from a field that often reaches a Content-Type header.
content_sha256
Lowercase hexadecimal only. An uppercase digest fails validation. This matters because the manifest is hashed as RFC 8785 JCS output, so a case difference is a different manifest and therefore a different commitment.
uris
At most eight, all unique, and each must match one of exactly three forms:
ipfs://<alphanumeric cid>[/optional path]
ar://<alphanumeric, underscore, or hyphen>
sha256://<64 lowercase hex characters>
There is no http and no https form. Every permitted scheme is
content addressed, so a URI names bytes rather than a location that can serve different
bytes tomorrow. The sha256:// form names the digest with no retrieval hint
at all, which is the honest way to say the content exists but you must already have it.
The array is required but its maxItems is 8 with no minItems,
so an empty array is valid. That is a deliberate allowance for a chapter whose content
is deliberately not distributed.
encryption
One scheme is permitted, xchacha20-poly1305. The nonce is base64 with
length exactly 32 characters, which is the encoding of a 24-byte XChaCha20 nonce. A
key_commitment_sha256 is required alongside it.
Note what the commitment is for. It lets a holder of the key confirm they hold the right one before attempting decryption, and it lets two parties agree they mean the same key without exchanging it. It is not a key escrow, and nothing in Tandem can recover content if the key is lost.
display
Three optional hints: fit as one of contain,
cover, or native; background as a six-digit hex
colour; and alt, at most 500 characters. The alt pattern
excludes control characters but deliberately permits 0x09 tab,
0x0a newline, and 0x0d carriage return, since alternative text
for a long piece of content may reasonably contain line breaks.
Schema 2
2. Close manifest
The close manifest is the chapter manifest's counterpart, used for the optional statement attached to a cooperative ending. Its structure is deliberately near-identical.
SAME as the chapter manifest
- The same five required properties
- The same
titlepattern and length limits - The same
content_typegrammar - The same three URI forms and the same 8-item cap
- The same encryption and display shapes
- The same rejection of unknown properties
DIFFERENT from the chapter manifest
schemais the constanturn:tandem:close- Its digest feeds the
TANDEM/CLOSE\0commitment domain, notTANDEM/CHAPTER\0 - The commitment preimage carries the CLOSE reason byte where a chapter carries its kind byte
- The on-chain commitment it produces may be omitted entirely by committing 32 zero bytes
That last difference is the important one. A MARK with an all-zero chapter commitment is
invalid, reason BAD_COMMITMENT, code 0x001d. A CLOSE with an
all-zero close commitment is perfectly valid and simply means there is no close
manifest. A pair can end an object without saying anything about why.
The two schemas express the same constraints through slightly different JSON structure:
the chapter schema factors its patterns into $defs and references them,
while the close schema inlines them. Validation behaviour is the same. Do not read
anything into the difference, and do not assume a validator that handles one will
necessarily exercise the same code path for the other.
Schema 3
3. Agreement envelope
This is the schema that carries the trust story. One pipeline states its entire view at one height and signs it, so another party can compare rather than believe.
| Property | Constraint |
|---|---|
schema | Exactly urn:tandem:agreement-envelope |
key_id | String, 1 to 128 characters, matching ^[A-Za-z0-9._:-]+$ |
tuple | The signed object, described below |
signature | Exactly 128 lowercase hexadecimal characters, a 64-byte Ed25519 signature |
| Property | Constraint | Meaning |
|---|---|---|
schema | Exactly urn:tandem:agreement-tuple | Distinguishes the signed object from its envelope |
protocol_id | ^tndm:(mainnet|signet|testnet4|regtest):[0-9a-f]{64}$ | The exact deployment binding, per rule ID-4 |
height | Decimal string, no leading zeros | The block height this tuple describes |
block_hash | 64 lowercase hex characters | The block at that height |
event_root | 64 lowercase hex characters | Rule ROOT-1 |
object_state_root | 64 lowercase hex characters | Rule ROOT-2 |
chained_root | 64 lowercase hex characters | Rule ROOT-4 |
founding_created | Decimal string | Post-block counter, rule ST-6 |
all_objects | Decimal string | Post-block counter, rule ST-6 |
active_objects | Decimal string | Post-block counter, rule ST-6 |
parser_commit | 40 lowercase hex characters | Which parser build produced this |
indexer_commit | 40 lowercase hex characters | Which indexer build produced this |
parser_binary_sha256 | 64 lowercase hex characters | Digest of that parser binary |
indexer_binary_sha256 | 64 lowercase hex characters | Digest of that indexer binary |
Three details that catch people out
Counters are strings, not numbers
height and all three counters use the pattern
^(0|[1-9][0-9]*)$. They are decimal strings with no leading zeros, not JSON
numbers. This avoids every floating-point and precision question, and it means
"07" is invalid while "0" and "7" are both fine.
Preserve them as strings through your whole pipeline. Parsing to a number and
re-serializing will eventually produce a different serialized form and a signature that
no longer verifies.
Only the tuple is signed
The signature covers the RFC 8785 JCS serialization of the tuple
object. schema, key_id, and signature at the
envelope level are outside it. Never treat key_id as authenticated; use it
to select a candidate key, then verify, then apply your own authorization policy to the
key that actually verified.
The release fields are not supposed to match
When two pipelines are compared, only the nine semantic fields are required to be equal:
protocol_id, height, block_hash,
event_root, object_state_root, chained_root, and
the three counters. The four release fields identify which build spoke, and independent
implementations will always differ there. Validate each pipeline's provenance
separately, against your own trust policy, rather than comparing them to each other.
Note also that the tuple describes a height, not a moment. A later release of the same pipeline does not retroactively change the release identity recorded for a height that was already signed. Keep historical tuples with the provenance they were signed with.
Using them safely
- Validate against the published files, not a copy you transcribed. Their digests are pinned by the repository verifier and by the independent Rust verifier's input lock.
- Reject unknown fields. All three schemas already require this; do not relax it in your own validator wrapper.
- Normalize with RFC 8785 JCS before hashing anything, so semantically identical documents produce identical digests.
- Keep counters and heights as strings end to end.
- Treat schema validity and signer authorization as two separate decisions, in that order.
- Retrieve only the payload named by
content_sha256, verify the bytes before display, and enforce your own size and type limits regardless of what the manifest claims. - Never execute retrieved content as application code, and never let a manifest field reach a shell, a template, or an HTTP header without escaping.
Schema validation constrains shape. It says nothing about whether the person who wrote the manifest is honest, whether the URI serves what it claims, or whether the content is safe to render. A manifest arrives from whoever put a commitment on chain, which is to say from anyone.