Audit trail
partly builtMissing: personal fields in a history, and more on its page; publishing. See What is built.
On this page
Keep a record of who changed what does the whole job, start to finish.
An entity keeps no history of its changes unless you ask. Give it an
audit: section and it keeps a history of every change: who made it,
when, through which action and request, and each changed field's value
before and after. retention: says how long each entry is kept. Here it is
seven years, from a setting of your app.
config.jadpo:
config { audit_retention: Duration { default: 2557d }}
domains/billing/entities/invoice.jadpo:
/// A bill, whose every change is kept for seven years.entity Invoice { id: Uuid number: InvoiceNumber audit: { retention: { after: config.audit_retention } }}
InvoiceNumber is a standard type built into Jadpo, as audit: is. Invoice
is your app's, and so is the action that issues one.
domains/billing/actions/issue_invoice.jadpo:
use billing/entities { Invoice }/// Issues an invoice.action issue_invoice(number: InvoiceNumber) -> Invoice { return Invoice.create { set: { number: number } }}
Nothing else is needed: the action writes no entry itself. This test reads the
entry back, with the history query Jadpo generates for the entity.
domains/billing/actions/issue_invoice.test.jadpo:
use billing/entities { Invoice }test "issuing an invoice leaves an entry in its history" { var invoice: Invoice = issue_invoice("INV-1") var page: Page<AuditEntry> = Invoice.history(invoice.id) assert list.map(page.items, entry -> entry.change) == [AuditChange.created]}
- Each entry is written in the same transaction as the change, in the same store, so a change can't happen without its entry. Entries are only ever added: nothing in your code changes or removes one.
- "Who" is the principal, the signed-in caller. For schedules and subscribers it is the application, together with the trace of the request that emitted the event, so a change made later is traced back to the click that caused it.
- Each changed field's value is kept as its canonical text, as
text.fromgives it (GBP 120.00,2026-10-25, an enum's variant by name), or as its JSON for a type with no single text form. A field no request may touch,FieldAccess.server_only, is left out. retention:is aDurationsetting, as every period is (Where the periods come from), counted from when each entry was written, so it names nosince:.audit: {}keeps each entry until its record is erased.
Reading a history: history
Invoice.history(invoice_id) is a generated query giving the record's
history newest first, one page of 25 entries at a time. Its next is
the cursor for the page after, which you pass back as after:.
What a caller gets depends on what they may read. Here each invoice belongs to
the clerk who issued it, and only that clerk may read its discount. principal
and authentication in app.jadpo say who signs in
(Sign-in and identity); the rest is the app's own, with
audit_retention as above.
app.jadpo:
use billing/entities { Clerk }application {}principal { user: { subject: Clerk.handle }}authentication { api_token: {}}
domains/billing/entities/clerk.jadpo, with Handle a short Text of the
app's:
use billing/types { Handle }/// Someone who keeps the books.entity Clerk { id: Uuid handle: Handle { unique: true } policy: { create: [Access.public] read: [Access.public] }}
domains/billing/entities/invoice.jadpo, with clerk_id, note and discount
new, and InvoiceNote another short Text:
use billing/entities { Clerk }use billing/types { InvoiceNote }/// A bill, whose every change is kept for seven years.entity Invoice { id: Uuid clerk_id: Clerk.id { on_delete: OnDelete.restricted } number: InvoiceNumber note: InvoiceNote? discount: Int { policy: { read: [clerk_id] update: [clerk_id] } } policy: { create: [clerk_id] read: [Access.authenticated] update: [clerk_id] } audit: { retention: { after: config.audit_retention } }}
domains/billing/actions/invoice_changes.jadpo:
use billing/entities { Invoice }/// The invoice's changes, newest first, as the caller may see them.action invoice_changes( me: Principal.user, invoice_id: Invoice.id, after: PageCursor?) -> Page<AuditEntry> { return Invoice.history(invoice_id, after: after)}
It needs the read grant on the record, and leaves out the fields the
caller can't read. Here anyone signed in may read an invoice but only
its clerk its discount, so another clerk sees each change without it.
A caller who may not read the record, and anyone once the record has
been deleted, gets an empty page; a test's call without a caller reads
every entry its id has.
Each entry is an AuditEntry:
| Field | Holds |
|---|---|
at |
When the change was made, the operation's clock.now |
change |
AuditChange.created, .updated or .deleted. A lifecycle's delete transition keeps the record, so it is .updated |
by |
AuditBy.user, .service, .application (a schedule or a subscriber) or .no_caller (a test's, a seed's or a tool's call without a caller) |
user_id, service_id |
The signed-in user's or service's id, or none |
action |
The outermost action the change was made through, by name |
request |
The request's id, when a request made the change |
trace |
The W3C trace id it was made in, which a subscriber keeps from the request that emitted its event |
fields |
Each changed field, an AuditFieldChange: its name, field, and its text before and after, none where it had no value |
This test issues an invoice, notes a payment, and reads both changes back, through three ordinary actions of this app.
domains/billing/actions/hire_clerk.jadpo:
use billing/entities { Clerk }use billing/types { Handle }use billing/failures { HandleTaken }/// Takes on a clerk.action hire_clerk(handle: Handle) fails HandleTaken -> Clerk { return attempt Clerk.create { set: { handle: handle } conflict: { Clerk.handle: HandleTaken } }}
domains/billing/actions/issue_invoice.jadpo:
use billing/entities { Invoice }/// Issues an invoice.action issue_invoice(me: Principal.user, number: InvoiceNumber) -> Invoice { return Invoice.create { set: { clerk_id: me.id number: number note: none discount: 5 } }}
domains/billing/actions/annotate_invoice.jadpo:
use billing/entities { Invoice, InvoiceNotFound }use billing/types { InvoiceNote }/// Notes something on an invoice.action annotate_invoice( me: Principal.user, invoice_id: Invoice.id, note: InvoiceNote) fails InvoiceNotFound -> Invoice { return attempt Invoice.update(invoice_id) { set: { note: note } }}
domains/billing/actions/invoice_changes.test.jadpo:
use billing/entities { Clerk, Invoice }use billing/actions { annotate_invoice, hire_clerk, issue_invoice }test "each change to an invoice comes back newest first" { var ada: Clerk = attempt hire_clerk("ada") var invoice: Invoice = issue_invoice(Principal.user(ada), "INV-1") attempt annotate_invoice(Principal.user(ada), invoice.id, "Paid by card") var page: Page<AuditEntry> = invoice_changes(Principal.user(ada), invoice.id, none) assert list.map(page.items, entry -> entry.change) == [AuditChange.updated, AuditChange.created] assert list.map(page.items, entry -> entry.action) == ["annotate_invoice", "issue_invoice"] assert list.map(page.items, entry -> list.map(entry.fields, change -> change.field)) == [ ["note"], ["clerk_id", "number", "discount"] ] assert list.map(page.items, entry -> list.map(entry.fields, change -> change.after)) == [ ["Paid by card"], [text.from(ada.id), "INV-1", "5"] ]}test "another clerk sees each change without the discount" { var ada: Clerk = attempt hire_clerk("ada") var bea: Clerk = attempt hire_clerk("bea") var invoice: Invoice = issue_invoice(Principal.user(ada), "INV-2") var page: Page<AuditEntry> = invoice_changes(Principal.user(bea), invoice.id, none) assert list.map(page.items, entry -> list.map(entry.fields, change -> change.field)) == [ ["clerk_id", "number"] ]}
Retention and erasure
The entity's built-in purge schedule (The purge schedule) deletes each entry when its retention ends, oldest first, in the same batches as the entity's other retention. Entries outlive their record otherwise: a record your code deletes, or that its own retention or purge removes, leaves its history until the history's own retention ends. The purge schedule's own deletions and clearings write no entry.
A person's erasure deletes the history of every record it deletes, in the same statement (Erasure). The permissions report lists which entities are audited (The permissions report).
A migration never removes an entry
(Removing data). What an entry holds of a
field retired or dropped since stays until its retention ends, and
history leaves it out for every caller, since the field's grants went
with it. The entries of an entity that is gone stay with nothing left to
purge them, so a person accepting its removal is told.
The audit trail records changes to data. Who exported or erased a person, and who read sensitive fields, is kept as log events instead (Access records).
Sending audit entries elsewhere
When something subscribes to the built-in AuditEntryRecorded event, each
entry is also emitted as that event, in the same transaction, and travels
through the outbox like any event
(Delivery: at least once, safe to repeat).
A SIEM or log archive will receive it through publishing, which isn't built
yet (Where events travel). With
nothing listening the event is never emitted. The database stays the record of
truth; the event is a copy. It holds an entry's fields after entity,
the entity's name, and record_id, the record's id. A subscriber to it
when no entity is audited is
subscriber.event_invalid.
A sealed field is in a history as having changed, with no value before or after, so a history in clear holds nothing the field sealed.
What the audit trail doesn't keep yet
Each of these is toolchain.not_built_yet where it is written, so no
value is copied into a history that the pages don't yet say how to keep
or remove:
- A field marked
personal:, or of a standard type that is personal data whatever holds it, such as aPhoneNumberor anEmail. Its history will keep it as a keyed hash, as logs do (Marked fields in logs), so the history shows that an email changed, not what it was. - A
Filefield, and a field with aretention:of its own, whose value the history would keep past it. - A person's record removed other than by their erasure: by a
deletein code, by its ownretention:or its lifecycle'spurge:, or by a cascade from another entity's. Its history would outlive it beyond the reach of the person's erasure. A lifecycle'sdeletetransition keeps the record, so it is fine. - A
deletewhose cascade deletes, or whoseOnDelete.clearedchanges, an audited record: the database makes that change, with no entry. Make the referenceOnDelete.restrictedand change those records first. - An erasure that keeps an audited record, anonymised or retained, whose history would still hold the reference to the person.
Nor can a route send a history as it is: an AuditEntry isn't an
Output, so output: Page<AuditEntry> is
route.output_not_output. An
action builds an Output of its own from the entries it reads.
Markdown for agents: /docs/audit-trail.md.Built from Jadpo main at aee2e8f.