The Provenance bundle format
A dossier is the file a buyer checks. This page sets out what is in it and how each check is made, so you can read what the checker does, and test another program against the same examples. The checker's fingerprint is here too, so you can see that the copy in your dossier is the one published.
Copyright 2026 My Digital Sovereignty Ltd. All rights reserved. This specification, its test vectors and the checker are published so that anyone may read them and see what the checker checks. Publication is not a licence: no licence to them, or to any other software of My Digital Sovereignty Ltd, is given by publishing them. The terms on which you may run the checker are those of the order under which your dossier was made; anyone holding or running a copy on no such terms is responsible for the terms under which they hold and run it.
The technical specification below is in English.
- 1. Conventions
- 2. The dossier file
- 3. Records
- 4. Canonical JSON
- 5. Hashes and domain bytes
- 6. Signatures and keys
- 7. The day tree and day roots
- 8. Lot commitments
- 9. Lot absences
- 10. Standing authorisations and signing models
- 11. Evidence strength and corroboration
- 12. Time stamps: the quorum and the qualified label
- 13. Key records
- 14. Other party records
- 15. Openings, erasures, mass balance and profiles
- 16. Verdict words, tiers and exit codes
- 17. The checks, in order
- 18. What the check does not establish
- 19. Test vectors
- 20. The checker and its fingerprint
1. Conventions
Every hash is SHA-256, written as 64 lowercase hex characters. || is byte concatenation. Byte values are written 0x00. A DID is a decentralised identifier, a name a party chose for its key. A record's receipt hash is SHA-256(canonical bytes || 0x00 || the 64 signature bytes). Every limit below is applied before the work it limits. Any text taken from a record or from the file (a provider name, a declared role, a field name) is shown with control and direction characters written as <U+XXXX>, so it can never start a line of the report or pass itself off as a result. The escaped set includes line breaks, direction and zero-width marks, the combining grapheme joiner, variation selectors, Hangul fillers, the braille blank, Mongolian variation selectors and tag characters; the whole result is escaped where it is made, so the page, the printed report, the command line and its --json output carry none of them raw. A literal "<U+" already in the file text is shown as "<<U+", so an escape cannot be forged by typing one.
2. The dossier file
A dossier is delivered as a zip holding README.txt (five languages, one after another), dossier.json, did/<SHA-256 of the DID>.json for each key document, tokens/<digest>.tsr when the file carries time-stamp tokens, verify.html and verify.js (the check page, which runs from the folder with no network), provenance-verifier.js (the checker, byte for byte the published file) and provenance-verifier.js.sha256. A segment pack that had to be cut to fit the size limit adds pack_omissions.json ({ "tokens_omitted": true, "upstream_omitted": [DID...] }, each only when it applies); it is not part of dossier.json.
dossier.json is one JSON object, at most 25 MB and 20000 records, nested at most 64 levels, with no duplicate field name anywhere. A field not listed here is unreadable.
Top level
| Field | What it holds |
|---|---|
dossier_version | Whole number. 1. Any other value is unknown_version. |
dossier_family | The string provenance. Anything else is unreadable (an Email Decision Record dossier is checked on its own page). |
subject_lot_ref | The lot_ref (64 hex) this dossier is about. It must have records of its own in the file. |
assembled_at | When the file was put together (ISO 8601). Not signed and not read by the check. |
segments | List: one segment per party, each holding that party's records (see "A party segment"). |
did_documents | List of key documents, one per party DID: id (the DID), verificationMethod (at most 16 entries of { id, type, publicKeyHex }; only the 32-byte Ed25519 key in publicKeyHex is checked) and optionally key_history (entries of { key_fingerprint_sha256, revoked_at? }). Two different documents for one DID are unreadable; an identical copy changes nothing. |
estate_roots | List. Carried for a later version; this checker does not yet check them and says so. |
disclosures | Object with up to three maps (see "Disclosures"). Optional. |
lot_graph | The builder's statement of the lots and handovers in the file: { lots: [lot_ref...], handovers: [{ handover_id, lot_ref, from, to }...], derivations? }. The check recomputes it from the signed records; a difference is changed. Optional. |
tokens | Object mapping the SHA-256 (64 hex) of each RFC 3161 time-stamp token to the token's DER bytes in base64. A key that is not the hash of its token voids every token in the file. |
corroboration | Object: the builder's statement of how far other parties corroborate each point of the subject lot. Unsigned; the check works it out again from the signed records and prints its own; a difference is shown, never trusted. |
policy_snapshots | Object mapping a party DID to that party's policy as the builder copied it. Unsigned; shown as unsigned and not compared with the records by this checker. |
pack_omissions | { tokens_omitted?: true, upstream_omitted?: [DID...] }: what an earlier party left out of the records it passed on, to keep within the size limit. Each makes the result incomplete. |
plot_openings | Object mapping a plot commitment to { geometry_sha256, geometry, salt }: the boundary of a plot of land its owner chose to disclose, in decision 204's form. Unsigned; each is checked against the commitment a shown record signed (a mismatch is changed; one for a commitment no shown record carries is listed and not read). At most 256. A segment then also shows the party's disclosure records naming an opened commitment. A checker older than this field refuses it as unreadable; a dossier without openings is unchanged, and every dossier carries its own copy of the checker beside it. |
A party segment
| Field | What it holds |
|---|---|
tenant_did | The party's DID. Every number, day root, proof, chain and key fingerprint is looked up under this DID only. |
declared_role | Text such as grower, as the builder states it. Not signed; shown only as unsigned text. |
sequence_disclosure | complete_range (every record in the party's day roots shown in the file) or selective. Not signed. It can only make a result stricter: under complete_range a missing number is gap_in_sequence. A shortfall in the party leaf (see "Lot commitments") against the count its party signed is incomplete in every segment, whatever the label (party_records_missing): the platform's file shows every standing authorisation, key record, authority confirmation and attestation, so a missing one may have been removed. A file in which a party has issued attestations cannot yet be verified, since the builder does not yet deliver them. A shortfall in the omittable party leaf, under a segment labelled exactly selective, is one informational line, party_records_not_shown: erasure or disclosure records the giver chose not to show, counted from the signed leaf, which cannot be checked from the file; only those numbers are excused from a gap, and nothing is said about completeness. Under complete_range or no label the same shortfall is incomplete or a gap. Whenever any number of a party's range is missing from the file, every record shown needs its own inclusion proof. A checker older than this specification reads a day root whose range holds an erasure or disclosure record, shown in full, as changed; this checker reads a file built before the split as incomplete or changed. Every dossier carries its own copy of the checker. |
receipts | List of record entries (see "A record entry"): the party's records of the lots in this file. |
day_roots | List of record entries holding the party's day roots (kind provenance_day_root) that cover the records shown. |
proofs | Object mapping a record's receipt_id to its inclusion proof in its day root: a list of { hash, side }, side "L" or "R", at most 64 steps. |
handover_terms | Object mapping a handover_id to the terms object both parties signed the digest of (see "Handover terms"). |
lot_commits | List of lot commitment entries (see "Lot commitments"). |
lot_commit_unproven | List of { day_root_receipt_id, lot_ref, reason } for pairs the builder could not show whole. Any entry makes the result incomplete. |
lot_absences | List of lot absence entries (see "Lot absences"). Present only when not empty. |
A record entry
| Field | What it holds |
|---|---|
canonical_bytes_base64 | The record's canonical bytes (see "Canonical JSON"), standard base64 with padding and nothing else. These are the bytes that were signed. |
signature_hex | The Ed25519 signature over those bytes: 64 bytes as 128 hex characters. |
json_copy | A readable copy of the record. It must equal the record the signed bytes decode to; a copy that differs is changed and is never read in place of the signed bytes. |
evidence_strength | Optional, not on a day root: { attestation_source, signing_model, text }, the builder's evidence-strength label. Unsigned. The check works the label out from the signed record: a source or signing model that differs is changed; text that differs is noted and the page prints its own. |
Handover terms
handover_terms_sha256 in each handover record is SHA-256(canonical JSON of the terms): { handover_id, lot_ref, giver_did, receiver_did, giver_last_receipt_sha256, product_class, hs_code?, quantity_commitment_sha256?, evidence_refs, proposed_at, expires_at }. The platform sets expires_at between 1 hour and 30 days after proposed_at; the checker does not enforce that range. It recomputes the digest from the terms in the segment and compares expires_at with the time of the reply: a reply after expiry is handover_unmatched. Where the terms are not in the file, it says the expiry was not compared.
Disclosures
| Map | What it holds |
|---|---|
lot_salts | Map from lot_ref to { genesis_tenant_did, lot_salt }. The check recomputes lot_ref = SHA-256(canonical JSON of that object). |
evidence_salts | Map from an evidence digest to its 32-byte salt (64 hex). A short value read off an object (a seal, certificate or container number) is recorded as SHA-256(UTF-8 of salt hex || ":" || the value with spaces and hyphens removed, upper-cased); the reader types the value and the check recomputes it. |
quantity_openings | Map from a quantity_commitment_sha256 to { value, unit, salt }; the check recomputes SHA-256(canonical JSON of that object). |
A salt or opening that is absent is listed as not provided and does not lower the headline.
3. Records
Every record carries the common fields, then the fields of its kind. Receipt version: 1. Kinds this checker reads: provenance_step, provenance_handover, provenance_day_root, provenance_mandate, provenance_key_event, provenance_attestation, provenance_tsa_confirmation, provenance_erasure_event, provenance_disclosure_event, provenance_archive_root, provenance_attestation_receipt; a record of another kind is not_understood. One record's canonical bytes may not exceed 16 KiB. No field is free text: every string is a value from a closed list, a digest, a time, a date, a DID or a UUID, so no name, address, note or coordinate can be carried.
Common fields
| Field | Form | Presence |
|---|---|---|
receipt_id | lowercase UUID | required |
receipt_kind | one of a closed list: the kind | required |
receipt_version | whole number, 1 or more | required |
tenant_did | a DID, did:<method>:<identifier> (identifier at most 200 characters) | required |
signer_did | the party's DID followed by #<key id> | required |
algorithm | one of a closed list: Ed25519 | required |
decided_at | ISO 8601 UTC with milliseconds | required |
attestation_authority | one of a closed list: platform | required |
time_attestation | object | required |
tenant_seq | whole number, 1 or more | on every kind but the day root |
time_attestation is { grade, unavailable_reason?, authorities: [...], quorum }: grade tsa_rfc3161 with at least one authority entry of status obtained carrying token_digest (the SHA-256 of a token in tokens), or grade operator_attested with an unavailable_reason and no obtained entry. An entry is { provider_id, status, token_digest?, name?, jurisdiction?, imprint_algorithm?, generation_time?, unavailable_reason?, qualified_status, trusted_list_ref? }: qualified_status is the platform's own statement when it asked (qualified_per_trusted_list, not_shown_on_trusted_list or not_assessed), with trusted_list_ref { jurisdiction, sequence, issued } exactly when it says qualified_per_trusted_list; the check never takes it as the label (see "Time stamps"). quorum is { required, configured, obtained, met }: required is 2 on a day root and an archive root and 1 on every other kind, obtained counts the obtained entries, configured counts every authority asked, and met is the string "true" exactly when obtained is at least required. The token's imprint is the anchored digest: SHA-256 of the canonical JSON of the record without signature and without time_attestation.
provenance_step
| Field | Form | Presence |
|---|---|---|
signing_model | one of a closed list: village_custodied, threshold_hybrid, participant_held | required |
captured_at | ISO 8601 UTC with milliseconds | required |
capture_mode | one of a closed list: online, queued | required |
profile_id | one of a closed list: generic_v1, honey_nz_v1, beef_nz_v1, dairy_nz_v1, kiwifruit_nz_v1, nz_mpi_animal_products_v1, nz_mpi_plants_v1 | optional |
mandate_ref_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
lot_ref | 64 lowercase hex characters (a SHA-256) | required |
step_type | one of a closed list: harvest, receive, process, pack, inspect, transport_leg, export_declaration, import_clearance, delivery | required |
occurred_at | ISO 8601 UTC with milliseconds | required |
product_class | one of a closed list: dairy, meat, wine, honey, seafood, horticulture, other | required |
evidence_refs | 0 to 16 evidence entries | required |
attestation_source | one of a closed list: self, device, third_party | required |
hs_code | six-digit tariff subheading | optional |
quantity_commitment_sha256 | 64 lowercase hex characters (a SHA-256) | optional |
plot_commitment_sha256 | 64 lowercase hex characters (a SHA-256) | optional |
location_class | location class object | by the rules below |
transport_mode | one of a closed list: road, rail, sea, air, inland_water, multimodal | by the rules below |
from_class | location class object | by the rules below |
to_class | location class object | by the rules below |
input_lot_refs | 1 to 32 lot_refs, sorted and distinct | by the rules below |
supersedes_receipt_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
correction_reason | one of a closed list: data_entry_error, evidence_replaced, wrong_lot, wrong_time, withdrawn_by_actor | by the rules below |
prev_receipt_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
provenance_handover
| Field | Form | Presence |
|---|---|---|
signing_model | one of a closed list: village_custodied, threshold_hybrid, participant_held | required |
captured_at | ISO 8601 UTC with milliseconds | required |
capture_mode | one of a closed list: online, queued | required |
profile_id | one of a closed list: generic_v1, honey_nz_v1, beef_nz_v1, dairy_nz_v1, kiwifruit_nz_v1, nz_mpi_animal_products_v1, nz_mpi_plants_v1 | optional |
mandate_ref_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
handover_id | lowercase UUID | required |
handover_role | one of a closed list | required |
handover_status | one of a closed list | required |
lot_ref | 64 lowercase hex characters (a SHA-256) | required |
counterparty_did | a DID, did:<method>:<identifier> (identifier at most 200 characters) | required |
handover_terms_sha256 | 64 lowercase hex characters (a SHA-256) | required |
giver_proposal_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
receiver_reply_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
reason_code | one of a closed list | by the rules below |
prev_receipt_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
provenance_day_root
| Field | Form | Presence |
|---|---|---|
date | date YYYY-MM-DD | required |
root | 64 lowercase hex characters (a SHA-256) | required |
previous_root | 64 lowercase hex characters (a SHA-256) | required |
previous_root_record_sha256 | 64 lowercase hex characters (a SHA-256) | required |
first_tenant_seq | whole number, 1 or more | required |
last_tenant_seq | whole number, 0 or more | required |
count | whole number, 0 or more | required |
lot_commit_root | 64 lowercase hex characters (a SHA-256) | required |
lot_commit_leaves | whole number, 0 or more | required |
provenance_mandate
| Field | Form | Presence |
|---|---|---|
signing_model | one of a closed list: village_custodied, threshold_hybrid, participant_held | required |
captured_at | ISO 8601 UTC with milliseconds | required |
capture_mode | one of a closed list: online, queued | required |
profile_id | one of a closed list: generic_v1, honey_nz_v1, beef_nz_v1, dairy_nz_v1, kiwifruit_nz_v1, nz_mpi_animal_products_v1, nz_mpi_plants_v1 | optional |
mandate_ref_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
mandate_id | lowercase UUID | required |
custody_model | one of a closed list: village_custodied, threshold_hybrid | required |
key_id | a key id (letters, digits, dot, underscore, hyphen; at most 64) | required |
scope | one or more scopes, sorted and distinct: archive_root, attestation, day_root, disclosure_event, erasure_event, handover, key_event, step, tsa_confirmation | required |
grantor_role | one of a closed list: owner | required |
grantor_auth | one of a closed list: passkey, totp, password | required |
valid_from | ISO 8601 UTC with milliseconds | required |
mandate_status | one of a closed list: granted, withdrawn | required |
valid_until | ISO 8601 UTC with milliseconds | by the rules below |
acknowledgements | the three acknowledgements, in order: erasure_limits, legal_framing, sealed_permanence | by the rules below |
privacy_note_version | whole number, 1 or more | by the rules below |
supersedes_mandate_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
provenance_key_event
| Field | Form | Presence |
|---|---|---|
signing_model | one of a closed list: village_custodied, threshold_hybrid, participant_held | required |
captured_at | ISO 8601 UTC with milliseconds | required |
capture_mode | one of a closed list: online, queued | required |
profile_id | one of a closed list: generic_v1, honey_nz_v1, beef_nz_v1, dairy_nz_v1, kiwifruit_nz_v1, nz_mpi_animal_products_v1, nz_mpi_plants_v1 | optional |
mandate_ref_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
event | one of a closed list: rotated, withdrawn, compromised | required |
key_id | a key id (letters, digits, dot, underscore, hyphen; at most 64) | required |
effective_at | ISO 8601 UTC with milliseconds | required |
successor_key_id | a key id (letters, digits, dot, underscore, hyphen; at most 64) | by the rules below |
provenance_attestation
| Field | Form | Presence |
|---|---|---|
signing_model | one of a closed list: village_custodied, threshold_hybrid, participant_held | required |
captured_at | ISO 8601 UTC with milliseconds | required |
capture_mode | one of a closed list: online, queued | required |
profile_id | one of a closed list: generic_v1, honey_nz_v1, beef_nz_v1, dairy_nz_v1, kiwifruit_nz_v1, nz_mpi_animal_products_v1, nz_mpi_plants_v1 | optional |
mandate_ref_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
lot_ref | 64 lowercase hex characters (a SHA-256) | required |
attestation_kind | one of a closed list: inspection, certification, audit | required |
point | one of a closed list: origin, handling, quantity, inspection | required |
outcome | one of a closed list: pass, fail, observed | required |
evidence_refs | 0 to 16 evidence entries | required |
occurred_at | ISO 8601 UTC with milliseconds | required |
location_class | location class object | required |
attestation_source | one of a closed list: self, device, third_party | required |
attested_receipt_sha256 | 64 lowercase hex characters (a SHA-256) | optional |
scheme_ref_sha256 | 64 lowercase hex characters (a SHA-256) | optional |
provenance_tsa_confirmation
| Field | Form | Presence |
|---|---|---|
signing_model | one of a closed list: village_custodied, threshold_hybrid, participant_held | required |
captured_at | ISO 8601 UTC with milliseconds | required |
capture_mode | one of a closed list: online, queued | required |
profile_id | one of a closed list: generic_v1, honey_nz_v1, beef_nz_v1, dairy_nz_v1, kiwifruit_nz_v1, nz_mpi_animal_products_v1, nz_mpi_plants_v1 | optional |
mandate_ref_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
authority_id | one of a closed list: certum, aruba, nz_tsa, pt_state | required |
tsu_anchor_sha256 | 64 lowercase hex characters (a SHA-256) | required |
trusted_list_jurisdiction | one of a closed list: AT, BE, BG, CY, CZ, DE, DK, EE, ES, FI, FR, GR, HR, HU, IE, IS, IT, LI, LT, LU, LV, MT, NL, NO, PL, PT, RO, SE, SI, SK | required |
trusted_list_sequence | whole number, 1 or more | required |
trusted_list_issued | date YYYY-MM-DD | required |
status | one of a closed list: confirmed_qualified, confirmed_not_qualified, withdrawn | required |
confirmed_at | ISO 8601 UTC with milliseconds | required |
confirmer_role | one of a closed list: owner | required |
supersedes_receipt_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
provenance_erasure_event
| Field | Form | Presence |
|---|---|---|
signing_model | one of a closed list: village_custodied, threshold_hybrid, participant_held | required |
captured_at | ISO 8601 UTC with milliseconds | required |
capture_mode | one of a closed list: online, queued | required |
profile_id | one of a closed list: generic_v1, honey_nz_v1, beef_nz_v1, dairy_nz_v1, kiwifruit_nz_v1, nz_mpi_animal_products_v1, nz_mpi_plants_v1 | optional |
mandate_ref_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
erased_class | one of a closed list: counterparty_label, lot_label, lot_salt, evidence_salt, quantity_opening, plot_geometry, mandate_assertion, all | required |
commitment_sha256_list | 0 to 256 digests, sorted and distinct | required |
occurred_at | ISO 8601 UTC with milliseconds | required |
basis | one of a closed list: request, leaving, retention_ended | required |
provenance_disclosure_event
| Field | Form | Presence |
|---|---|---|
signing_model | one of a closed list: village_custodied, threshold_hybrid, participant_held | required |
captured_at | ISO 8601 UTC with milliseconds | required |
capture_mode | one of a closed list: online, queued | required |
profile_id | one of a closed list: generic_v1, honey_nz_v1, beef_nz_v1, dairy_nz_v1, kiwifruit_nz_v1, nz_mpi_animal_products_v1, nz_mpi_plants_v1 | optional |
mandate_ref_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
disclosed_classes | one or more classes, sorted and distinct: evidence_salt, lot_salt, plot_geometry, quantity_opening | required |
commitment_sha256_list | 0 to 256 digests, sorted and distinct | required |
recipient_class | one of a closed list: buyer, regulator, auditor, certifier, other | required |
occurred_at | ISO 8601 UTC with milliseconds | required |
provenance_archive_root
| Field | Form | Presence |
|---|---|---|
covers_from_date | date YYYY-MM-DD | required |
covers_to_date | date YYYY-MM-DD | required |
day_root_hashes_root | 64 lowercase hex characters (a SHA-256) | required |
previous_archive_root_sha256 | 64 lowercase hex characters (a SHA-256) | required |
hash_algorithm | one of a closed list: sha256 | required |
provenance_attestation_receipt
| Field | Form | Presence |
|---|---|---|
signing_model | one of a closed list: village_custodied, threshold_hybrid, participant_held | required |
captured_at | ISO 8601 UTC with milliseconds | required |
capture_mode | one of a closed list: online, queued | required |
profile_id | one of a closed list: generic_v1, honey_nz_v1, beef_nz_v1, dairy_nz_v1, kiwifruit_nz_v1, nz_mpi_animal_products_v1, nz_mpi_plants_v1 | optional |
mandate_ref_sha256 | 64 lowercase hex characters (a SHA-256) | by the rules below |
lot_ref | 64 lowercase hex characters (a SHA-256) | required |
attester_did | a DID, did:<method>:<identifier> (identifier at most 200 characters) | required |
attestation_receipt_sha256 | 64 lowercase hex characters (a SHA-256) | required |
delivery_id | lowercase UUID | required |
The rules a step follows
- Required:
lot_ref,step_type,occurred_at,product_class,evidence_refs(0 to 16 entries). location_classis required on every step type excepttransport_leg, which carriestransport_mode,from_classandto_classinstead.input_lot_refs(1 to 32, sorted, distinct) appears only on aprocessstep that begins a new lot.supersedes_receipt_sha256andcorrection_reasoncome as a pair: a correction names the step it replaces; the earlier record stays.prev_receipt_sha256is absent on the first record of a lot in a party's chain and required on every later one.- A location class is
{ country, region?, facility_type, establishment_ref_sha256? };facility_typeis one offarm,orchard,vineyard,apiary,vessel,aquaculture_site,processing_plant,packhouse,cool_store,winery,warehouse,port,airport,border_inspection_post,distribution_centre,retail_site,other. - An evidence entry is
{ evidence_kind, sha256, salted };evidence_kindis one ofdocument,lab_result,certificate,seal,photo,sensor_log,weighbridge_ticket,other;saltedis"true"or"false".
The rules a handover follows
| Role / status | giver_proposal_sha256 | receiver_reply_sha256 | reason_code |
|---|---|---|---|
| giver / proposed | absent | absent | absent |
| giver / withdrawn | required | absent | expired, withdrawn_by_giver, declined_by_receiver |
| giver / completed | required | required | absent |
| receiver / accepted | required | absent | absent |
| receiver / declined | required | absent | not_expected, quantity_mismatch, evidence_mismatch, damaged, other |
A receiver's acceptance begins its chain for the lot and carries no prev_receipt_sha256 unless it takes back a lot it held before. A handover is matched across parties by handover_id and the terms digest; the receiver's lot_ref must equal the giver's.
The chain rules
Per party and lot, a chain begins with exactly one of: a harvest; a process step with input_lot_refs; a receiver's acceptance. Then: receive at most once and first after an acceptance; process, pack, inspect and transport_leg any number of times; export_declaration at most once, after which only inspect, transport_leg, corrections and a proposal; import_clearance at most once and only in a chain begun by an acceptance; delivery closes the chain (corrections only after it); harvest never after the start. A proposal moves the lot to pending, where only inspect, corrections, a withdrawal (back to open) and a completion (handed over) follow. The check re-applies every rule and names the one broken. Two parties each beginning the same lot reference with no handover between them is incomplete (lot_claimed_by_two_parties). A damaged or unreadable entry never explains away a gap: both are reported. A correction inherits the derivation inputs of the step it supersedes, following the chain of corrections back to the original, so correcting a derivation step keeps the lot derived and its mass balance checked.
4. Canonical JSON
- UTF-8, no whitespace outside strings.
- Object keys sorted by UTF-16 code unit, compared as unsigned 16-bit numbers; duplicate keys are refused by the parser before canonicalisation.
- Strings: " and \ escaped; U+0008, U+0009, U+000A, U+000C and U+000D as \b, \t, \n, \f, \r; every other code point below U+0020 as \u followed by four lowercase hex digits; everything else written as itself. A lone surrogate is refused.
- Numbers: integers only, from -(2^53 - 1) to 2^53 - 1, written in decimal with no sign on zero, no leading zero, no exponent.
- No null and no boolean: a field is present or absent; a flag is the string "true" or "false".
- At most 64 levels of nesting.
The test vectors give inputs, their canonical text and its SHA-256, and inputs that must be refused.
5. Hashes and domain bytes
Every tree hash starts with one byte saying what it is, so a value of one kind can never be read as another.
| Byte | Used for | Input after the byte |
|---|---|---|
| 0x00 | day-tree leaf; also the separator in the receipt hash | the 32-byte receipt hash (in the receipt hash: canonical bytes || 0x00 || signature) |
| 0x01 | node, in the day tree and in the lot tree | left (32 bytes) || right (32 bytes) |
| 0x02 | lot leaf | sortkey (32 bytes) || inner (32 bytes): 65 bytes in |
| 0x03 | lot sortkey | lot_ref (32 bytes) || the day root's date, 10 ASCII bytes "YYYY-MM-DD": 43 bytes in |
| 0x04 | lot inner | count (4 bytes, unsigned big-endian) || lot_subroot (32 bytes): 37 bytes in |
6. Signatures and keys
- The signed bytes are the canonical JSON of the record including
time_attestationand withoutsignature; these arecanonical_bytes_base64. - The signature is Ed25519 (RFC 8032) over those bytes, checked against the 32-byte key in the party's key document. The checker uses the browser's or Node's Ed25519, and its own implementation where that is missing; both refuse a non-canonical point or an out-of-range scalar.
- A key fingerprint is SHA-256 of the 32 key bytes. A reader who gives a party's fingerprint pins that party's key; without one the result is
key_not_pinnedfor that party. - A reader pins each party by the fingerprints that party gave them. A party that signs with more than one key, such as a
threshold_hybridparty (its split key for the co-signed kinds, the Village key for the rest), is pinned by all of them. The checker takes a list, or one string with the fingerprints separated by commas. Every record's signing key must be one of the fingerprints given, and every fingerprint given must be a key in that party's key document or key history; otherwise the result iskey_mismatch. Which key a record's signing model may use is held by the standing authorisation the record names, which states the key. - A key is withdrawn by a
key_historyentry withrevoked_at, or by the reader listing its fingerprint. A record whose anchored time is after the withdrawal, or that has no outside time, isunanchored_under_withdrawn_key. - A key document may list at most 16 keys. Only the key the record names in its
signer_did(the part after #, matched to that key's id in the party's key document) can verify it: a record whose signature checks under another of the party's keys, or that names a key the document does not hold, ischanged, whatever is pinned. - A reader may give at most 16 fingerprints for one party; any given beyond 16 are not read, and the result names each one dropped. A fingerprint given for a DID that is not a party in the file, or bound with a DID not spelled exactly as the party's (for example with # written as %23), is named on its own line as unused (pin_unused) and changes no verdict. A fingerprint in a party's key history that no record of the file uses is named on a line, and the verdict is unchanged.
- A fingerprint may be bound to a key id:
KEY=FINGERPRINTin a party's box on the page, or--pin DID#KEY=FINGERPRINTon the command line. A bound fingerprint must be the key the document holds under that id: a different key there iskey_mismatch(pin_bound_mismatch), and an id the document does not hold iskey_mismatch. A bound pin is compared with the key the document gives that id even when no record names it (pin_bound_document_mismatch,key_mismatch). - A party with a segment but no record whose signature was checked is still held to its pin: the fingerprint is compared with its key document (pin_no_records); a fingerprint that is not a key there is
key_mismatch. - A key document must be consistent: one key listed under two ids, or one id listed twice with different keys, is
changed, the document named inconsistent, whatever is pinned. - A record signed under a joint signing model (
threshold_hybridorparticipant_held) must verify under a key different from the one the party uses for itsvillage_custodiedrecords; otherwise the record isincomplete(signing_key_not_distinct) and its label does not say it was signed jointly.
7. The day tree and day roots
Each party numbers its records 1, 2, 3 and so on (tenant_seq), across all its lots, and signs one day root per date over a contiguous range of them.
- Leaf = SHA-256(0x00 || receipt hash). Node = SHA-256(0x01 || left || right). An odd node at the end of a level is promoted unchanged. One leaf: the root is that leaf. No leaves: the root is SHA-256 of no bytes.
- An inclusion proof lists siblings from the leaf upward as
{ hash, side }: "L" means the sibling is on the left (node = SHA-256(0x01 || sibling || current)), "R" on the right. - A day root carries
date,root(the tree over its range in number order),first_tenant_seq,last_tenant_seq,count,previous_rootandprevious_root_record_sha256(64 zeros on the first), and the lot commitmentlot_commit_rootandlot_commit_leaves. - Where every record of a day root's range is in the file, the check rebuilds the root from them, so a missing proof changes nothing; otherwise each record shown must carry a proof that reaches the root.
8. Lot commitments
A day root covers all of a party's lots, so a one-lot file shows a selection of its range. Each day root therefore also signs a tree with one leaf per lot carrying that lot's record count and subroot, built so that a neighbouring leaf reveals nothing about another lot.
- The records of the range are grouped by
lot_ref. A sequenced record that is not a step, a handover or an attestation is committed under one of two reserved lot references: erasure and disclosure records under the omittable party leaf, SHA-256 of the ASCII textprovenance:party_omittable_records:v1, which is316f1a126fb367fd21d30a6b3529c29361885d3fc60902e5de2c2dec7aae1876; every other party record (a standing authorisation, key record or authority confirmation, and any kind added later) under the party leaf, SHA-256 of the ASCII textprovenance:party_records:v1, which is1e9111626cca531d5772865a9c298c94b521d33ea632e281e2f41b596d4ab814. No lot may use either reference, as alot_refor amonginput_lot_refs. An attestation is committed under the lot it speaks about, its ownlot_ref(its signer holds no lot row for it and it joins no lot chain): a file for that lot that carries the attester's segment shows it against the attester's count for the lot, and one left out readsincomplete. Each included day root with a lot commitment carries an entry or an absence proof for both reserved references and for the lots of the records the segment shows; a segment that shows no step or handover of the file's lots (a party in the file for its attestations) owes this per day root, for the lots of the records it shows in that day root's range, and nothing for the file's other lots, so an attestation pack built for one lot is read in a file for a lot later made from it. A reserved leaf stripped whole, records and entry, isincomplete(lot_absence_missing). What keeps an attestation from being dropped together with its entry is the holder's receipt of it (see "Other party records"). count: the lot's records in the range (1 to 232 − 1).lot_subroot: the day-tree root over the lot's receipt hashes in number order.- sortkey = SHA-256(0x03 || lot_ref || date). inner = SHA-256(0x04 || count || lot_subroot). leaf = SHA-256(0x02 || sortkey || inner).
- The lot tree orders leaves by the 32 raw bytes of sortkey, ascending, and combines them with node = SHA-256(0x01 || left || right), an odd node promoted, the leaf hashes used as they are. No lots:
lot_commit_rootis 64 zeros andlot_commit_leavesis 0. - A
lot_commitsentry carries exactlyday_root_receipt_id,lot_ref,count,lot_subroot,index,proof,neighbours;neighboursis{ previous, next }, eachnullor exactlysortkey,inner,proof. It carries no date, sortkey or inner of its own: the check computes them from the signed day root. - The path flags of index i in a tree of n leaves are fixed: p = i, m = n; while m > 1: if p is odd the step is "L"; if p is even and p + 1 < m the step is "R"; if p is even and p + 1 = m there is no step; then p = floor(p / 2), m = ceil(m / 2). A proof's sides must equal that list exactly (at most 32 steps), so the index is verified, not trusted.
What the check does with an entry
- The day root it names must be in the party's segment and its signature must have verified (an entry naming a day root not in the segment is
changed).indexis from 0 tolot_commit_leaves− 1, and the leaf walks to the signedlot_commit_rootalong a proof with the flags of its index. previousis null exactly at index 0 andnextexactly at the last index; each one present walks to the same root with the flags of its own index, and previous.sortkey < own sortkey < next.sortkey, strictly.- More records shown than
countischanged; exactlycountmust rebuildlot_subroot, elsechanged; fewer isincomplete. - Every shown record inside an included day root needs an entry for (that root, its lot) or the file is
incomplete; the counts under one day root may not add up to more than itscount; where a party's day root range is wholly in the file, the counts are recomputed from all its records and must equal the signed root. - A lot that matches its signed count under every day root covering it needs no interior-hole rule: numbers not shown are taken as other lots' records, on the signer's own signed word.
What this establishes: the signer signed, under that day root, a leaf binding the lot to a count and a subroot, and the records shown match it or they do not. What it does not: that the signer's tree holds no other leaf with records of the lot. A party holding its own key can place a second leaf for the lot away from the shown one, and the check, which sees the shown leaf and its two neighbours, accepts it. The not-established list says so.
Reasons a builder lists in lot_commit_unproven:
| Reason | Meaning |
|---|---|
records_not_shown | Fewer of the lot's records in that day root's range are in the file than its count (a pack cut at a handover). |
day_root_without_lot_commit | The day root carries no lot commitment. |
lot_commit_not_supplied | A stored pack made before lot commitments existed. |
9. Lot absences
For every day root of a party in the file that carries a lot commitment, the file must show, for each lot shown anywhere in the segment, either the lot's leaf or that the signed lot tree holds no leaf for it. A lot_absences entry carries exactly day_root_receipt_id, lot_ref, between, previous_index, next_index; between is { previous, next } in the neighbour form above.
lot_commit_leaves0: valid only with the zero root, both neighbours null and both indexes null.- Otherwise at least one neighbour;
previousnull exactly whenprevious_indexis null, and thennext_indexis 0;nextnull exactly whennext_indexis null, and thenprevious_indexis the last index; with both,next_index=previous_index+ 1. - Each neighbour walks to the signed root with the flags of its index, and previous.sortkey < the lot's sortkey < next.sortkey, strictly. Two leaves at consecutive indexes of a sorted tree with the sortkey strictly between them leave no place for a leaf with that sortkey.
- An absence that does not check is
changed; a shown lot with neither an entry nor an absence for a day root that carries a commitment isincomplete.
10. Standing authorisations and signing models
Every sequenced record states its signing_model: one of village_custodied, threshold_hybrid, participant_held. village_custodied: the organisation's key is held by its Village and used on a standing authorisation the organisation's owner gave. threshold_hybrid: the signature needs key shares from more than one holder, one of them the Village, so neither signs alone. participant_held: key shares held by the organisation's own custodians; the Village holds none. The record also states captured_at and capture_mode (online or queued; an online capture is never after decided_at; a queued capture, entered on a device with no connection and sealed when it reconnects, is never more than 5 minutes after it), so a record entered offline and signed later says so. captured_at is the device's clock and is not checked. Where captured_at and decided_at are more than 15 minutes apart, in either mode, the check prints both (record_capture_time, informational): "Time recorded by the device: … Time sealed: … The device's clock was not checked."
A standing authorisation is a provenance_mandate record, signed under the custody model it grants: custody_model (village_custodied or threshold_hybrid), the key_id it covers, a scope (sorted, from archive_root, attestation, day_root, disclosure_event, erasure_event, handover, key_event, step, tsa_confirmation), grantor_role owner, grantor_auth (a grant needs passkey or totp), valid_from and valid_until (at most 12 months apart), the three acknowledgements in order (erasure_limits, legal_framing, sealed_permanence) and privacy_note_version. A grant must be signed with the key it names in key_id: a grant signed by any other key covers nothing, and the records that name it are incomplete, with the reason named. A withdrawal or renewal is a later mandate naming the earlier one in supersedes_mandate_sha256. Every record signed under village_custodied or threshold_hybrid names its mandate in mandate_ref_sha256 (the mandate's receipt hash); a mandate and a participant_held record carry none.
- The mandate a record names must be in the file, signed by the same party; otherwise the record was signed outside any standing authorisation shown, and the result is
incomplete. - The mandate must cover the record: its kind in scope, the same custody model,
decided_aton or aftervalid_fromand beforevalid_until, no withdrawal or renewal in the file taking effect first, and the key the record names. The first failing reason is named (out of scope, another key arrangement, not yet valid, expired, withdrawn, another key); the result isincomplete. - A key document may say how a key is held (
custodyon its key entry or key-history entry). Where it disagrees with the signing model of a record signed with that key, the result isincomplete. - A detached day root: a standing authorisation is often granted days before the records that name it. A segment labelled
selectivemay carry, before the party's unbroken run of day roots, the day root that holds that mandate (or a withdrawal or renewal of it) without the day roots between. Such a day root is accepted only before the run, only when it shows no lot record (step or handover), and only when it shows a mandate that the records of the run name; anything else breaks the run as usual. Acomplete_rangesegment is never read this way. The check names each detached day root on a line. - What a detached day root cannot show, stated on that line and here: a record the party signed on a day between the detached day root and the run, such as a withdrawal of the mandate or a key record, is not in the file and would not show.
The signing part of every label is printed as the record states it, under the heading "Signing, as the record states it (this page cannot check how the key was held)": a dossier carries no evidence of how a key holder kept its key, and the not-established list says so. The source sentence and the signing sentence of a label are chosen independently, so a holder's own attestation and a signing the page cannot show are both stated, and a label never says "Signed jointly" for a signing the page cannot show.
11. Evidence strength and corroboration
Every record other than a day root is labelled from two of its own signed fields: attestation_source (on a step, self or device; device needs a weighbridge_ticket or sensor_log among its evidence; an attestation is always third_party) and signing_model. The label's text is fixed per value: the source says whose word the record is, and the signing part, printed as the record states it, says who held the key, with the dates of the standing authorisation when it is in the file. The test vectors carry every label and its exact text.
Corroboration is worked out per point of the subject lot (origin, handling, quantity, inspection): contested where another party that did not hold the lot recorded a third_party attestation of fail about that point, corroborated where one recorded pass or observed, otherwise uncorroborated. The parties that held the lot never corroborate one another, and a lot holder's own third_party attestation about that lot is the holder speaking, not corroboration: it is left out before the levels are worked out. Only records whose signature verified, from parties the reader pinned that hold no record of the lot in the file, can corroborate or contest a point; the limit (a party with no record of the lot in this file may hold one elsewhere) is said once. Where a lot holder signs a third_party attestation on its own lot, the label says the file shows that party holds the lot. A superseded step does not count. The file's own corroboration and evidence_strength are unsigned and are checked against what the check works out.
12. Time stamps: the quorum and the qualified label
- Each token in
tokensmust be the SHA-256 of its own bytes, and must check as an RFC 3161 token over the record's anchored digest, issued by an authority whose roots this checker carries. A record whose stated tokens are missing or do not check givestimestamp_missing, neververified. - For each record the check states how many authorities the record says answered against the number required (2 on a day root and an archive root, 1 otherwise) and how many it checked itself, for example "only one authority answered (2 required)".
- A quorum counts different authorities, each with its own token: each obtained entry must come from a different authority and carry a different token. The same authority twice, or two authority names over one token, is refused by the platform before signing and by the signing host, and a file carrying such a record is not verified. The check counts only tokens it verified itself, and counts authorities cautiously. An authority is every key on a token's checked path together with the operator of the anchor the path ends at: two paths that share any key, or end at anchors with the same operator, are one authority (the checker's bundled anchors name their operators: both Certum roots are
certum, Aruba isarubapec, the Portuguese state root ispt-sce). The count is never more than the number of distinct stamping certificates. Where the two differ for a record whose stated quorum is met, the checker's time line for that record says both, as "(N authorities, M stamping certificates)": that text is in the checker's result and in the command-line report (printed with--authorities), and the result carries both counts as the line'sauthoritiesandstamping_certificates. The browser page writes its own time line and shows the number of authorities only. A record whose signed quorum says met but whose checked tokens make fewer authorities than required isincomplete(quorum_not_met_here), whatever authority names the record gives. - A token is labelled qualified only when both hold: a certificate on the path the checker itself verified has the key of a qualified time-stamping service as pinned in the checker's own dated snapshot of the trusted lists (today IT sequence 224, issued 2026-09-24; PL sequence 162, issued 2026-09-18; PT sequence 121, issued 2026-10-07, read 13 October 2026; the lists' own XML signatures are not verified by the checker). The label also needs the list entry to be of type qualified time-stamping service (QTST), with status granted and a status start no later than the token's time; the snapshot holds each service's current status only, so a status that began after the stamp earns no label, nor does a withdrawn status or another type. The Italian entry was re-read on 13 October 2026: sequence 224, type QTST, granted, status start 27 November 2017; and a party in the file has confirmed that authority as qualified in a signed
provenance_tsa_confirmationwhosetsu_anchor_sha256matches the token's certificate and which no later record withdraws. Every other token is "not verified as qualified"; the record's ownqualified_statusis never taken as the label. - A
provenance_tsa_confirmationnamesauthority_id(certum,aruba,nz_tsa,pt_state), the fingerprint of the stamping certificate the party matched, the jurisdiction of the trusted list read (an EU or EEA code), its sequence and issue date, astatus(confirmed_qualified,confirmed_not_qualified,withdrawn),confirmed_atandconfirmer_roleowner. It seals no link: the checker prints the official list's address from its own table. A confirmation is superseded or withdrawn only by a later confirmation of the same party; a record of another party naming it changes nothing. While a party's confirmation that an authority isconfirmed_not_qualifiedstands, no token of that authority is labelled qualified, whatever another party confirms. A confirmation that cites an older entry of the trusted list (a lower sequence) than the snapshot the checker carries does not earn the label, and nor does one citing another member state's list or the same sequence with a different issue date: a confirmation earns the label only when it cites exactly the list entry the checker carries; the line says why. A confirmation citing a newer issue of the list than the checker carries does not override the checker's snapshot, which decides; the line says the confirmation cites a newer issue.
13. Key records
A provenance_key_event records that a key was rotated, withdrawn, compromised, with key_id and effective_at. A rotation names a different successor_key_id and is signed by the successor; a withdrawal or compromise is signed by the key it names or by its successor. From effective_at the key signs nothing that counts: a record signed with it whose anchored time is at or after that moment, or that has no outside time, is unanchored_under_withdrawn_key; a record anchored before it still verifies.
A compromise is different: its effective_at is the party's own statement and may be back-dated. The check prints it as a stated compromise time, says it does not verify it, and lists every record signed with that key against it: signed before it by its checked outside time (shown, no finding), signed after it, or not shown either way because no outside time was checked (both unanchored_under_withdrawn_key). No record is quietly counted as good or bad.
14. Other party records
provenance_attestation: a party's signed statement about a lot it does not hold here (inspection, certification, audit; point origin, handling, quantity, inspection; outcome pass, fail, observed). provenance_erasure_event and provenance_disclosure_event list the commitments affected (sorted, distinct) with a closed class, basis or recipient class, and no personal data. provenance_archive_root is dated and unsequenced, like a day root, and covers a run of day roots; this checker checks its signature and its contract only, and says so.
provenance_attestation_receipt: the holder's own signed record that an attestation about its lot was delivered to it through the platform. It names the holder's lot_ref, the attester_did, the attestation's receipt hash attestation_receipt_sha256 and the delivery_id, and nothing of what the attestation says. It is sequenced and committed under the holder's lot like a step, joins no lot chain (no prev_receipt_sha256), and is shown in every file for that lot; one about a lot that is not the file's lot or an input of it is changed (foreign_lot_records). For each such receipt the file must show the attestation it names in the attester's own segment, about the same lot, with an inclusion proof into a signed day root of that party; otherwise the file is incomplete (attestation_receipt_unmet), whether the attestation, the attester's segment or its day root for that day was left out. This holds for the day roots the file includes: a file cut after an earlier day root of the holder or of the certifier, leaving out its trailing day root, can still drop a delivered attestation without trace: the general limit on a party's day roots that are not in the file, which the not-established list states. A receipt that is met gives an informational line (attestation_received); each party in the file for its attestations gives one line stating its signed day roots in the file (attester_roots_shown); an attestation that no receipt in the file names gives an informational line saying it did not reach a holder shown here through the platform (attestation_unreceipted). Such an attestation can be left out of a file together with its entry and nothing in the file says so; the not-established list says this too.
15. Openings, erasures, mass balance and profiles
A commitment is a fingerprint of a value and a random salt: it fixes the value without showing it, and it is opened by disclosing the value and the salt, which anyone can hash again. The word is kept in English on every page and in every language, with this gloss.
- Plot openings. For each entry of
plot_openingsthe check hashes the geometry and the salt again. A match with the commitment a shown record signed gives a line naming the plot and its number of corner points (plot_opened); a mismatch ischanged(plot_opening_mismatch); an entry for a commitment no shown record carries is listed and not read (plot_opening_unused). A plot or quantity opening is said to match only for a record whose signature verified (plot_opened, quantity_opened); for a record that did not verify, the line says the opening was not checked against it (opening_record_unverified). Where the same party signed a disclosure record naming that commitment, verified and pinned, a line says so (plot_disclosure_signed); otherwise a line says the boundary was disclosed without a signed disclosure record in this file (plot_opened_without_disclosure). - Erasure. A commitment whose data its party erased is shown as "Commitment unopened (data erased)", informational, never as a change: only when an erasure record of the same party, whose signature verified and whose key the reader pinned, names that digest for that class of data (or for all). The erasure record must also be proved into a day root in the file and must not be signed under a key its party states was compromised before it (not unanchored); the date shown is the date the party states. Disclosure lines follow the same rules. The dossier may include the party's own erasure records for this: at most 64 per segment, earliest first, each naming a digest a shown lot record carries.
- Mass balance. Over the steps whose signatures verified and the quantities opened in the file, one informational line: balanced (the quantities opened add up: no lot made from others holds more than they did, and no lot's quantity grows along its records; it says only that the figures in the file add up), unbalanced, not compared (the quantities are in different kinds of measure), or not checked (a quantity opening does not match its commitment, or not every quantity needed is opened).
- Profiles. A record may name a profile:
generic_v1,honey_nz_v1,beef_nz_v1,dairy_nz_v1,kiwifruit_nz_v1,nz_mpi_animal_products_v1,nz_mpi_plants_v1. A well-formed profile this copy of the checker does not list gives one informational line per profile, "Profile not known to this checker", naming the parties; those records are checked as any other, and what the profile adds is not checked. An adapter id such as the EU Deforestation Regulation supplier pack is not a profile id.
16. Verdict words, tiers and exit codes
Every finding raises one word. The headline is the word of the lowest tier raised; within a tier the first raised stands. The exit code is the one a command-line run gives.
| Word | Tier | Exit code | Meaning |
|---|---|---|---|
changed | 1 | 1 | A signed record, a stated value, or an entry of the file does not match what was signed. |
key_mismatch | 1 | 1 | A party's key is not the one whose fingerprint the reader gave. |
gap_in_sequence | 1 | 1 | A record that a day root or a chain link says exists is missing from the file. |
handover_unmatched | 1 | 1 | One side of a handover is in the file without the other, or names a record that is not there. |
handover_disputed | 2 | 2 | Both a withdrawal and an acceptance of one handover are in the file; the check states which outside time stamp is earlier and chooses no winner. |
unanchored_under_withdrawn_key | 2 | 5 | A record is signed with a key the file or the reader says was withdrawn before the record's anchored time. |
incomplete | 3 | 3 | Nothing is shown to be wrong, but the file does not hold enough to say it is whole (a record not covered by a day root, an unproven lot commitment, a missing input lot or upstream party). |
unknown_version | 3 | 6 | A version this checker does not read. |
unreadable | 3 | 6 | The file, or part of it, is not in the format: not JSON, a duplicate key, a field the format does not have, a limit exceeded. |
not_finished_within_limit | 3 | 6 | The check was stopped, or neither signature engine could run. |
not_understood | 4 | 4 | A record of a kind this checker does not read. |
sandbox_receipt | 5 | 3 | A record made in a sandbox. |
key_not_pinned | 6 | 4 | The reader gave no fingerprint for a party, so who holds its key is not established. |
key_history_missing | 6 | 4 | No key history for a party, so whether its key was withdrawn is not established. |
timestamp_missing | 6 | 4 | A record's time stamp is absent or does not chain to an authority this checker carries. |
verified | 7 | 0 | Every check passed, and the file is whole for every party in it. |
17. The checks, in order
- Parse: at most 25 MB; strict JSON with no duplicate field name; nesting at most 64; no unknown top-level, segment or entry field; family
provenance; version 1; at most 20000 records. - Each record: the base64 is canonical; the bytes are canonical JSON that re-canonicalise to themselves; the readable copy equals them; version 1; the contract of its kind.
- Each signature against the key in that party's key document; the fingerprint the reader gave for that party; key withdrawals.
- Time stamps: each token's key is its hash; each token is checked as RFC 3161 against the anchored digest and the authorities this checker carries.
- Handovers across parties: proposal and reply over one terms digest, each signed by its own party; the terms digest recomputed; the receiver's lot_ref equal to the giver's.
- Per party: the sequence its signed day roots cover, the day-root links, each inclusion proof (at most 64 steps), the lot commitments and absences.
- Per party and lot: the chain from its first record to its last, then the chain rules.
- Standing authorisations and key records: each record signed on a party's behalf held to the mandate it names; key documents that disagree with a record's signing model; keys stopped by key records.
- Time: per record, the authorities that answered against the quorum, and per token the qualified label rule.
- Evidence strength and corroboration, worked out from the signed records and compared with any the file states.
- Disclosures: lot salts, evidence salts (and a seal number the reader types), quantity openings, evidence files the reader drops.
- The lot graph, recomputed and compared with the one stated.
Nothing is fetched: the checker makes no network request. It returns a result for any input and never throws.
18. What the check does not establish
Printed under every result, by the checker itself:
- Who any party is. A DID is a name a party chose for its key. Whether it belongs to the business on your contract is for you to confirm with that business, for example by comparing the key fingerprint they give you with the one shown here.
- Whether anything a party declared is true. A grower's harvest record shows what the grower signed and when. It does not show where the produce was grown, what it contains, or that the statement is accurate.
- What a referenced document says. Where the document is in this file, the page checks only that it matches its fingerprint; where it is not, nothing about it is checked.
- That nothing was left out. A party can choose not to record a step. For each day root of a party in this file, the records shown for this lot match the count of records that party signed for the lot in that day root, or that party signed that it made none that day, so a record cut from the file or added to it shows. On a day whose day root is not in this file, a record of this lot would not be seen.
- Whether a party made a correction after this file was put together, or one made after its last day root in this file was left out when the file was put together.
- Whether the lot meets any law, standard or scheme, including the EU Deforestation Regulation.
- The time of a record whose time stamp is absent or does not chain to an authority this page carries.
- Whether a key was withdrawn. The page shows a withdrawal only as this file states it, unless you give it the key history yourself or list the keys you know were withdrawn.
- Whether a party made later records for this lot that are not in this file. A record made after the last of a party's day roots in this file, such as a later record by the last party to hold the lot, a correction a party made after handing the lot on, or what a party did with an input lot after deriving from it, would not show.
- Whether a party signed only one history. A party controls its own signing key, so it could sign a day root whose count for a lot leaves out or hides some of that lot's records, or sign a second branch of its chain for this lot, for example one handing the same lot to someone else as well. The page shows that this file agrees with what each party signed, not that a party signed nothing else. A buyer who needs more can ask that party for a dossier showing its records in full. The same holds for a party's records that belong to no lot: a dishonest signer could file a key or authorisation record among its erasure and disclosure records, which a file may leave out and of which this page only counts the ones not shown. Nor does the page show an attestation that another party made and that was never delivered through the platform to a holder in this file: no chain here records receiving it, so a file can leave it out and nothing in the file says so.
- How a signing key was held. The record states how it was signed; this check cannot see inside a key holder's systems.
19. Test vectors
test-vectors.json (SHA-256 5b17d67e5c4083d88ff1e48ce85df0d3701bacb51f5823de32b77b6fc6850053) holds: canonical JSON inputs, their canonical text and hash, and inputs that must be refused; a record signed with the private key of RFC 8032 section 7.1, test 1 (published in that RFC; it must never sign anything real), with its canonical bytes, signature, receipt hash, anchored digest and day-tree leaf, and the same bytes with one byte changed, whose signature fails; a signed record of every other kind the milestone 1 schema adds (standing authorisation, key record, attestation, authority confirmation, erasure and disclosure events, archive root, and the holder's receipt of an attestation), with the quorum and qualified status in the time block; a step captured offline (queued) whose device time is exactly 5 minutes after its sealing time, which is valid, the same one millisecond later, which is refused, and one captured three hours before it was sealed, which is valid; the party leaf; every evidence-strength label and corroboration vector; day-tree roots of 1, 2, 3 and 5 leaves and every proof of the 5-leaf tree; and the lot commitment and absence vectors: a three-lot tree, a two-lot and a one-lot tree, the path flags up to five leaves, a forged tree with two leaves for one lot, and one absent lot in each gap of the three-lot tree. The published checker meets every vector.
Sample dossiers: samples.json lists 14 sample files (four-party-honey, four-party-honey-altered, honey-chain, honey-chain-altered, honey-chain-incomplete, beef-chain, beef-chain-altered, beef-chain-incomplete, dairy-chain, dairy-chain-altered, dairy-chain-incomplete, attestation-delivered, attestation-delivered-stripped, queued-capture), each with its SHA-256, the key fingerprints to give, what it carries and the result to expect, read from the manifest itself.
20. The checker and its fingerprint
The checker is one hand-written file with no build step: the file served is the source, so its fingerprint is the fingerprint of the source. It runs in a current browser and in Node 20 or later and needs nothing else.
provenance-verifier.js, SHA-256:
eff7425271fd4ff57ac0d27e6e05944de7292c9297edde4396e239f53128618f
The same value is at provenance-verifier.js.sha256, and the export puts a dossier together only when the copy it places inside matches it. To compute it yourself from a copy you hold:
sha256sum provenance-verifier.js # Linux
shasum -a 256 provenance-verifier.js # macOS
certutil -hashfile provenance-verifier.js SHA256 # Windows
node -e "process.stdout.write(require('crypto').createHash('sha256').update(require('fs').readFileSync('provenance-verifier.js')).digest('hex'))"
Hash the file's bytes as downloaded, without opening and saving it in an editor, which may change line endings.