Verify Without Us
Goal: Check a Provenance export and the signatures in it yourself, on your own machine, without trusting OpenX to tell you it is intact.
One command reads files you already hold: a signatures export, the workspace's public seal keys and, if you have them, the exports of the records the signatures attest. It makes no network call and needs no Fathym account. It prints a verdict for each file's chain and each signature, then one verdict for the whole export.
The command is published in @o-industrial/common as /verify. It uses the same functions OpenX uses to hash
records and check seals, so it is not a second, different reading of the record.
Get the files
Ask someone with access to the workspace for these files, or read them yourself through the API. Keep each file exactly as it was downloaded: the check reads the bytes.
| File | Where it comes from |
|---|---|
The signatures (sigs.json) | GET /ox-api/provenance/events/export?target=DomainSignatures&format=json&scopeKey=<scope> |
The seal keys (seal-keys.json) | GET /ox-api/provenance/signatures/seal-keys, saved as it answers: { "Keys": [...] } |
| The records signed (optional, one per container) | GET /ox-api/provenance/events/export?target=<Container>&format=json&scopeKey=<scope>, for example target=DomainEvents |
scopeKeyis the scope the records belong to (a study, in Sample Management). A signature is kept in the same scope as the record it signs, so export both with the samescopeKey.- An export holds at most 10,000 rows, with no paging. If the answer's
x-provenance-has-moreheader istrue, the file is only part of the record: narrow the date range (startDate,endDate) and export again. - A JSON export does not name its own container inside the file, so you name it when you run the command.
- Reading the seal keys needs the
Workspace.Provenance.Readright, the same right an auditor reads the record with.
Run it
You need Deno. Then:
deno run --allow-read jsr:@o-industrial/common@<version>/verify \
--signatures sigs.json \
--keys seal-keys.json \
--records DomainEvents=events.json
--signatures <file>: the signatures export. Required.--keys <file>: the seal keys. Required.--records <Container>=<file>: an export of a container the signatures attest, named by its container. Repeat it once per container. Optional. The container name must be the one the signatures name as theirTargetType, or their records are not found.<version>is a release of@o-industrial/commonthat carries/verify(it first shipped in0.1.2632-round-10). Name an exact version, and write it down with your results, so the same check can be run again later.
--allow-read is the only permission it is given. The command reads the files you name and nothing else: it
makes no network call, reads no environment and writes no file, and if anything in it tried, Deno would stop
and ask you rather than allow it. Deno downloads the command from JSR the first time you run it; after that it
runs from Deno's cache, offline.
What it checks
Each file's chain. Every record carries a Hash over its own content and the PreviousHash of the record
before it. For each file the command:
- works out each record's hash again and compares it with the
Hashthe record carries. A record that does not match is changed. - where a record's predecessor is in the same file, checks that its
PreviousHashis that record'sHash. One that is not is a broken link. - where a record's predecessor is not in the file (an export filtered by date, or cut at 10,000 rows), counts a gap. A gap is not a failure: the file simply does not hold that record.
Each signature.
- Its own record: the signature is a record in the signatures chain; did its hash work out the same?
- Its link: when the record it signs is in one of the
--recordsfiles, the command works out that record's digest again and compares it with the signature'sBindingDigest, the digest of what the person was shown when they signed. - Its seal: the signature's seal is checked against the key version it names (
LinkSealKeyID), from the seal keys file. The seal is ES256 over the signature'sBindingDigest,CeremonyID,Meaning,SignedAt,SignedBy,TargetRecordIDandTargetType.
What it prints
One line per file's chain:
Chain DomainSignatures: 3 records, 0 changed, 0 broken links, 0 gaps
Chain DomainEvents: 42 records, 1 changed (000000000017), 0 broken links, 2 gaps
One line per signature, with its record, link and seal:
Signature 000000000003 Approval on DomainEvents 000000000017 by ana@acme.example at 2026-10-31T14:02:11.000Z: Changed (record Intact, link Changed, seal Valid)
Then the verdict for the whole export, for example Verdict: NotValid.
A signature's verdict
| Verdict | What it means |
|---|---|
Valid | Every check ran and passed: its own record is intact, the record it signs still matches, and its seal verifies |
Changed | The signature's own record, or the record it signs, is not what was signed |
Invalid | The seal does not verify against the key version the signature names |
NotChecked | Something could not be checked: the record it signs is not in the files given, the seal key is not in the keys file, or the signature was written before sealing (seal Unsealed). This is never a pass |
The seal reads Valid, Invalid, Unsealed (written before sealing) or NotChecked (no key for it). The link
reads Intact, Changed or NotInExport.
The export's verdict
| Verdict | What it means |
|---|---|
Valid | Nothing failed and every signature was fully checked |
NotValid | Something failed: a changed record or a broken link in any file, or a signature that reads Changed or Invalid |
Incomplete | Nothing failed, but at least one signature reads NotChecked |
NoSignatures | The signatures file holds no signatures, so no signature was verified. It is never reported as Valid |
Exit codes
| Code | When |
|---|---|
0 | The export reads Valid |
1 | Any other verdict: NotValid, Incomplete or NoSignatures |
2 | An argument or a file is wrong: a flag it does not know, a missing --signatures or --keys, a file it cannot read, a file that is not JSON, or a file in the wrong shape. It prints what was wrong and how to call it |
So a script can stop on anything but 0.
What it cannot prove
Read these before you rely on a result. A Valid export is evidence about these files, nothing wider.
- It checks the files, not the whole record. Records missing between two records in a file show as gaps.
Records missing from the start or the end of a file leave no trace in it. To know you hold everything, export
the whole date range and check that
x-provenance-has-morewasfalse. - A chain only agrees with itself. Someone who rewrote a file from start to finish, with fresh hashes,
would make a chain that agrees with itself. What ties a signed record to what the person saw is the seal on
its signature, which covers the
BindingDigest. Records that no signature attests have no seal to hold them. The storage the export was read from (your workspace's Provenance, in your own Azure subscription, at the level you set) is outside what this command can see. - It trusts the keys you give it. A seal that verifies proves it was made by the private half of the key in your keys file. Get that file from the workspace itself, and keep it with the export.
- It does not judge the signer. It does not check who the person was, how they signed in, whether they held the right to sign, or whether the meaning was the right one. OpenX checks those when a signature is made, and the validation pack says who is responsible for each. In particular, OpenX sees that a login happened, not that a password was typed again.
- It is not a finding of compliance. The command checks data. Whether your system meets 21 CFR Part 11, EU Annex 11 or any other rule is for your quality unit and your auditor to decide.
Next steps
- What OpenX, your app, your login system and your QA each do -> Validation Pack
- Export, verify and read signatures in the workspace -> Activity Log
- How a signature is made -> Sign records from your app