Methodology

Karatum Flight Record v1: the agent black box

Testnet pilotSource: docs/methodology/flight-recorder.md

Status: implemented, testnet only (Base Sepolia anchors). Code: packages/flightrec (TypeScript), packages/flightrec-python, API services/api/src/routes/flight*.ts, jobs services/flightrec, storage packages/store/migrations/023_flightrec.sql.

1. What problem this solves

A Karatum trial measures an agent in a sandbox we control. Production is where agents do real things, and nobody can audit them: logs are mutable, vendors can rewrite them, and an agent's own claim ("I swapped 25 USDC") proves nothing.

The Flight Recorder lets a production agent keep a log that is:

  • tamper-evident: editing, deleting or reordering a step is detectable by anyone;
  • non-repudiable: every batch of steps is signed by the agent's key; the agent cannot later deny it, and cannot show two different histories (equivocation is detectable);
  • timestamped publicly: batch roots are anchored on-chain (one transaction per epoch);
  • privacy-preserving: only Merkle roots leave the agent. Prompts, arguments, results and PII stay on the operator's machine and can be opened one field of one step at a time;
  • checkable against the world: when an opened step claims an on-chain outcome, Karatum compares the claim with the chain. That is a field verdict, reported separately from trial verdicts.

What it does not do (see section 10): prove the recorder was running, prove the agent recorded everything, or prove off-chain content is true.

2. Objects

All hashes are keccak256 and all hex is lowercase 0x-prefixed. JSON is canonicalised with the same canonicalJson as signed execution events (@karatum/sdk, karatum.signing.canonical_json): keys sorted by UTF-16 code unit at every level, no whitespace, JSON string escaping, ECMAScript number formatting, non-finite numbers rejected. Integers above 2^53 must be passed as strings (TypeScript cannot represent them exactly).

2.1 Commitment

commit(value, salt) = keccak256( "karatum.fr.v1.commit" || salt(32 bytes) || utf8(canonicalJson(value)) )

salt is 32 random bytes per field. With a fresh salt a commitment is hiding even for low-entropy values ("yes", a small amount) and binding. Opening a commitment = publishing { salt, value }.

2.2 Step record

One thing the agent did. Strict object, no extra keys:

fieldtypemeaning
v1version
agentaddresssigning key (EOA), lowercase
stream[A-Za-z0-9._:-]{1,64}independent log of the same key (default main)
sequint0-based, contiguous within (agent, stream)
tsuintunix milliseconds on the agent's clock
prevbytes32stepHash of seq-1; zero hash for seq = 0
kindtool_call llm_call tx message event
namestring 1..128tool or model name. Public: do not put identifiers in it
prompt, args, result, claimbytes32 or nullcommitments (null = absent)
txHash, chainIdbytes32/int or nullpublic: a transaction is public on-chain anyway
stepHash = keccak256( "karatum.fr.v1.step\n" || utf8(canonicalJson(stepRecord)) )

Because each record contains prev, stepHash commits to the whole history before it (a hash chain).

2.3 Claim

The only content Karatum interprets. Opened by the agent, committed in claim:

{ "kind": "tx.success" }
{ "kind": "native.transfer", "to": "0x…", "value": "<wei>" }
{ "kind": "erc20.transfer",  "token": "0x…", "to": "0x…", "amount": "<base units>" }

Addresses lowercase. The recorder lowercases and validates claims at record time.

2.4 Merkle tree

Binary tree over 32-byte items, RFC 6962 style domain separation, odd node promoted unchanged (no duplication):

leaf = keccak256( 0x00 || item )        node = keccak256( 0x01 || left || right )

A proof is { index, size, siblings[] }; the path shape is derived from index and size, so a proof cannot be reinterpreted for another position or tree size. Verification: fold siblings bottom-up (a node that is the last, unpaired one at its level consumes no sibling), the result must equal the root and every sibling must be consumed.

2.5 Batch

A run of consecutive steps of one stream. Header:

FlightBatch { agent, stream, index, fromSeq, toSeq, prevHead, head, root, createdAt }
root     = merkleRoot( stepHash(step_fromSeq) … stepHash(step_toSeq) )
head     = stepHash(step_toSeq)
prevHead = step_fromSeq.prev           (zero hash for the first batch)
index    = 0,1,2…                      batch counter per (agent, stream)
createdAt = unix seconds

Signed by the agent key in one of two schemes over the same header:

  • EIP-712: domain { name: "Karatum Flight Record", version: "1" }, type FlightBatch(address agent,string stream,uint64 index,uint64 fromSeq,uint64 toSeq,bytes32 prevHead,bytes32 head,bytes32 root,uint64 createdAt). Works with any wallet (viem, ethers, Coinbase CDP/AgentKit wallet providers).
  • EIP-191 (personal_sign) over the text Karatum Flight Batch v1\nagent: …\nstream: …\nindex: …\nfromSeq: …\ntoSeq: …\nprevHead: …\nhead: …\nroot: …\ncreatedAt: ….

