Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Substance binding — binding what a record says

Anchoring proves that a 32-byte root existed at a moment in time. That is a claim about a hash. Substance binding is what puts your content behind that hash — field by field — so the same proof that says “this root existed” also says “and it covers this invoice total, unchanged”.

Change one cent and the proof visibly breaks. That is meant literally, and this page shows you where to check it.

What is committed, and what is stored

When you post a record, Autopliance:

  1. Canonicalises the payload (RFC 8785, the same JSON canonicalisation the identity world uses under SD-JWT), so there is exactly one byte sequence per logical content.
  2. Computes a salted commitment for every field: SHA-256(ds ‖ path ‖ 0x00 ‖ salt ‖ 0x00 ‖ enc(value)).
  3. Builds a Merkle tree over those commitments using the Certificate Transparency rules — RFC 6962 §2.1, carried unchanged into its successor RFC 9162 §2.1.1 — the same construction the logs guarding the web’s certificates run on.
  4. Puts the tree’s root into a record header, signs it, and links it to the previous record in its stream.

Your payload goes to the vault, encrypted under a key belonging to the data subject it is about. Your header goes to the chain. The chain contains commitments and metadata and has never contained a customer value in any column.

A record page in Autopliance for a stream called test.e2e.invoices, record 3. It shows a commitment root, a count of four fields committed, a chain leaf, the leaf it links from, the subject, and the signing key. Below, a Fields table lists four fields — counterparty id, due date, total amount and currency — each with its kind, its value, its own commitment, and a tick-box for including it in a disclosure.
One record, as the four steps above leave it. The header carries the commitment root and the link to the previous record; the Fields table shows the per-field commitments that root was built over — one commitment per field, which is what makes a single field disclosable on its own. The values are visible on this page because it is the account holder's own view, decrypted from the vault; the chain holds only the commitment column. Fixtures are deliberately marked (test.e2e.*, TEST-SUBJECT-…, an edssa.invalid key) so nothing here can be mistaken for a customer record.

Getting started

You need an API token.

1. Declare a stream

A stream is a named sequence of records of one kind. Records in it carry dense sequence numbers, which is what makes a missing one visible.

curl -X POST https://app.edssa.io/v1/streams \
  -H "Authorization: Bearer $AUTOPLIANCE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "erp.invoices",
    "declarations": {
      "ooo:/invoice/total/amount_minor": "money",
      "ooo:/invoice/total/currency": "currency",
      "oo:/invoice/due_date": "timestamp"
    }
  }'

Declarations are optional — a stream with none still commits every field. Declaring says what kind of value a field must be, so a money field can never quietly become a float.

2. Post a record

curl -X POST https://app.edssa.io/v1/records \
  -H "Authorization: Bearer $AUTOPLIANCE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "stream": "erp.invoices",
    "record_type": "invoice.issued",
    "occurred_at": "2026-08-15T09:12:33Z",
    "subject_id": "acme-oy",
    "payload": {
      "invoice": {
        "total": { "amount_minor": 1249900, "currency": "EUR" },
        "counterparty_id": "acme-oy",
        "due_date": "2026-09-14T00:00:00Z"
      }
    }
  }'

You get back the sequence number, the commitment root, the chain leaf and the signed header.

The payload — your JSON, your shape

The payload is your own JSON, in whatever shape your system already produces. There is no required schema and nothing to register in advance: Autopliance walks the object you send and commits every leaf field individually, wherever it sits. Nesting is fine, arrays are fine, key order does not matter (the payload is canonicalised before anything is hashed, so {"a":1,"b":2} and {"b":2,"a":1} are one record).

Two consequences of “every field individually” are worth designing for:

  • A field is the unit of disclosure. You can later reveal invoice.total.amount_minor on its own precisely because it is its own field. If you pack several facts into one string — "summary": "1249900 EUR, Acme Oy, due Sept 14" — you can only ever reveal or seal them together. One fact per field keeps your options.
  • A field is the unit of proof. Change any committed value by one character and that field’s commitment — and the record’s root — visibly breaks. That is as true of a free-form note as of an amount.

Structure is opt-in, per stream, through the declarations you saw in step 1. A stream with no declarations accepts any shape and commits every field with the generic encoding. Declaring a field adds a constraintmoney refuses fractions, timestamp refuses non-RFC-3339 strings — and declaring the stream "strict": true goes further: a payload containing any undeclared field is refused outright, which is the right setting once your integration is stable and an unexpected field most likely means an upstream system changed without telling anyone.

The mechanical limits, all refused at ingest with the offending field named — never silently truncated or coerced:

