Evidence
Audit chain
Every governance decision is appended to a per-tenant hash chain. Each event seals the one before it, so editing or deleting a record breaks the sequence — and the break is provable on demand.
Why a chain and not a log table#
A log table answers "what happened". It does not answer "has this been changed since".
Anyone with database access can UPDATE a row, DELETE an inconvenient one, or insert a plausible-looking record after the fact. Nothing in an ordinary audit table detects any of that. For an internal debugging log this is fine. For evidence — a compliance review, an incident report, a dispute about what an agent was authorized to do — it is the whole problem.
Arcus's audit chain makes tampering detectable. It cannot prevent someone with write access from modifying a row; it guarantees that if they do, verification fails and names the exact sequence number where the record stopped being trustworthy.
How each event is sealed#
Every event carries three fields that bind it into place:
| Field | Meaning |
|---|---|
seq | Position in your chain. Starts at 1, no gaps. |
prevHash | The hash of the previous event in your chain. |
hash | SHA-256 over this event's canonical serialization, including prevHash. |
seq 1 ┌─────────────────────────┐
│ type: key.created │
│ prevHash: (genesis) │
│ hash: a3f1… │
└───────────┬─────────────┘
│
seq 2 ┌───────────▼─────────────┐
│ type: policy.created │
│ prevHash: a3f1… │
│ hash: 9c02… │
└───────────┬─────────────┘
│
seq 3 ┌───────────▼─────────────┐
│ type: dispatch.allowed │
│ prevHash: 9c02… │
│ hash: 4e7b… │
└─────────────────────────┘Because each hash covers the previous hash, changing event 2 changes its hash, which no longer matches the prevHash stored in event 3. Every event after the edit is invalidated, and the first mismatch is the exact point of tampering. There is no way to edit one row quietly — you would have to recompute every hash from that point to the present, and the verification endpoint recomputes them independently from the same source.
Deletion is equally visible: the seq sequence is contiguous, so a missing number is a missing event.
One chain per tenant#
Your chain is independent, starting at sequence 1 for your account.
A single global chain would interleave other accounts' writes into your proof, so verifying your own history would depend on rows you are not permitted to read — and one tenant's high write volume would become a contention point for everyone else's. Per-tenant chains keep the proof self-contained.
Concurrent writes cannot fork the chain#
Appending takes a per-tenant advisory lock inside the transaction that writes the event. Two dispatches arriving at the same instant serialise: one becomes seq n, the other n+1, and both read a settled prevHash. The lock is per tenant, so your traffic never waits on anyone else's.
The audit write and the decision it records share one transaction. There is no state in which a dispatch was decided but not sealed, or sealed but not decided.
What an event contains#
{
"seq": 1428,
"type": "dispatch.blocked_by_dlp",
"actor": "ark_live_4f8c21",
"summary": "intake-agent → https://api.example.com/webhook/triage refused: 2 DLP categories",
"metadata": {
"sourceAgent": "intake-agent",
"targetUrl": "https://api.example.com/webhook/triage",
"violations": ["PASSWORD_FIELD", "AWS_ACCESS_KEY"],
"matchCount": 2,
"transferLogId": "clx8m2p4k0005abcd"
},
"prevHash": "9c02f7ab…",
"hash": "4e7b1d3c…",
"createdAt": "2026-08-24T09:14:02.881Z"
}actor is the key prefix for gateway traffic, not the claimed from. This is a deliberate choice: the sender name is caller-supplied and may be the very thing under suspicion, whereas the key that authenticated the request is a fact Arcus established itself. For control-plane events, the actor is the account that took the action.
Payload contents are never stored in an audit event. DLP records the category and the path, never the matched value. A guardrail records the field, the expected threshold and the observed value — the observed value being the thing under review. What does not appear is the full message body: the chain is a record of decisions, not a second copy of your data.
The 23 event types#
Dispatch decisions
| Type | Meaning |
|---|---|
dispatch.allowed | Passed all five gates and was queued |
dispatch.blocked_by_policy | No policy allowed it, or one blocked it |
dispatch.blocked_by_dlp | Credential-shaped data in the payload |
dispatch.blocked_by_threat | The sending agent was quarantined |
dispatch.spoof_rejected | from did not match the key's bound agent |
dispatch.blocked_by_parameter | A block guardrail matched |
dispatch.held_for_approval | A REQUIRE_APPROVAL policy held it |
dispatch.held_for_parameter | A hold_for_approval guardrail held it |
dispatch.flagged_by_parameter | A flag_only guardrail matched; delivered anyway |
Delivery
| Type | Meaning |
|---|---|
delivery.succeeded | The target returned 2xx |
delivery.failed | An attempt failed, with the reason |
Human decisions
| Type | Meaning |
|---|---|
message.approved | A held dispatch was approved, with the approver |
message.rejected | A held dispatch was rejected, with the reason |
Configuration changes
| Type | Meaning |
|---|---|
key.created / key.revoked | Credential lifecycle |
policy.created / policy.deleted | Route rules changed |
guardrail.created / guardrail.updated / guardrail.deleted | Value rules changed |
subscription.changed | Plan or tier changed |
Enforcement overrides
| Type | Meaning |
|---|---|
threat.released | A quarantine was cleared early, with the actor |
abuse.throttled | A platform operator imposed a quarantine, with the reason |
Rule changes are in the same chain as the decisions
This is the point of including policy.created, guardrail.deleted and the rest. "The policy was different at the time" becomes a verifiable claim rather than an assertion, and disabling a guardrail is itself an audited governance event — sealed, timestamped and attributed, in sequence with the traffic it affected.
Reading the chain#
curl -sS "$ARCUS_URL/v1/admin/audit/events?limit=50" \
-H "Authorization: Bearer <clerk-session-token>"| Parameter | Notes |
|---|---|
type | Filter to one event type, up to 60 characters |
limit | 1–200, default 50 |
cursor | Sequence number to page from |
# Every credential revocation, newest first
curl -sS "$ARCUS_URL/v1/admin/audit/events?type=key.revoked&limit=100" \
-H "Authorization: Bearer <clerk-session-token>"Filtering happens in the database, not client-side, so a narrow query over a long history stays cheap. In the dashboard this is the Audit Trail view.
Retention on Free#
| Free | Pro / Max | |
|---|---|---|
| Events written | All | All |
| Events readable | Most recent 100 | Full history |
| Window readable | Last 7 days | Full history |
| Chain verification | — | Yes |
Every event is written on every plan — the chain is complete and the hashes are computed regardless of tier. What Free limits is how far back you can read. Upgrading reveals history that was recorded all along; it does not begin recording.
Verifying the chain#
curl -sS "$ARCUS_URL/v1/admin/audit/verify" \
-H "Authorization: Bearer <clerk-session-token>"Arcus walks your chain from sequence 1, recomputing each hash from the stored event and comparing it to both the recorded hash and the next event's prevHash.
Intact:
{
"valid": true,
"eventCount": 1428,
"firstSeq": 1,
"lastSeq": 1428,
"verifiedAt": "2026-08-24T09:20:11.402Z"
}Broken:
{
"valid": false,
"eventCount": 1428,
"brokenAtSeq": 872,
"reason": "hash mismatch: recomputed hash does not match stored hash",
"verifiedAt": "2026-08-24T09:20:11.402Z"
}brokenAtSeq is the first event that fails. Events before it are intact; that one and everything after are suspect.
Verification requires the audit.verify capability (Pro and above). In the dashboard it is the Verify chain button on the Audit view.
Verify on a schedule, not only after an incident
A quarterly verification with the result filed is worth more than an ad-hoc one, for the same reason a backup you have restored is worth more than one you have not. Run it, keep the JSON, and you can show a reviewer that the chain was intact at known points in time rather than only asserting it is now.
If verification fails#
It means the database rows no longer match their hashes. Treat it as a security incident:
- Do not write more events to that chain if you can pause traffic — further appends build on an already-broken sequence.
- Note
brokenAtSeq. Everything before it is still trustworthy, which is often most of your history. - Read the events around the break and compare them to the corresponding
TransferLogrows. A discrepancy between the two records is a strong signal about what was altered. - Investigate database access — who has write credentials, what has connected recently, and whether a migration or restore touched the table.
- Restore from a backup taken before the break if the integrity of the record matters more than the events after it.
A legitimate cause exists and is worth ruling out first: a database restore that replayed rows out of order, or a manual data fix. Both are still integrity failures of the record — the chain correctly refuses to pretend otherwise.
What the chain does and does not guarantee#
It does guarantee that any modification, deletion or reordering of your audit history is detectable, that the first point of tampering is identifiable, and that every governance decision Arcus made was recorded in the same transaction that made it.
It does not guarantee that a sufficiently privileged attacker cannot rewrite the entire chain from the point of compromise forward — recomputing all subsequent hashes is possible for someone with unrestricted database write access and knowledge of the serialization. What that attacker cannot do is change history invisibly while leaving the rest intact, and they cannot alter a copy you have already exported. For stronger guarantees, export your chain periodically and store the exports somewhere Arcus's database credentials do not reach — see Compliance reporting.
This is stated plainly because a tamper-evident system and a tamper-proof system are different things, and only one of them is achievable inside a single database.