Arcus Docs

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:

FieldMeaning
seqPosition in your chain. Starts at 1, no gaps.
prevHashThe hash of the previous event in your chain.
hashSHA-256 over this event's canonical serialization, including prevHash.
text
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#

json
{
  "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

TypeMeaning
dispatch.allowedPassed all five gates and was queued
dispatch.blocked_by_policyNo policy allowed it, or one blocked it
dispatch.blocked_by_dlpCredential-shaped data in the payload
dispatch.blocked_by_threatThe sending agent was quarantined
dispatch.spoof_rejectedfrom did not match the key's bound agent
dispatch.blocked_by_parameterA block guardrail matched
dispatch.held_for_approvalA REQUIRE_APPROVAL policy held it
dispatch.held_for_parameterA hold_for_approval guardrail held it
dispatch.flagged_by_parameterA flag_only guardrail matched; delivered anyway

Delivery

TypeMeaning
delivery.succeededThe target returned 2xx
delivery.failedAn attempt failed, with the reason

Human decisions

TypeMeaning
message.approvedA held dispatch was approved, with the approver
message.rejectedA held dispatch was rejected, with the reason

Configuration changes

TypeMeaning
key.created / key.revokedCredential lifecycle
policy.created / policy.deletedRoute rules changed
guardrail.created / guardrail.updated / guardrail.deletedValue rules changed
subscription.changedPlan or tier changed

Enforcement overrides

TypeMeaning
threat.releasedA quarantine was cleared early, with the actor
abuse.throttledA 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#

bash
curl -sS "$ARCUS_URL/v1/admin/audit/events?limit=50" \
  -H "Authorization: Bearer <clerk-session-token>"
ParameterNotes
typeFilter to one event type, up to 60 characters
limit1–200, default 50
cursorSequence number to page from
bash
# 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#

FreePro / Max
Events writtenAllAll
Events readableMost recent 100Full history
Window readableLast 7 daysFull history
Chain verificationYes

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#

bash
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:

json
{
  "valid": true,
  "eventCount": 1428,
  "firstSeq": 1,
  "lastSeq": 1428,
  "verifiedAt": "2026-08-24T09:20:11.402Z"
}

Broken:

json
{
  "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:

  1. Do not write more events to that chain if you can pause traffic — further appends build on an already-broken sequence.
  2. Note brokenAtSeq. Everything before it is still trustworthy, which is often most of your history.
  3. Read the events around the break and compare them to the corresponding TransferLog rows. A discrepancy between the two records is a strong signal about what was altered.
  4. Investigate database access — who has write credentials, what has connected recently, and whether a migration or restore touched the table.
  5. 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.