LimitValue
Payload size256 KB
Committed fields per record4 096
Nesting depth~125 levels (the JSON parser’s recursion limit — measured, not theoretical; depth 120 ingests, depth 127 is refused)
Committed path length512 bytes
Integerswithin ±2⁵³ (use minor units or strings beyond that)
Shapea JSON object with at least one field — a bare number or string has no field names, so there would be nothing to disclose selectively
Field namesno control characters

Small print that occasionally matters: an empty object or array is a committed fact (so {"waiver": {}} and a payload without the key are provably different records), null is a committable value, and 1 / 1.0 / 1e0 are one number and commit identically.

Two things that do not belong in a payload:

  • Raw documents. Commit the file’s digest ("contract": "sha256:<64 hex>", declared kind digest) and keep the file wherever your documents live. The digest binds it; a later reveal of the digest plus the file proves the document, and a megabyte of base64 inside a record would only fight the size limit.
  • More than one person’s data. A record has exactly one subject_id, and erasure is per subject — destroying one subject’s key must not take a colleague’s records with it. If a fact involves two data subjects, split it into a record per subject.

Where it all goes: the payload is encrypted in the vault under a key belonging to its subject_id, and only the commitments reach the chain. You can fetch your payload back at any time (GET /v1/records/<id>/payload) — until the subject is erased, after which nobody can, including us.

3. Disclose one field

To a tax auditor: the total and the due date. The counterparty stays sealed — not redacted in the bundle, not in it.

curl -X POST https://app.edssa.io/v1/records/$RECORD_ID/disclosure \
  -H "Authorization: Bearer $AUTOPLIANCE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fields": ["ooo:/invoice/total/amount_minor", "oo:/invoice/due_date"],
    "coverage": { "from_seq": 1, "to_seq": 4183 }
  }'

The optional coverage is a completeness claim: it says nothing is missing from that window of the stream. It is checkable — the verifier walks the sequence numbers — so only make it if you mean it.

Field paths

A committed path looks like ooo:/invoice/total/amount_minor. Two parts:

  • a JSON Pointer (RFC 6901), /invoice/total/amount_minor;
  • a container-kind vector in front, one character per step: o where the step indexes an object, a where it indexes an array.

The vector is there because a pointer alone cannot tell an array index from a numeric object key — {"m":[1]} and {"m":{"0":1}} both give /m/0 — and two different records must never produce one commitment.

