Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 4 Nov 2026. What's built · Install 0.2.0
jadpo
Guides: all 17

Guides

Keep a record of who changed what

You end with an audited entity, its history read back with what each caller may see, and a route that sends it.

On this page

An entity keeps no history of its changes unless you ask. Give it an audit: section and Jadpo writes down each change in the same transaction: who made it, when, through which action and request, and each changed field's value before and after. You write no logging code, and nothing can change a record without its entry. This guide keeps the history of a book-keeper's invoices and reads it back through a route.

You start from an app with a Clerk entity that people sign in as, an Invoice entity, and actions called hire_clerk, issue_invoice and annotate_invoice that change them. Add sign-in and Store and change a record show how to write those.

Turn it on

audit: goes on the entity. retention: says how long each entry is kept, counted from when it was written, and takes a Duration setting as every period does. Write audit: {} to keep each entry until its record is erased.

config.jadpo:

jadpo
config {    /// How long each invoice change is kept: seven years.    audit_retention: Duration {        default: 2557d    }}

domains/billing/entities/invoice.jadpo. audit: is new:

jadpo
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        }    }}

The entries live in a table Jadpo owns, jadpo_audit, in the same store as the record, so make a migration:

shell
jadpo migrate new audit_invoices

From now on, every write to an invoice adds an entry. Nothing in your code can change or remove one. A field no request may touch, FieldAccess.server_only, is left out.

An audited entity can't hold an Email, a field marked personal:, or a File field yet. Keep them on an entity with no audit:, such as a Customer that the invoice refers to. The last section of this page says more.

Read the history

Invoice.history(invoice_id) is a query Jadpo generates. It gives one page of 25 entries, newest first, and its next is the cursor for the page after. Each is an AuditEntry:

Field Holds
at When the change was made
change AuditChange.created, .updated or .deleted
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 trace id it was made in, which a subscriber keeps from the request that emitted its event
fields Each changed field: its name, field, and its text before and after, none where it had no value

A history is read as the caller may see it. It needs the read grant on the record and leaves out the fields the caller can't read, so here another clerk sees each change without the discount, which only the invoice's clerk may read. A caller who may not read the record gets an empty page.

Send it through a route

A route can't send an AuditEntry as it is, because it isn't an Output. You build an Output of your own, which also makes you choose what your API's clients see.

domains/billing/types/change_line.jadpo:

jadpo
/// One field an invoice's change touched.type ChangeLine = Output {    field: Text    before: Text?    after: Text?}

domains/billing/types/invoice_change.jadpo:

jadpo
use billing/types { ChangeLine }/// One change to an invoice, as the API sends it.type InvoiceChange = Output {    at: Instant    action: Text?    user_id: Uuid?    fields: List<ChangeLine>}

domains/billing/actions/invoice_changes.jadpo:

jadpo
use billing/entities { Invoice }use billing/types { ChangeLine, InvoiceChange }/// The invoice's latest changes, newest first, as the caller may see them.action invoice_changes(me: Principal.user, invoice_id: Invoice.id) -> List<InvoiceChange> {    var page: Page<AuditEntry> = Invoice.history(invoice_id)    return list.map(        page.items,        entry -> InvoiceChange {            at: entry.at            action: entry.action            user_id: entry.user_id            fields: list.map(                entry.fields,                line -> ChangeLine {                    field: line.field                    before: line.before                    after: line.after                }            )        }    )}

domains/billing/routes/invoices.jadpo:

jadpo
use billing/entities { Invoice }use billing/types { InvoiceChange }use billing/actions { invoice_changes }/// The office's invoices.routes Invoices {    prefix: /invoices    /// One invoice's latest changes, newest first.    GET /{invoice_id: Invoice.id}/changes {        output: List<InvoiceChange>        run: invoice_changes(current_principal.user, invoice_id)    }}

Run jadpo api update to bring the API record up to date. This route gives the latest 25 changes. For more, take a PageCursor from the caller and pass it as Invoice.history(invoice_id, after: after).

Test it

These tests make changes through the actions, as a person would, and read them back.

domains/billing/actions/invoice_changes.test.jadpo:

jadpo
use billing/entities { Clerk, Invoice }use billing/types { InvoiceChange }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 changes: List<InvoiceChange> = invoice_changes(Principal.user(ada), invoice.id)    assert list.map(changes, change -> change.action) == ["annotate_invoice", "issue_invoice"]    assert list.map(changes, change -> list.map(change.fields, line -> line.field)) == [        ["note"],        ["clerk_id", "number", "discount"]    ]    assert list.any(        changes,        change -> list.any(change.fields, line -> line.after == "Paid by card")    )}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 changes: List<InvoiceChange> = invoice_changes(Principal.user(bea), invoice.id)    assert list.map(changes, change -> list.map(change.fields, line -> line.field)) == [        ["clerk_id", "number"]    ]}

domains/billing/routes/invoices.test.jadpo:

jadpo
use billing/entities { Clerk, Invoice }use billing/actions { hire_clerk, issue_invoice }test "the route answers with the changes, for anyone signed in" {    var ada: Clerk = attempt hire_clerk("ada")    var invoice: Invoice = issue_invoice(Principal.user(ada), "INV-3")    var response: Response = request {        method: HttpMethod.get        path: "/invoices/{invoice_id}/changes"        params: {            invoice_id: invoice.id        }        as: Principal.user(ada)    }    assert response.status == 200}
shell
jadpo test
output
tested: 3 passed, 2 action tests, 1 route test

How long entries last

The entity's built-in purge schedule deletes each entry when its retention ends. An entry outlives its record otherwise: deleting an invoice leaves its history until the history's own retention ends, though history then gives an empty page. A person's erasure deletes the history of every record it deletes.

The audit trail records changes to data. Who read or exported a person's data is kept in access records instead.

To send entries somewhere else, subscribe to the built-in AuditEntryRecorded event. Each entry is then also emitted in the same transaction, and your subscriber gets it like any other event. The database stays the record of truth.

Read more

Audit trail, Subscribers, The permissions report and Marked fields in logs.

Not built yet

An audited entity can't yet hold a value its history would have to keep in a way Jadpo hasn't built. Each of these is toolchain.not_built_yet where it is written:

  • An Email, a field marked personal:, or a standard type that is personal data whatever holds it, such as a PhoneNumber.
  • A File field, or a field with a retention: of its own.
  • A person's record removed other than by their erasure, such as by a delete in code or by a cascade.
  • A delete whose cascade deletes, or whose OnDelete.cleared changes, an audited record. Make the reference OnDelete.restricted, as clerk_id is here, and change those records first.
  • An erasure that keeps an audited record, anonymised or retained.

An Email on Invoice gives this, its rule: and help: lines left out:

jadpo check

error Auditing Invoice isn't built yet

--> domains/billing/entities/invoice.jadpo:26:5

   |12 |     customer_email: Email   |     -------------- an `Email`26 |     audit: {   |     ^^^^^ decided, not built
reason
customer_email is an Email, personal data (PersonalData.contact) whatever holds it, and its history keeps a personal value only as a keyed hash, as logs do, which isn't built yet (area 15, "Marked fields in logs"): accepted, the value would be copied into every entry.
next
Leave audit: out of Invoice for now (agent)

Sending entries outward through publishing isn't built yet either. It comes with outgoing webhooks. Subscribe to AuditEntryRecorded for now.

Markdown for agents: /guides/guide-keep-a-record-of-who-changed-what.md.Built from Jadpo main at 94ab778. Jadpo's CI compiles this guide's code and runs its tests.