batchId = EIP-712 digest of the header regardless of scheme. Signing happens once per batch, not per step, so recording costs hashing only. Smart-contract wallets (ERC-1271) are not supported in v1: the verifier uses ecrecover.

2.6 Disclosure of one step

{ step, reveal: { prompt?, args?, result?, claim? }, proof, batch }

reveal.k = { salt, value } opens exactly the fields the agent chooses. A verifier checks, offline and without trusting anyone: (1) the batch signature recovers to header.agent; (2) step.agent/stream match, step.seq is inside [fromSeq, toSeq], proof.index == seq - fromSeq, proof.size == toSeq - fromSeq + 1; (3) the Merkle proof from stepHash(step) reaches header.root; (4) the first step's prev equals header.prevHead, the last step's hash equals header.head; (5) every opened commitment matches. Nothing else about the batch is revealed: the other steps appear only as salted, unguessable hashes on the proof path.

2.7 Epoch and anchoring

Karatum collects the batchIds of all not-yet-anchored batches (at most 512), builds the Merkle tree over them in storage order (the epoch) and writes the root in one EAS attestation:

  • schema bytes32 epochRoot,uint32 batchCount,string spec (spec = "karatum.flightrec.v1"), non-revocable, no resolver, registered once on the network's SchemaRegistry;
  • network: Base Sepolia (eip155:84532), EAS at the OP-stack predeploy 0x4200…0021; Ethereum Sepolia supported;
  • signer: the Karatum testnet attester key (checked by packages/kos/src/netguard.ts: a production key can never sign here, and no mainnet network is configured).

A public verifier needs: batchId, the epoch proof { index, size, siblings }, epochRoot, and the transaction hash. It checks the proof, reads the transaction from any RPC, confirms it was sent to the EAS contract, calls attest with the Karatum schema and epochRoot in data, and succeeded. That gives "this batch existed no later than block N", and with the batch signature, "this agent key signed it".

3. Continuity, gaps and equivocation

The ingest service keys everything by (agent, stream) and serialises writers with a Postgres advisory lock.

situationoutcome
same batchId againidempotent: duplicate: true
batch index n+1 whose fromSeq = prev.toSeq + 1 and prevHead = prev.headstored, status: ok
index jumps ahead (a batch is missing)stored, status: gap; flips to ok when predecessors arrive
same index, different contentequivocation same_index
overlapping seq rangesequivocation overlap
chain does not link to a neighbour (prevHead or fromSeq mismatch)equivocation chain_break

On equivocation the second statement is not stored as a batch; both signed statements are kept in flight_equivocations.evidence and served publicly. They are self-contained proof: anyone can re-verify both signatures. The recorder receives HTTP 409 EQUIVOCATION. A key with equivocation evidence is flagged on its page and in /v1/flight/agents/:address; an honest recorder never produces one (it resumes its chain from its local journal after a crash).

Gaps are legitimate when delivery failed, so they are a visible signal, not a verdict.

4. Field verdicts: claimed vs on-chain

For a disclosed step with an opened claim, a txHash and a chainId, the API reads the transaction and receipt from the configured RPC and evaluates:

  • always: receipt success; transaction sent by the agent key;
  • native.transfer: tx.to == claim.to and tx.value == claim.value;
  • erc20.transfer: a Transfer(agent, to, amount) event emitted by claim.token (read from logs, not from calldata).

Result: confirmed (all checks pass), contradicted (positive on-chain evidence against the claim: reverted, other sender, recipient or amount), unverifiable (no claim opened, no transaction, transaction not found yet, RPC failure, unsupported claim). Unverifiable never counts against an agent; a re-check job re-evaluates unverifiable steps whose transaction was not found yet.

Counting is per distinct transaction, not per opened step: however many records open the same transaction it counts once, and if any record of it is contradicted the transaction is contradicted. Otherwise an agent could inflate its field record by recording and opening one confirmed transfer a hundred times.

The agent page shows a field record section: counts, a field rating (Wilson lower bound of confirmed / (confirmed + contradicted) times 24, the same formula as the K-rating, labelled "field only"), and coverage = opened steps / recorded steps. Selective disclosure invites cherry-picking, so a field rating always carries its coverage. Field verdicts are never added to the trial K-rating; a consumer that wants to blend them must do so explicitly and say so.

Default privacy on POST /v1/flight/proofs: the server verifies every opened field but stores only the opened claim (it is what the verdict is about). args, result and prompt are stored only with ?publishReveals=1.

5. Replay

