Home / API reference / Audit trail

Audit trail

Everything SentrAI does is written to the audit trail: each event it scored, each incident, decision, block and undo, each alert, setting change and chat question. Records are chained: each one holds the hash of the one before, so changing or removing any record breaks every hash after it.

To prove the trail was not rebuilt from scratch on the server, SentrAI also sends a signed fingerprint of the newest record with its alerts, and can keep a copy of every record on another machine (the witness, see What SentrAI sends out).

Every endpoint here needs the API token (see Signing in) unless it says otherwise.

Read the trail

GET /audit

The records, oldest first, with whether the chain checks out.

Query

Name Type Default Meaning
limit integer, 0 or more 0 the last this many records; 0 returns all
incident string none only records about this incident, such as RSK-2026-081
curl -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8000/audit?limit=2"

Returns

{
  "chain_valid": true,
  "first_invalid_seq": null,
  "count": 79,
  "head_hash": "97876778a68a9dfaa4edb711c44625b59afe078a524221dcdfdba5b174951a28",
  "records": [
    {
      "seq": 78,
      "ts": "2026-10-27T02:45:12+00:00",
      "type": "telegram_linked",
      "data": {"agent": "Cyanide", "contact": "amir", "name": "Amir", "code_made_by": "erick", "...": "..."},
      "prev_hash": "37ec2b9e7064fe4cc407cefb43d50e89e6504fd5d230a6c55f85da5d6a62006e",
      "hash": "85b2953244d0c0c61c453c3c4fdd93796ddd0c94984f20ae9cc92fd998913ab9"
    },
    {
      "seq": 79,
      "ts": "2026-10-27T02:45:12+00:00",
      "type": "telegram_unlinked",
      "data": {"agent": "Cyanide", "contact": "amir", "operator": "erick", "...": "..."},
      "prev_hash": "85b2953244d0c0c61c453c3c4fdd93796ddd0c94984f20ae9cc92fd998913ab9",
      "hash": "97876778a68a9dfaa4edb711c44625b59afe078a524221dcdfdba5b174951a28"
    }
  ]
}
Field Meaning
chain_valid whether every record’s hash checks out, over the whole trail (not only the records returned)
first_invalid_seq the first record that does not, or null
count how many records the trail holds in all
head_hash the newest record’s hash

Each record has seq (from 1, no gaps), ts, type (what happened, such as incident_opened or machine_joined), data and the two hashes.

Checking a record yourself. hash is the SHA-256, in hex, of prev_hash followed by the JSON of {"seq", "ts", "type", "data"} written with sorted keys, no spaces (, and : as separators) and non-ASCII characters kept as they are. The first record’s prev_hash is 64 zeros. In Python:

import hashlib, json

def record_hash(rec):
    body = json.dumps({k: rec[k] for k in ("seq", "ts", "type", "data")},
                      sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    return hashlib.sha256((rec["prev_hash"] + body).encode("utf-8")).hexdigest()

Fingerprints

A fingerprint is one line naming the newest record and its hash, signed with a key only this SentrAI holds:

SENTRAI-FP v1 seq=79 head=97876778...51a28 at=2026-10-27T02:45:12Z sig=3acfc07b5b80e4ba2f15807208dce9e1

Every alert carries one (its witness field), SentrAI sends one on its own every hour when new records were written (ANCHOR_EVERY_MIN, in minutes), and each client summary holds one. Once a line has left the server, nobody there can recall it, so it can later prove the records up to it were not changed, even by someone with full access to the server.

GET /audit/fingerprint

A fingerprint of the trail as it is now.

Returns

{
  "seq": 79,
  "head": "97876778a68a9dfaa4edb711c44625b59afe078a524221dcdfdba5b174951a28",
  "at": "2026-10-27T02:45:12Z",
  "line": "SENTRAI-FP v1 seq=79 head=97876778a68a9dfaa4edb711c44625b59afe078a524221dcdfdba5b174951a28 at=2026-10-27T02:45:12Z sig=3acfc07b5b80e4ba2f15807208dce9e1"
}

sig is the first 32 hex characters of an HMAC-SHA256 with the anchor key (anchor.key next to the audit database, or SENTRAI_ANCHOR_KEY). It is missing when there is no key.

GET /audit/verify

Checks a fingerprint line against the trail on disk now. It passes only when the signature matches and the record it names still has the same hash, which fails if any record up to it was changed or removed. A shortened head (16 hex characters or more, as in alerts) works too.

Query

Name Type Meaning
fingerprint string, required a SENTRAI-FP line from an alert or summary
curl -G -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/audit/verify \
  --data-urlencode "fingerprint=SENTRAI-FP v1 seq=79 head=97876778... at=2026-10-27T02:45:12Z sig=3acfc07b..."

Returns status 200 either way; the answer is in ok.

{"ok": true, "reason": "records 1 to 79 are exactly as they were at 2026-10-27T02:45:12Z and the signature matches"}

When ok is false, reason is one of:

reason Meaning
not a SentrAI fingerprint line (it starts with 'SENTRAI-FP v1') not a fingerprint
the signature does not match: this line was not made by this SentrAI, or was altered wrong key, or the line was edited
the chain on disk is broken at record 40, before the fingerprinted record 79 a record before it was changed
record 79 is missing: the chain on disk is shorter than when the fingerprint was sent records were removed
record 79 has a different hash now: records up to it were changed or removed ... the trail was rebuilt

The same check runs on the server with python -m app.anchor verify "SENTRAI-FP v1 ...".

The off-box copy

GET /audit/remote-copy

Whether every record is also sent to a witness on another machine, and how far it has got. It is on when SENTRAI_REMOTE_COPY_URL is set (with SENTRAI_REMOTE_COPY_TOKEN, and SENTRAI_REMOTE_COPY_CA for a certificate of your own).

Returns {"enabled": false} when it is off. When it is on:

{
  "enabled": true,
  "url": "https://witness.example.com:8600",
  "ok": true,
  "audit_seq": 79,
  "lines_sent": 1204,
  "last_sent_at": "2026-10-27T02:45:12Z",
  "error": null,
  "lines_waiting": 0,
  "lines_dropped": 0
}
Field Meaning
ok whether the last send worked (null before the first)
audit_seq the newest audit record the witness holds
lines_sent sealed log lines sent since the core started
error why the last send failed
lines_waiting, lines_dropped lines held while the witness was out of reach, and the oldest ones dropped when more than 100,000 waited

When the witness refuses a record because it differs from the copy it already holds, the record on this server was changed after it was sent: that opens a log_tampering incident.