Guarantees and limits
What the audit log proves, and, stated plainly, what it does not.
The audit log is append-only JSONL. Every line is hash-chained to the previous one and signed with your Ed25519 key.
prevHash ← the previous entry's hash, verbatim
hash ← sha256 over a fixed-order canonical form of every signed field
sig ← ed25519 over "agentveins.audit.v1\n" + hashverifyAuditLog() is exported so anyone, you, an auditor, a reviewer, can check a log independently.
What it detects
| Attack | Detected | How |
|---|---|---|
| Edit any signed field | Yes | the hash no longer matches |
| Delete an entry from the middle | Yes | the next entry's prevHash dangles |
| Reorder entries | Yes | sequence gap |
| Forge a new entry | Yes | no valid signature without the operator key |
| Substitute another log entirely | Yes | logId is signed into every entry |
| Truncate the tail | Only with the anchor | see below |
The anchor
Truncation is the hard one: a strict prefix of a valid chain is itself a valid chain, so a log cannot detect its own tail being cut. And because spend replays from the log, deleting trailing lines would silently restore budget.
So the guard keeps a tiny out-of-band anchor, a signed record of the log's expected head, written after every append and checked at startup. A truncated log no longer reaches the anchored position and the guard refuses to start.
The guard fails closed on every ambiguous case. An absent anchor beside a non-empty log, a bad anchor signature, a logId mismatch, or a log ending short of the anchor all throw at construction rather than being treated as a fresh start.
What it does not detect
Stated plainly, because tamper-evident should not imply more than it delivers.
Both of these are inherent, not oversights.
- Delete the anchor and truncation becomes undetectable again. An unauthenticated file on the same disk can always be removed. The guard refuses to start when the anchor is missing but the log is not, but if both go, it looks like a first run.
- Rolling both files back to an older matching snapshot is not detected. Signing stops an attacker fabricating an anchor; it does not stop them replaying a genuine older one alongside a matching log. Detecting that needs monotonic state outside both files, and same-disk state rolls back with them. Closing it properly means append-only or remote storage.
When the log cannot be written
A governance tool that cannot record cannot authorise. If an audit write fails, disk full, read-only mount, a remote sink down, the guard latches closed: every later pay() returns blocked with audit_unavailable before touching an adapter.
Two honest caveats:
- Latched refusals leave no trace. They produce no audit entry and their
auditIdis the empty string, so an operator reconciling the log cannot tell whether one payment or ten thousand were refused while the sink was down. - The latch is process-scoped. A restart clears it, so with a persistently dead sink each fresh process authorises exactly one payment before latching again.
If a payment settles on chain and only the recording fails, the guard still returns settled with the real signature, it will not tell you money stayed put when it did not, and then latches.
Who gets paid
In x402 mode the vendor declares both the price and the destination after the guard has already approved an amount.
- Price: a quote asking for more than approved is refused. A cheaper quote pays the cheaper price.
- Recipient: with
policy.recipientsset, a quote naming a destination you did not approve is refused.
Both refusals happen before anything is signed.
Leave policy.recipients out and that second check does not run: the amount stays governed, the payee does not, and a compromised or DNS-hijacked endpoint can redirect the payment while the allowlist, the budget and the audit log all record a normal governed payment.
What this still does not prove is that an approved address belongs to the vendor. It proves only that you named it in advance. Binding an address to a vendor identity needs a signed vendor record.
Payments that cannot be confirmed
Direct mode waits for confirmation rather than trusting that a node accepted the transaction. sendTransaction returns when a node accepts it, which is not the same as it landing: blockhash expiry and congestion drops are ordinary on Solana.
If confirmation times out while the transaction is still plausibly in flight, the guard records a fourth audit outcome, uncertain, carrying the signature, and consumes the budget, while returning failed with error.code: "timeout" and error.txSig.
Rounding against the agent is deliberate: the count can never come in low.
Sharp edge
An agent that retries on a bare status === "failed" without reading error.code will spend budget twice for one uncertain payment. Check the code.