Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

50. Auditable Revisions

Rullst SQLx models can bind each instance mutation to a validated principal and restore eligible update revisions as a new compensating mutation. The audit row and model write share a transaction; an audit failure rejects the write.

This contract is opt-in and does not infer identity from request data. Install the principal and tenant contexts only after your authentication and authorization boundary has validated them.

1. Mark the model and install the table

#![allow(unused)]
fn main() {
use rullst_orm::Orm;

#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "projects", auditable, tenant_column = "account_id")]
struct Project {
    id: i32,
    account_id: String,
    name: String,
    #[orm(masked)]
    api_token: String,
}

async fn install() -> Result<(), rullst_orm::Error> {
rullst_orm::audit::create_audit_table().await?;
Ok(())
}
}

create_audit_table also adds the v2 columns to the legacy audit table. Legacy records retain version 1 and cannot be restored.

On MySQL/MariaDB, new audit tables store old_values, new_values and restore_patch as LONGTEXT; TEXT holds only 64 KiB, so a larger audited change would fail the mutation in strict SQL mode (or be truncated otherwise). A table created by an earlier version keeps TEXT columns and create_audit_table only logs a warning. Apply this reviewed migration during a maintenance window (it rebuilds the table):

ALTER TABLE rullst_audits
    MODIFY old_values LONGTEXT,
    MODIFY new_values LONGTEXT,
    MODIFY restore_patch LONGTEXT;

Fields whose names contain password, token, secret, API key, credential, cookie or similar markers must use #[orm(masked)] on auditable models. Their values are never retained in audit payloads or reverse patches. The same applies to such keys inside JSON fields: when an update adds or removes one, the reverse patch keeps only the key’s presence and that revision cannot be restored.

#[orm(masked)] and #[orm(encrypted)] fields appear as "***" in every generated audit payload, whatever their name. When an update changes one of them, the audit row still records the field (as "***" on both sides) so the change is visible without its value, and that revision cannot be restored.

2. Bind a mutation to trusted context

#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "projects", auditable, tenant_column = "account_id")]
struct Project { id: i32, account_id: String, name: String, #[orm(masked)] api_token: String }
use rullst_orm::audit::{AuditContext, with_audit_context};
use rullst_orm::with_tenant;

async fn update(mut project: Project) -> Result<(), rullst_orm::Error> {
let audit = AuditContext::user("user-42")?
    .with_correlation_id("request-01J...")?;

with_tenant("account-a", with_audit_context(audit, async move {
    project.name = "New name".to_string();
    project.save().await
}))
.await?;
Ok(())
}
}

Background work can use AuditContext::service("billing-worker"); reviewed maintenance can use AuditContext::system("migration-2026-08"). Empty, padded, control-character, and oversized identifiers fail validation. An auditable mutation without an active context also fails closed.

3. Restore an eligible update

#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "projects", auditable, tenant_column = "account_id")]
struct Project { id: i32, account_id: String, name: String, #[orm(masked)] api_token: String }
use rullst_orm::audit::{AuditContext, with_audit_context};
use rullst_orm::with_tenant;
async fn restore(project: Project, audit_id: i32) -> Result<Project, rullst_orm::Error> {
let actor = AuditContext::user("admin-7")?
    .with_correlation_id("support-case-1042")?;

let restored = with_tenant("account-a", with_audit_context(actor, async move {
    project
        .restore_revision(audit_id, "approved support rollback")
        .await
}))
.await?;
Ok(restored)
}
}

The returned model contains the restored state. Rullst verifies the exact model, ID, active tenant, patch version and current post-state before writing. PostgreSQL and MySQL/MariaDB lock the row during this check. Success creates a new updated audit entry with reverted_audit_id and the bounded reason; audit history is not rewritten or deleted.

Use restore_revision_with_tx when a caller-owned SQLx transaction must include other relational writes. Prefer Orm::transaction when generated post-commit observers must wait for the final commit decision.

Deliberate refusal cases

Restoration fails for:

  • legacy v1, create, or delete entries;
  • a revision from another model, record, or tenant;
  • a row changed again after the selected revision;
  • a patch containing a redacted/sensitive change, including any change to an #[orm(masked)] or #[orm(encrypted)] field;
  • malformed, empty, too deep, too large, or excessively wide patches.

A change whose reverse patch would be too deep, too large or too wide (for example two multi-megabyte values or thousands of changed JSON keys) is still saved and audited; the revision is simply recorded without a restore patch and refused on restoration.

Those refusals prevent audit history from becoming an unsafe generic backup mechanism. Use reviewed database backups and restore drills for disaster recovery. Bulk update/delete builders do not invent per-row audit history, and cross-process export or notification should use the transactional outbox.