Skip to content

Pre-release. v0.1 is not out yet, so there is nothing to install and no public source to clone — the quickstart builds from a checkout.

The audit ledger

Every authorisation decision is appended to the ledger before the answer goes out — every allow, and every denial that names an agent, a person, a task or a token. A denial that names nobody is written once per reason per window and the rest are counted into one summary record carrying count (Onbe:Audit:Aggregation:*), so an anonymous flood cannot fill a table that refuses DELETE.

{
"seq": 10428,
"chain": 6,
"ts": "2026-09-09T14:03:41.882Z",
"event": "token.issued",
"task_id": "task_01HQZX9K4M",
"agent_id": "jira-triage",
"sponsor": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"audience": "https://jira.internal",
"scope": "jira:read jira:comment",
"jti": "tok_01HQZX9K5P",
"delegation_depth": 1,
"decision": "allow",
"reason": null,
"prev_hash": "9c1f…a20b",
"hash": "4e77…c913"
}

sponsor is the human the whole task is being run for. It is the field you filter on when someone asks what any agent did on behalf of a person.

chain is which of the ledger’s chains the record sits on. It is absent on records written before the ledger was sharded.

Events written by the control plane: token.issued, token.denied, token.refreshed, token.revoked, task.created, task.revoked, task.expired, agent.registered, agent.updated, agent.deleted and admin.denied.

tool.called is reserved and not produced. The specification marks it that way because there is no endpoint to report a tool call to and no SDK that sends one; if that lands, it will be opt-in, non-blocking and off the request path, because a tool server that cannot reach the control plane must still answer. What a tool server records today, it records in its own log — see the log line.

hash = sha256(canonical_json(record minus hash) || prev_hash)

Each record hashes its own contents together with the hash of the record before it in its chain; the record that opens a chain has prev_hash: null. Change one record and its hash changes; leave the stored hash alone and that record no longer hashes to it; recompute it and the next record’s prev_hash no longer matches. There is no edit that does not show. chain and count sit outside the hashed JSON, so a ledger written before either existed still verifies.

The ledger is a fixed number of chains — Onbe:Audit:Chains, sixteen by default, one at the least — each with its own transaction-scoped advisory lock, so any number of instances write coherent chains and writers on different chains do not queue behind each other. A record is placed by the human it names, so one person’s history is one provable chain; one naming no human goes by the agent, and one naming neither is spread. The table refuses UPDATE, DELETE and TRUNCATE by trigger.

Sequence numbers may have holes. A rolled-back append consumes a number without touching the chain, so a gap is normal and is not evidence of anything.

Terminal window
Onbe.Server audit-verify
Output
Audit ledger intact: 621 record(s) verified across 3 chain(s), heads 609:2b8d0e4c…,617:9c1f6a30…,621:484fa8a9….

It walks every chain from its first record and recomputes every link, reporting the sequence number and kind of the first fault: a broken link — a record missing, inserted or replaced — or a record that no longer hashes to its stored hash. It exits 0 when every chain is intact, 3 when one is not, 1 when the ledger could not be read, and 2 for a malformed head argument.

It cannot tell a cut tail from a short ledger. If somebody with write access removes the last hundred records, what remains is a perfectly valid chain that is simply shorter. Nothing inside the database can detect that, which is why each run prints the head of every chain as a comma-separated seq:hash list:

Terminal window
Onbe.Server audit-verify 609:2b8d0e4c…,617:9c1f6a30…,621:484fa8a9…

Pass that list back and the command also confirms each chain still reaches its head unchanged. Keep the list somewhere the database cannot reach — a different system, a chat channel, a printed page. That is the part that makes the chain worth having; without it you are trusting the same storage to hold both the evidence and the proof of the evidence.

GET /audit?sponsor=f47ac10b-…&from=2026-09-01&to=2026-09-30
Authorization: Bearer <admin api key>

Filters, all optional and combined with and: sponsor, agent_id, task_id, from (inclusive), to (exclusive) and decision. A time is an ISO 8601 timestamp or a bare date meaning midnight UTC; a bare date in to covers that whole day, so the example above is all of September.

Records come back oldest first, limit per page (default 100, at most 1000), with an opaque next_cursor to pass back as cursor. The sponsor and agent filters are served from dedicated indexes, so a page of one person’s month is one index range in page order, never a scan.

The ledger is the record; a sink is a copy. Set Onbe:Audit:Sink:Url and every append is also queued and posted to that URL in batches, in sequence order. A slow, failing or absent sink changes nothing about what the token endpoint answers — the endpoint only queues.

Delivery is at least once, and batches from several instances can arrive out of order, so a sink should treat seq as the identity and order by it.

OnbePre-release. v0.1 is not out yet.

© 2026 Onbe