services/flightrec/src/replay.ts re-simulates a disclosed on-chain step. It forks the chain with the Mirror node (@karatum/chain Mirror.resetToBlock) at txBlock - 1, impersonates the sender, executes the identical calldata, value and gas, then compares status and logs with the original receipt and evaluates the claim against the replayed receipt. The result (reproduced, verdict, forkBlock) is stored on the step and shown as "replayed".

Limits, reported rather than hidden: transactions earlier in the same block are not re-applied; the fork needs state for txBlock - 1 (public non-archive RPCs keep only recent blocks; configure an archive RPC for older steps); a replay shows the same call yields the same outcome from the same state, not why the agent made it.

6. API

No API key: the signature is the credential. Anonymous callers are IP rate-limited; a key may add at most 1440 batches per 24 h.

endpointpurpose
POST /v1/flight/rootsbody = signed batch { header, scheme, signature }. 201 stored, 200 duplicate, 409 EQUIVOCATION, 422 BAD_SIGNATURE/FUTURE_BATCH, 429 BATCH_QUOTA
POST /v1/flight/proofs[?publishReveals=1]body = disclosure. 404 UNKNOWN_BATCH unless the root was posted first; 422 BAD_DISCLOSURE
GET /v1/flight/proofs/:batchId/:seqstored disclosure + epoch proof + anchor tx + field verdict + replay
GET /v1/flight/agents/:addresssummary, field record with coverage and rating, batches, opened steps, equivocations, bound builds
GET /v1/flight/agents, /anchors, /equivocations[?agent=], /statslistings

7. Binding a flight key to a Karatum agent

A flight key is shown on an agent's page only when its address equals the build's registered event-signing wallet (builds.wallet). Unbound keys are recorded and fully verifiable but labelled "not bound to a registered build". The binding is the same wallet registration used for signed execution events, so no new trust root is introduced.

8. SDKs and integrations

piecewhere
TypeScript recorder, verifier, API clientpackages/flightrec
Python recorder, verifier, API clientpackages/flightrec-python (byte-identical: packages/flightrec/test-vectors/v1.json is reproduced by both test suites, signatures included)
MCP proxy (HTTP and stdio), records any MCP agent transparentlypackages/flightrec/src/mcp-proxy.ts, mcp-cli.ts
LangChain.js callback handlerintegrations/langchain-js/src/flight.ts
LangChain Python callback handlerintegrations/langchain-python/src/langchain_karatum/flight.py
Vercel AI SDK middleware + tool wrapperintegrations/vercel-ai/src/flight.ts
Coinbase AgentKit action wrapperintegrations/agentkit/src/flight.ts

Integrations depend on a structural FlightRecorderLike type, not on the recorder package, so they add no dependency. A recorder failure never reaches the agent (record() and every integration swallow and report through onError).

The recorder keeps a local JSONL journal (FileJournal) holding the plaintext values and salts needed to open commitments. It never leaves the machine unless the operator posts a disclosure; protect it like any log of the agent's inputs. retainPlaintext: false keeps commitments only (nothing can be opened later).

10. Threat model and honest limits

threatwhat the design giveswhat it does not
operator edits or deletes past stepsdetectable: hash chain + signed Merkle roots + public anchorscannot stop an operator who never published the batch
operator shows two historiesequivocation evidence (two valid signatures)only if both versions reach the server
operator records only flattering stepscoverage is shown; gaps in seq or index are visiblecannot prove completeness: an agent can run unrecorded actions. Field verdicts are about recorded, opened steps
operator lies in a claimcontradicted by the chaina claim about off-chain facts is not checkable
key theftanything signed by the key looks authenticno revocation in v1; rotate by starting a new key and re-registering the wallet
server tamperingusers can verify disclosures and anchors locally without Karatum (verifyDisclosure, verifyAnchor, browser verifier on /flight)availability of Karatum's copy
clock manipulationanchor block time is the public upper bound on when a batch existedts inside a step is the agent's own clock
PII leakagesalted commitments; the server stores roots, and only the claim of an opened stepthe tool name and txHash/chainId are public
spamper-IP limits, 1440 batches/day/key, 64 KB bodiesa Sybil can create keys; unbound keys are never shown on agent pages

Disclosure is irreversible: once a commitment is opened and posted it is public if publishReveals=1 is used, and anyone who ever received the opening can republish it. Open only what you are ready to publish.

11. Conformance

packages/flightrec/test-vectors/v1.json pins commitments, step hashes, leaf hashes, roots, proofs, EIP-712 and EIP-191 signatures, batch id, an epoch tree and the EAS anchor calldata for fixed inputs (test key only, never holds funds). Any implementation must reproduce every value. Tests: packages/flightrec/src/*.test.ts (merkle for every tree size up to 40, tamper cases, resume after crash, MCP proxy, anchor verification), packages/flightrec-python/tests, services/api/src/routes/flight*.test.ts (including a Postgres contract test with racing forks), services/flightrec/src/*.test.ts.