Foxy Audit
Book a demo
← The manual

The evidence model

How a record becomes proof: the commitment, the chain and the verifier, and what each one does and does not prove.

Skip to the chapter
All chapters

What you'll learn

  • how the SDK turns a prompt into a commitment, and why it uses a key;
  • what salts add, and what they cost;
  • how the chain makes a changed record show, and why you keep its head;
  • what the verifier checks, and what its answers mean;
  • what this evidence proves, and what it does not.

The path of one record

your function returns
        |
        v
the prompt and the answer, written out in a fixed way
        |
        v
a keyed hash of each: the commitments          <- the text is dropped here
        |
        v
a record of hashes and labels -> the spool    (saved on your disk)
        |
        v
the background thread -> the backend           (the ledger links it into your chain)

The prompt and answer text exist only above the commitment step, inside your own program. Nothing below it holds them.

Commitments

A commitment is a hash of your prompt, or of the answer, made with a key. The SDK uses HMAC with SHA-256, keyed with your commitment key. By default the SDK makes that key for you, and it stays on your machine.

Why a key. A plain hash of a short prompt can be guessed: someone could hash "yes", "no" and every common question until one matches. With a secret key, nobody without the key can make those guesses.

Whose key. Yours. By default the SDK makes your commitment key and keeps it in a file next to the spool, commitment.key. Foxy Audit never sees it, so only whoever holds that file can check which text a commitment came from. You can give a key of your own with FOXY_COMMITMENT_KEY instead.

Back the key file up. If you lose it, you can no longer prove which text a commitment came from. The chain still checks out, because it never needs the key. In a container, keep the key in a secret store, as Configuration shows.

Older records. Before version 1.18.0, a program with no key set made its hashes with your API key. Those records still check out with that key. Nothing changes them.

Written out in a fixed way. Before hashing, the SDK writes the prompt or the answer as JSON in one fixed form, with its keys sorted. The same value always gives the same bytes, so the same commitment. An object from a model library is turned into plain data first, when it offers a way to do that (model_dump, dict or to_dict), so it commits the same way as the data inside it. Any other object is written out as its text form, str(value).

The record says how its hashes were made, in commitment_alg:

Value When
hmac-sha256 the hash was made with a key, which is the normal case
hmac-sha256-salted the hash was made with a key and a salt, because a salt file was set
sha256-legacy there was no key at all, so the hash has no key

Each name keeps its meaning forever, so an old record can always be checked the way it was made.

Salts

A salt is a random value mixed into each commitment. It stops a commitment being guessed even by someone who somehow has your key. To turn salts on, set FOXY_SALT_SIDECAR to a file path. The SDK then makes a new 128-bit salt for each record and writes it to that file, with the record's id.

  • The salt never leaves your machine. It is not in the record, not sent, and not logged. The chain does not need it.
  • Only you can read the salt file. The SDK makes it that way before it has its name, as it does the key file, and never writes over one that is there.
  • If writing the salt fails, the record is made without a salt, rather than with a salt nobody can find later. A warning names the file, once.
  • If you lose the salt file, you can no longer prove which text those records came from. foxy explain answers salt_unavailable for them, and the verifier reports them as not checked. The chain itself still verifies, because it never needs the salt.

So keep the salt file as carefully as you keep your ledger exports.

The chain

The backend keeps your records in order. Each record gets a number, and a hash that covers its own fields and the hash of the record before it. That is the chain.

Change one old record, even by one letter, and its hash changes. Then the next record's link no longer matches, and neither does any record after it. So a change made to one record alone shows the next time anyone checks the chain.

The links use plain SHA-256, with no secret. So someone who can rewrite the stored records could also work out every link after the change again, and the rewritten chain would check out on its own. What catches that is a head you saved before the change: the rewritten chain no longer has it. Records removed from the very end are the same case, because what is left still links up. Keep the head shows how.

The chain does not stop a change from being made. It makes a change show, against a head you kept.

Each workspace has its own chain. The backend, not the SDK, works out each record's place and its hash when the record arrives.

The verifier

