Skip to content

Audit log

Every schema with audit: true (the default) writes an audit row on every mutation. Reads are surfaced as a per-record history endpoint, a GraphQL field, an MCP history_<path> tool, and a typed client method.

ActionWhen
createPOST / GraphQL <path>CreateOne / MCP create_<path>.
updatePUT / GraphQL <path>UpdateById / MCP update_<path>.
deleteDELETE — soft or hard.
restorePOST /:id/restore.
transitionAny state-machine transition.
{
"_id": "65c0...",
"schemaPath": "quote",
"documentId": "65b1...",
"action": "transition",
"userId": "65a0...",
"before": { "status": "review" },
"after": { "status": "approved" },
"diff": { "status": { "from": "review", "to": "approved" } },
"field": "status",
"createdAt": "2026-05-10T12:00:00Z"
}
KeyDescription
schemaPath / documentIdThe record this row belongs to.
actioncreate / update / delete / restore / transition.
userIdThe actor’s tenant identity, from the JWT.
before / afterDocument projections at each end of the change. ACL-projected, so a hidden field never leaks into history.
diffField-level { from, to } map. Only fields that changed are present.
fieldSet on transition rows — the state-machine field that moved.
createdAtWhen the row was written.

Audit writes are best-effort. The framework writes the row in a fire-and-forget pattern after the mutation succeeds — a Mongo write failure on the audit log is logged but does not fail the caller’s request. Use the rate of audit writes as a health metric on your dashboard.

GET /api/v1/quote/abc/history

Returns:

{
"results": [
{ "action": "transition", "field": "status", "diff": {/* ... */}, "createdAt": "..." },
{ "action": "update", "diff": {/* ... */}, "createdAt": "..." },
{ "action": "create", "after": {/* ... */}, "createdAt": "..." }
]
}

Newest first. Same pagination params as the list endpoint (__page, __sort).

{
quoteHistory(_id: "abc") {
action, field, before, after, diff, createdAt
}
}
{
"name": "history_quote",
"arguments": { "id": "abc" }
}
const history = await api.quote.history('abc');
// AuditEntry[] with discriminated `action` field

before, after, and diff are run through the schema’s ACL projector at read time. A user without read: ['admin'] on salary doesn’t see salary changes in history — even if they’re allowed to see the rest of the record.

The same projection is applied to webhook payloads: the audit log and outbound webhooks share the same projection layer, so there’s no side channel that bypasses ACL.

History reads honour the schema’s acl.list slot — operators with the listed role can read any record’s history, not just their own.

Without acl.list, history is owner-only.

module.exports = {
path: 'session_event',
audit: false, // no audit rows; history endpoints absent
fields: [/* ... */],
};

Use for high-volume / low-value rows where the audit log itself would dwarf the data. Common candidates: session events, analytics rows, ephemeral caches.

The audit log has its own collection (audit_log) and grows linearly with mutations. The framework does not auto-purge audit rows — they live forever unless you prune them manually.

For a bounded retention window (compliance, storage cost), run a periodic prune on a cron:

// Keep 1 year of audit rows. Run weekly.
db.audit_log.deleteMany({
createdAt: { $lt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
});

Framework-side audit retention is a tracked enhancement; today the auto-purge story stops at idempotency keys and soft-delete tombstones. See Backup & retention.