Reference

JSON Schema reference

This repository publishes exactly three JSON Schemas. All three use Draft 2020-12 and all three set additionalProperties to false at every object level, so an unknown field is a validation error rather than something to ignore. That strictness is deliberate: it makes a future extension impossible to smuggle in unnoticed.

Protocol finalized. Mainnet is not active.

The three schemas

Published schemas, their identifiers, and what they constrain
File$idConstrains
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
None of these schemas describe on-chain data.

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.

Top-level properties. Five are required, two are optional, nothing else is allowed.
PropertyRequiredConstraint
schemaYesExactly the constant urn:tandem:chapter
titleYesString, 1 to 120 characters, no C0 control characters and no 0x7f
content_typeYesString, 3 to 127 characters, a restricted lowercase media type
content_sha256YesExactly 64 lowercase hexadecimal characters
urisYesArray, at most 8 items, unique, each a constrained content-addressed URI
encryptionNoIf present, an XChaCha20-Poly1305 descriptor
displayNoIf 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:

Permitted URI 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 title pattern and length limits
  • The same content_type grammar
  • 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

  • schema is the constant urn:tandem:close
  • Its digest feeds the TANDEM/CLOSE\0 commitment domain, not TANDEM/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.

Envelope properties. All four are required.
PropertyConstraint
schemaExactly urn:tandem:agreement-envelope
key_idString, 1 to 128 characters, matching ^[A-Za-z0-9._:-]+$
tupleThe signed object, described below
signatureExactly 128 lowercase hexadecimal characters, a 64-byte Ed25519 signature
Tuple properties. All fourteen are required, and nothing else is allowed.
PropertyConstraintMeaning
schemaExactly urn:tandem:agreement-tupleDistinguishes 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
heightDecimal string, no leading zerosThe block height this tuple describes
block_hash64 lowercase hex charactersThe block at that height
event_root64 lowercase hex charactersRule ROOT-1
object_state_root64 lowercase hex charactersRule ROOT-2
chained_root64 lowercase hex charactersRule ROOT-4
founding_createdDecimal stringPost-block counter, rule ST-6
all_objectsDecimal stringPost-block counter, rule ST-6
active_objectsDecimal stringPost-block counter, rule ST-6
parser_commit40 lowercase hex charactersWhich parser build produced this
indexer_commit40 lowercase hex charactersWhich indexer build produced this
parser_binary_sha25664 lowercase hex charactersDigest of that parser binary
indexer_binary_sha25664 lowercase hex charactersDigest 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

  1. 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.
  2. Reject unknown fields. All three schemas already require this; do not relax it in your own validator wrapper.
  3. Normalize with RFC 8785 JCS before hashing anything, so semantically identical documents produce identical digests.
  4. Keep counters and heights as strings end to end.
  5. Treat schema validity and signer authorization as two separate decisions, in that order.
  6. 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.
  7. Never execute retrieved content as application code, and never let a manifest field reach a shell, a template, or an HTTP header without escaping.
A valid manifest is still untrusted input.

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.