The verifier, foxy_verify.py, is a separate Python program. It works out every hash of the chain again from an export, and it tells you whether they all match. It uses only Python's own libraries and needs no network, so it can run anywhere, including on an auditor's machine.

You get it in the verification bundle from the dashboard's Export page. Run it on the export:

Terminal
python foxy_verify.py foxy-audit-logs.json

What you should see, for an intact ledger:

[OK]   chain intact - <rows> rows verified from genesis
       head @ seq <rows> = <hash>
[--]   no anchor receipt in this export

The second line is the head: the number of the last record, and its hash. The third line is about anchors, explained below.

What its exit codes mean:

Code Meaning
0 the whole ledger is intact
1 something does not match: the chain is broken at the number it prints, or an anchor receipt does not match the chain, or a commitment you asked it to check does not match
2 nothing was checked, for example an empty or unreadable file
3 every record you gave matched, but they are only part of the ledger
4 you asked for --anchor, and the check on the public blockchain could not be done, so it vouched for nothing

A large ledger is exported in several files. Give them all to the verifier in one command, in any order. It checks that each file joins the next, so a missing file cannot pass unnoticed.

Keep the head

Keeping the head is what turns "the links match" into "nothing has changed since". Make it part of every export:

  1. After you export and verify, copy the head line, head @ seq <rows> = <hash>.
  2. Keep it somewhere Foxy Audit cannot reach, such as your own document store, a ticket, or a message to your auditor. Foxy Audit keeps no copy of your heads outside its own servers, so this step is yours.
  3. Next time, export and verify again. Then check that the record with that number still has that hash. Each row of the export carries its seq and its chain_hash, and this short program compares them:
Python
import json
import sys

saved_seq, saved_hash, export = int(sys.argv[1]), sys.argv[2], sys.argv[3]
with open(export, encoding="utf-8") as handle:
    rows = json.load(handle)["logs"]
row = next((r for r in rows if r["seq"] == saved_seq), None)
if row is None:
    print("That record is not in this export. Export a range that holds it.")
elif row["chain_hash"] == saved_hash:
    print("Same hash: nothing up to that record has changed.")
else:
    print("DIFFERENT: the ledger was rewritten after you saved the head.")

Save it as compare_head.py, and run it with the number and the hash you saved:

Terminal
python compare_head.py <seq> <hash> foxy-audit-logs.json

When the verifier says the new export is intact, and the saved record still has its hash, nothing up to that record has changed since you saved it.

An anchor does the same job in public. If your workspace anchors its chain, the export holds an anchor receipt: the head at one moment, written to a public blockchain, where nobody can change it later. The verifier checks the receipt against the chain by itself, and prints [OK] anchor receipt matches the chain @ seq <number>. To check the receipt against the blockchain too, add --anchor --rpc <an RPC address for that blockchain>. That check needs the web3 package and a network connection.

Proving one record

The chain, with a head you kept, shows that nothing changed. To prove which text one record came from, use foxy explain. It rebuilds the commitment from your prompt and your key, and compares it with the record. See Proving a row with foxy explain.

The AI judge

After a record arrives, the backend can grade its metadata with an AI judge, if your workspace has one set up. The judge sees labels, names and counts, never your text and never a hash. Its grade is advice for a human to review, not a verdict. When no judge is set up, a set of fixed rules grades the record instead.

What this evidence proves, and what it does not

It proves:

  • that the records are in this order, and have not changed since a head you saved, or an anchor, was taken;
  • what the SDK recorded about its checks: for a call in block or redact mode, which rules fired, under which ruleset, and what was decided. A call in observe mode records no prompt check. All of it is what the SDK on your machine reported;
  • with your key and your prompt, that a record came from that exact text.

It does not prove:

  • that the ledger was never rewritten, if you kept no head and there is no anchor. The links use no secret, so a rewrite of the whole ledger can rebuild them;
  • that your AI system is safe, or that your company is compliant. It is evidence for an auditor to weigh, not a certificate;
  • that the checks caught everything. They are measured, not guaranteed;
  • anything about calls your program made without the SDK. A call that was never decorated leaves no record.