In a declaration, array steps are wildcards: oao:/lines/*/sku declares the SKU on every line, not on line 0.

Field kinds

A kind decides the enc(value) in the commitment formula above — the exact bytes that get hashed. The operator console carries a bench for seeing that, one field at a time:

A commitment bench in the operator console. A kind selector set to money, a committed path, and a value of 1249900 produce a line reading 'bytes hashed — enc(value): money:1249900' and a 64-character commitment beginning 390a39f2. A note says the bench uses a fixed constant salt so the numbers are reproducible, while a real commitment's salt is derived from a data subject's key that this console has never seen.
The same formula, run on one field. Picking the kind changes the bytes on the enc(value) line — money:1249900 here — and therefore the commitment; changing one cent changes it entirely. The bench uses a fixed salt so its output is reproducible, which is precisely what a real commitment is not: a real salt is derived from the data subject's key, so the same value in two records commits to two different digests, and destroying that key is what makes both uncomputable.
KindValue must beEncoded as
moneya whole number of minor unitsmoney:1249900
currencyISO 4217 alpha-3cur:EUR
timestampRFC 3339time:2026-08-15T09:12:33Z (normalised to UTC)
dateISO 8601 full-datedate:2026-09-14 — a calendar date, not an invented midnight instant; the date must exist (leap years checked)
uuidRFC 9562, any common spellinguuid:1b4e28ba-… — case, {braces}, urn:uuid: and bare-hex all normalise to one lowercase-hyphenated form, so one identifier is one commitment
lat6lat9, lon6a number in rangelat6:60.169857 (rounded, precision inside the hash)
texta stringtxt:… (NFC-normalised)
integer, decimal, booleana number / boolint:, dec:, bool:
digestsha256:<64 hex>the document itself stays in your vault
freeanythingencoded by JSON kind

Money is minor units, never a float. A fraction where money is declared is rejected at ingest, with the offending field named — because coercing it would make “change one cent and the proof breaks” untrue of exactly the field that sentence is about.

Why this list is short, and stays short. A kind earns its place only when it normalises real spelling variance (four spellings of one timestamp, one UUID in four costumes) or refuses a dangerous coercion (money as a float) — not when it merely names a type. Everything commits under free already, so a missing kind never blocks you; and every kind added becomes verifier surface that every offline reimplementation must reproduce byte-for-byte, forever. That is why there is no email, iban or vat-id kind: their validation rules live in registries that change, which is exactly what must never be inside a hash. If your records need a kind this page lacks, that is a conversation we want to have — the list grows from real data, not speculation.

Verifying a bundle

A bundle verifies offline. No account with us, no call to us, no network. That is the property; everything else is convenience.

For convenience there is POST /v1/verify and a paste-in page at /verify-record. Both are unauthenticated, because a recipient is an auditor or an arbitrator with no relationship to us and should not need one.

The verifier reports seven checks separately, and any one of them can say it was not checked and why:

  1. recompute each disclosed field’s commitment
  2. Merkle-verify it into the record’s commitment root
  3. verify the header signature, and whether that key was valid at the record’s time
  4. verify the record’s leaf sits in a sealed batch
  5. verify independent witness receipts
  6. check the sequence is dense over any claimed window
  7. emit the verdict with the time bracket

A result is only VERIFIED when every step was checked and passed. Anything unchecked reads as VERIFICATION INCOMPLETE, with the reasons listed. A gap in a claimed window is also INCOMPLETE, not FAILED: the records present are exactly as sound as they were, and what is missing is the claim that they are all of them.

A verification result headed VERIFICATION INCOMPLETE. Two disclosed fields, a due date and a total amount, each hold. A step table reports content commitments recomputed and matched and no gaps in the stated window, while the header signature, key validity, sealed batch and independent witness steps each read 'not checked'. Below, a list explains what was not established and why — no public key in the supplied directory, no chain inclusion proof, no witness receipts.
The rule above, rendered. Two steps were checked and passed; four say not checked, each with the reason it could not be — and the verdict is INCOMPLETE rather than a pass, even though nothing failed. This is a verifier holding no key directory and no witness trust store, which is the honest state of any recipient who has only the bundle: the missing pieces are named so the reader knows exactly which claims are standing on evidence and which are standing on nothing.

Erasure

Every record names a subject. Destroying that subject’s key deletes their payloads and makes every salt for them uncomputable, so their commitments become permanently opaque:

curl -X POST https://app.edssa.io/v1/subjects/$SUBJECT_ID/erase \
  -H "Authorization: Bearer $AUTOPLIANCE_TOKEN"

The chain is untouched. Its records still verify, they still count toward sequence continuity — so nothing reads as a gap — and every other subject’s proofs keep working. This is the thing a blockchain cannot do.

We call it erasure by key destruction, and nothing stronger. Whether a commitment whose salt has been destroyed is still personal data is a legal position we have not yet had signed off, so we describe the mechanism and let it stand on that.

A repeated erasure reports a no-op rather than a second erasure, and an erased subject is never silently re-keyed by the next ingest — records for them are refused with 410 subject_erased.

What the signature means today

Records are signed with Autopliance’s hosted ingest key. That attests that this service received these bytes at this point in the sequence — not that your own system produced them. Every response says so ("attestation": "hosted-ingest").

Signing with a key you hold yourself is a stronger claim and is on the roadmap. It requires the commitments to be built on your side, which requires a client library, so it will arrive with one rather than before.

What cryptography here does not prove

A holding bundle shows that this content existed, from this source, unmodified, no later than the witnessed time, and ordered after the record before it.

It does not show that the source told the truth. Nothing can: a sensor reporting a wrong reading, signed correctly and anchored promptly, produces a perfect proof of a wrong number.

The engineering answer is a second independent author — a counterparty’s own record, a bank feed, a customs declaration, a second sensor — anchored in the same fabric, plus dense sequence numbers so an omission is visible and anchored calibration records beside the readings. Not more mathematics.

We would rather write that down than have you find it.

Not a blockchain

Public blockchainAutopliance
Where the data livesreplicated to every node, foreverstays with you; witnesses receive a 32-byte root
Trust mechanismeconomic consensusappend-only Merkle log + independent witnesses
Erasure (GDPR Art. 17)impossible by designcrypto-shredding; proofs stay intact
Cost per recordgasone hash
Verificationjoin the network, or trust itoffline: bundle + public root
Regulatory surfaceMiCA, crypto-asset questionsstandard records processing
Failure mode51% / fork politicstamper-evident, not tamper-proof

The construction to name is Certificate Transparency — the log that guards the web’s certificates. Same mathematics, no coin.

We cite RFC 6962 rather than its successor RFC 9162 deliberately: the two define the same tree (identical leaf and interior hash prefixes, identical recursion), and 6962 is the version the deployed logs actually run — Chrome’s log policy names it, and there is no browser-trusted v2 log. Both are Experimental, so the newer number would buy no extra standing. Our implementation conforms to either.