Verifiability
A signed audit chain that lets you prove the IPS in effect on June 15 — and every guardrail decision since — was what we say it was, without trusting our database. Design draft.
Why this exists
The agent platform’s value is the policy layer. Today, that policy lives in our Postgres database — meaning anyone with DB write access could in principle alter what your IPS said yesterday, or change a past guardrail decision’s stored reason. We don’t do that, and we have access controls + audit log on the database itself, but you can’t verify any of that. You have to take our word for it.
Two classes of customer eventually need more than trust:
- Compliance teams who have to evidence to a regulator that the policy in effect on a specific date was the one they approved.
- Crypto-native customers whose mental model expects to verify state cryptographically rather than via vendor attestation.
The design below addresses both without the complexity of putting policy content on a public chain.
The model: a signed hash chain
Every event that defines or applies the policy lands in an append-only policy_events log:
ips.created/ips.amended— a new IPS version goes livestanding_rule.set/standing_rule.revokedauthority.set— an authority-map overridedrawdown_rule.setguardrail.decision— every block / allow the policy layer returnskey.minted/key.revoked/key.rotated— with a hash of the per-key policy, never the secretpolicy.drift_detected/policy.drift_cleared— every time your portfolio crosses into or out of an IPS band breachbroker.trade_observed— every fill we sync from your custodian, withsource: ‘ac_initiated’or‘user_initiated’so the chain captures trades you placed on Gemini directly without going through AC. Schwab observation lands when the transaction-sync processor moves past stub.
Each row is shaped like this:
{
id: string, // uuid
userId: string, // who the policy applies to
eventType: string, // one of the types above
payload: unknown, // event body (no secrets)
occurredAt: string, // ISO 8601 timestamp
prevHash: string, // 0x0 for the first event
hash: string, // sha256(prevHash + canonicalJson(payload) + occurredAt)
signature: string, // ed25519(hash) by AC's signing key
signerKeyId: string, // which AC pubkey to verify against
}The hash links each event to its predecessor — change one row and every subsequent hash stops matching. The signature makes those hashes non-repudiable: an AC private key signed this exact byte sequence at this time.
AC’s signing keys
- Ed25519 — small signatures, fast verification, no curve choices to defend.
- Rotated quarterly. Each key has a public key id (
signerKeyId), a valid-from / valid-to window, and is published in a public, signed/api/agent/v1/signing-keysmanifest that we also include in the chain. - Private keys held in HSM. The signer service is the only system component with access. Compromise of the API process does not expose the key.
Reading the chain
Two new endpoints:
GET /api/agent/v1/policy/events— paginated cursor-based feed of yourpolicy_eventsrows, with the full hash + signature on each. Scope:read.GET /api/agent/v1/policy/snapshot— a point-in-time materialized view: “here is your policy as of timestamp T”, with the event id of the last event applied so a verifier can re-walk from genesis. Scope:read.
The verification client
We’ll ship a verifier as a CLI (npx @advisors-crypto/verify) and a library function in both SDKs. It does three things:
- Walks the events you fetched and re-computes every hash from
prevHash + payload + occurredAt. - Verifies each signature against the signer key valid at
occurredAt, using the published signing-key manifest. - Returns either a
{ ok: true, head: hash }envelope or the first row where verification broke, with the reason.
import { verifyPolicyChain } from '@advisors-crypto/verify';
const events = await ac.getPolicyEvents();
const result = await verifyPolicyChain(events);
if (!result.ok) {
throw new Error(`Chain broken at ${result.eventId}: ${result.reason}`);
}
console.log('Verified head:', result.head);Why this is not on-chain (yet)
Two concrete reasons we are deferring on-chain checkpoints:
- Privacy. Even a hash leaks timing — “this customer had an IPS change at 16:42” — and on a public chain that timing is observable forever by any analyst. The off-chain signed chain limits visibility to people who can read your
/policy/eventsfeed. - No additional guarantee for the common case. The threat model that on-chain solves is “AC and a customer’s legal team both lie to a regulator simultaneously.” That’s a real but narrow scenario; for everything else, a signed off-chain chain already prevents AC from quietly rewriting history.
When we ship the v0.5+ on-chain extension, the model is a Merkle root of the day’s policy_events hashes published to a Base contract once daily. That gives you third-party verifiability — “Base block 12M says AC’s root on 2026-06-15 was 0xabc...” — for a few cents per day. We will not put policy content on-chain.
Rollout
- Phase 1 — Plumbing.
policy_eventstable, signer service, hash + signature on every event we already emit (IPS changes, guardrail decisions, key mutations). No new UI yet. - Phase 2 — Read API + verifier. The two endpoints above plus the verification client. CLI ships via npm. Customers can manually run
npx @advisors-crypto/verifyand confirm. - Phase 3 — In-app verification. “Verify this snapshot” button on the
/agent#policyview that runs the verifier in-browser and shows green. - Phase 4 (optional) — On-chain checkpoints. Daily Merkle root publish to a Base contract. The verifier gains an
--with-chainflag that cross-references.
Non-goals
- Not a blockchain. Events live in Postgres; the chain is in the
prevHashcolumn. We aren’t building consensus, only an append-only signed log. - Not a zero-trust system. You still trust AC to run the signer correctly and to publish the signing-key manifest honestly. Phase 4 reduces this to trust in Base + the manifest publication mechanism, not elimination.
- Not policy enforcement. Enforcement is still
guardrail.service.ts; the chain proves what we said we did, not that we did it. The two need to match — and that’s what audit reconciliation in phase 2 verifies against the existing audit log.
Open questions
- How do we handle backfilled events when this ships — do we start the chain at “today” or reconstruct from existing audit + IPS history?
- How do enterprises with their own KMS plug their own signing key alongside AC’s for dual-attestation?
- What’s the right retention?
policy_eventscan’t prune the wayagent_api_callsdoes — but it grows forever otherwise.