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:
- 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.
- Computes a salted commitment for every field:
SHA-256(ds ‖ path ‖ 0x00 ‖ salt ‖ 0x00 ‖ enc(value)). - 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.
- 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.
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_minoron 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 constraint —
money 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:
| Limit | Value |
|---|---|
| Payload size | 256 KB |
| Committed fields per record | 4 096 |
| Nesting depth | ~125 levels (the JSON parser’s recursion limit — measured, not theoretical; depth 120 ingests, depth 127 is refused) |
| Committed path length | 512 bytes |
| Integers | within ±2⁵³ (use minor units or strings beyond that) |
| Shape | a 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 names | no 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 kinddigest) 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:
owhere the step indexes an object,awhere 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:
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.| Kind | Value must be | Encoded as |
|---|---|---|
money | a whole number of minor units | money:1249900 |
currency | ISO 4217 alpha-3 | cur:EUR |
timestamp | RFC 3339 | time:2026-08-15T09:12:33Z (normalised to UTC) |
date | ISO 8601 full-date | date:2026-09-14 — a calendar date, not an invented midnight instant; the date must exist (leap years checked) |
uuid | RFC 9562, any common spelling | uuid:1b4e28ba-… — case, {braces}, urn:uuid: and bare-hex all normalise to one lowercase-hyphenated form, so one identifier is one commitment |
lat6 … lat9, lon6 … | a number in range | lat6:60.169857 (rounded, precision inside the hash) |
text | a string | txt:… (NFC-normalised) |
integer, decimal, boolean | a number / bool | int:, dec:, bool: |
digest | sha256:<64 hex> | the document itself stays in your vault |
free | anything | encoded 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:
- recompute each disclosed field’s commitment
- Merkle-verify it into the record’s commitment root
- verify the header signature, and whether that key was valid at the record’s time
- verify the record’s leaf sits in a sealed batch
- verify independent witness receipts
- check the sequence is dense over any claimed window
- 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.
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 blockchain | Autopliance | |
|---|---|---|
| Where the data lives | replicated to every node, forever | stays with you; witnesses receive a 32-byte root |
| Trust mechanism | economic consensus | append-only Merkle log + independent witnesses |
| Erasure (GDPR Art. 17) | impossible by design | crypto-shredding; proofs stay intact |
| Cost per record | gas | one hash |
| Verification | join the network, or trust it | offline: bundle + public root |
| Regulatory surface | MiCA, crypto-asset questions | standard records processing |
| Failure mode | 51% / fork politics | tamper-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.