Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 5 Nov 2026. What's built · Install 0.3.0
jadpo
ReferenceAudit trail

Built-in services

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:

example
config {    audit_retention: Duration {        default: 2557d    }}

domains/billing/entities/invoice.jadpo:

example
/// 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:

example
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:

example
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.from gives 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 a Duration setting, as every period is (Where the periods come from), counted from when each entry was written, so it names no since:. 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:

example
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:

example
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:

example
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:

example
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:

example
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:

example
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:

example
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:

example
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 a PhoneNumber or an Email. 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 File field, and a field with a retention: of its own, whose value the history would keep past it.
  • A person's record removed other than by their erasure: by a delete in code, by its own retention: or its lifecycle's purge:, or by a cascade from another entity's. Its history would outlive it beyond the reach of the person's erasure. A lifecycle's delete transition keeps the record, so it is fine.
  • A delete whose cascade deletes, or whose OnDelete.cleared changes, an audited record: the database makes that change, with no entry. Make the reference OnDelete.restricted and 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.