Audit
Search the record of what people, agents and machines did, check that nobody has changed it, and export it.
Every approval, enrolment, dispatch, task hand-off, policy change and budget lift the platform makes, and every action a workbench or node reports for its agents, is one entry in your organisation's audit trail. The trail is built so that a change to any entry shows.
Administrators read it, and in self-hosted AIOE operators too. In Mojo Up AI Cloud that is a team's owners and admins. Each project's members see that project's entries on its Activity tab.
Search the trail
Open Audit. Entries are newest first, each with the time, the person (and the agent, when an agent acted for them), the action, its target, the machine, and whether it was refused or failed.
- Search matches an action, a person or a target.
- What to show: everything, tool calls, or tool reviews.
- Period: the last 24 hours, the last 7 days (the default), the last 30 days, or all time.
The list shows the latest 200 entries that match. Search or shorten the period to narrow it.
Why you can trust it
Entries are never changed or removed. The database rejects any attempt to change or delete an audit entry, and the platform has no way to do either.
Every entry is chained to the one before it. Each entry carries a sequence number and a hash covering the entry and the previous entry's hash. Changing, removing or reordering any entry breaks every hash after it. There is one chain per organisation, numbered from 1 with no gaps; the platform's own entries and the ones machines report share it, in the order the platform received them.
Keep the newest link somewhere else. The chain cannot prove by itself that nothing was cut off its end, or that someone who owns the database did not empty and rebuild it. For that, note the newest link (its number and hash) somewhere the database's owners cannot reach, and compare it at the next check.
Entries written before the chain existed carry no number. They are counted separately and are not covered.
Check the chain
The Audit view checks the chain quietly when it opens, and shows:
| Figure | What it says |
|---|---|
| In the chain | How many entries are chained, and how many older ones are from before the chain. |
| Last check | Unchanged when every link holds; otherwise Broken, with the entry where it breaks. |
| Newest link | The number and the start of the hash of the newest entry. |
Choose Check the chain to check it again on the record: a check a person asks for is itself an audit entry, and so is any check that finds a break.
Export it
Under The chain, download the period you chose:
- For checking: one JSON object per line, in chain order, with each entry's number, hash and previous hash. This is the form an auditor recomputes.
- CSV: the same entries for a spreadsheet.
Every export is itself recorded.
Check an export without AIOE
An auditor can verify a JSON-lines export with nothing but SHA-256:
- The first entry's previous hash is
SHA-256("aioe-audit:v1:<tenantId>"), where<tenantId>is the entry'stenantId. An export of a period starts mid-chain: its first entry'sprevHashis the anchor, and every entry after it must follow. - For each entry, in order, build its canonical form: an object of
v(always1) and the fieldsseq,tenantId,deviceId,workspaceId,projectId,ts(as an ISO time in UTC, to the millisecond),actorKind,actorId,principal,sessionId,taskId,action,target,teamId,detail,outcomeanderror. Leave out fields that are absent or null, sort keys at every depth, and serialise it as JSON with no spaces. - The entry's
hashmust equalSHA-256(previous hash + "\n" + canonical form), as lower-case hex, and itsprevHashmust equal the previous entry'shash.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const sha = (text) => createHash("sha256").update(text, "utf8").digest("hex");
const sorted = (v) =>
Array.isArray(v) ? v.map(sorted)
: v && typeof v === "object"
? Object.fromEntries(Object.keys(v).sort().filter((k) => v[k] !== undefined && v[k] !== null).map((k) => [k, sorted(v[k])]))
: v;
const FIELDS = ["seq", "tenantId", "deviceId", "workspaceId", "projectId", "ts", "actorKind", "actorId", "principal",
"sessionId", "taskId", "action", "target", "teamId", "detail", "outcome", "error"];
const rows = readFileSync(process.argv[2], "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
let prev = rows[0].seq === 1 ? sha(`aioe-audit:v1:${rows[0].tenantId}`) : rows[0].prevHash;
for (const row of rows) {
const picked = Object.fromEntries(FIELDS.map((f) => [f, row[f]]));
const canonical = JSON.stringify(sorted({ v: 1, ...picked, ts: new Date(row.ts).toISOString() }));
if (row.prevHash !== prev || row.hash !== sha(`${prev}\n${canonical}`)) {
console.error(`The chain breaks at entry ${row.seq}`);
process.exit(1);
}
prev = row.hash;
}
console.log(`All ${rows.length} entries hold. Newest link: #${rows.at(-1).seq} ${prev}`);Run it with node verify-audit.mjs audit-chain-<date>.jsonl.
What is recorded
Among others: approvals and enrolments, revocations, policy and catalogue changes, tool reviews and tool calls refused or held, every secret released to a machine (by name and version, never the value), memory reads (by label and count, never content), git credentials granted or refused, project changes, budget lifts, node decisions, audit checks and exports. Credentials that stray into an entry's details are masked before it is stored.