# `auditlog` — Behavior Specification
This document is the single source of truth for the `auditlog` crate. It is the authoritative
description of the crate's own behavior. Where this prose and the executable code appear to
disagree, **the code wins** and the discrepancy should be treated as a bug in this document.
---
## 0. Scope, Vocabulary, and Conventions
`auditlog` records every change made to a host application's data models into a single polymorphic
`audits` table. Each change is one **audit row** recording: what record changed, what changed (a
diff), who changed it, when, from where (IP), under what request, and an optional comment.
The crate is **ORM-agnostic**. The host implements the [`Auditable`] trait for its model and hands
the audit methods a [`Backend`]. The host calls the audit methods explicitly around its own
persistence (there is no single ORM to hook), passing the *before* and *after* state; the crate
computes the diff, applies configuration, and writes the audit.
Vocabulary (concept → term used in this spec):
| the changed model | **auditable** |
| a persisted audit row | **`Audit`** |
| the serialized diff | **change set** (`AuditedChanges`) |
| the per-request/job store of current context | **audit context** (task-local) |
| scoping the acting user for a unit of work | **`as_user` scope** |
| suspending auditing for a unit of work | **`without_auditing` scope** |
| recording a parent/associated record | **associated record** |
| polymorphic ref `(id, type)` | `(Option<AuditId>, Option<String>)` pair |
Conventions:
- "Action" is always one of `Create`, `Update`, `Destroy`. A no-op touch is recorded as `Update`
(there is no distinct touch action string; the legacy input string `"touch"` is normalized to
`Update` on read).
- All "current context" values (acting user, IP, request id) live in a **per-task audit context**,
ANDed with process-global and per-type configuration.
---
## 1. The `audits` Table Schema
This is the cumulative schema. All columns are **nullable** except where noted; only `version` has
a default. **There is no `updated_at` column** — audits are immutable; `created_at` is the only
timestamp.
All polymorphic ids (`auditable_id`, `associated_id`, `user_id`) are stored as **`TEXT`**, so
integer **and** UUID primary keys work uniformly. `audited_changes` is stored as **JSON text**.
`created_at` is stored as a fixed-width RFC 3339 UTC timestamp (microsecond precision), so
lexicographic ordering equals chronological ordering.
### 1.1 Columns
| `id` | integer PK (auto) | no | auto | Primary key. |
| `auditable_id` | `text` | yes | — | Id of the audited record (polymorphic id half). |
| `auditable_type` | `text` | yes | — | Type name of the audited record (polymorphic type half). |
| `associated_id` | `text` | yes | — | Id of an associated/parent record (`associated_with`). |
| `associated_type` | `text` | yes | — | Type name of the associated/parent record. |
| `user_id` | `text` | yes | — | Id of the acting user when the user is a record. |
| `user_type` | `text` | yes | — | Type name of the acting user record. |
| `username` | `text` | yes | — | Acting user as a plain string. **Mutually exclusive** with `user_id`/`user_type`. |
| `action` | `text` | yes | — | `"create"`, `"update"`, or `"destroy"`. |
| `audited_changes` | `text` (JSON) | yes | — | The serialized change set. Shape depends on action (see §2). |
| `version` | integer | yes | `0` | Per-auditable monotonic version counter (see §3). |
| `comment` | `text` | yes | — | Optional audit comment. |
| `remote_address` | `text` | yes | — | Client IP at audit time. |
| `request_uuid` | `text` | yes | — | Correlates all audits from one request. |
| `created_at` | `text` (RFC 3339) | yes | — | When the change occurred. |
> The stored/queried value for deletions is `"destroy"` (never `"delete"`).
### 1.2 Indexes
Six indexes. The first five accelerate the common lookups; the sixth is a **unique** constraint
that guards against version races (§3).
| `auditable_index` | `(auditable_type, auditable_id, version)` | Type-first; includes `version`. |
| `associated_index` | `(associated_type, associated_id)` | Type-first. |
| `user_index` | `(user_id, user_type)` | Id-first (intentional asymmetry). |
| `request_uuid_index` | `(request_uuid)` | Single column. |
| `created_at_index` | `(created_at)` | Single column. |
| unique `(auditable_type, auditable_id, version)` | composite | Rejects duplicate versions for one record. |
### 1.3 Migrations
`SqlxBackend::migrate()` creates the `audits` table and all indexes if they do not already exist,
for both the SQLite and Postgres dialects. The schema is identical across backends except for
dialect-specific column declarations and placeholder syntax (`?` for SQLite, `$n` for Postgres).
---
## 2. The Change Set (`audited_changes`) — CRITICAL
The shape of `audited_changes` **differs by action**. Getting this exactly right is the most
important correctness requirement.
### 2.1 Shape by action
| **Create** | Flat map `{ column => value }` (single values, **not** pairs). The record's full filtered `audited_attributes` snapshot. | `{"name": "Brandon", "status": 1}` |
| **Update** | Map `{ column => [old, new] }` (2-element pairs). | `{"name": ["Brandon", "Changed"]}` |
| **Destroy** | Flat map `{ column => value }` (single values, like create). Full filtered snapshot. | `{"name": "Brandon", "status": 1}` |
Modeling:
```rust
enum ChangeValue {
Set(Value), // create / destroy snapshot value (also legacy single-value updates)
Update(Value, Value), // update: (old, new)
}
// AuditedChanges wraps an ordered, string-keyed map (ValueMap = IndexMap<String, serde_json::Value>).
```
> **Do not** wrap create/destroy values in a pair. A serializer that always emits `[old, new]` is
> wrong.
### 2.2 Deriving `new_attributes` / `old_attributes`
| `new_attributes` | value as-is | `pair[1]` (the new value) |
| `old_attributes` | value as-is | `pair[0]` (the old value) |
Both return string-keyed maps. **Legacy tolerance:** if a stored update value is a scalar rather
than a 2-element array (older storage format), it is treated as the value itself for both old and
new. The action of the row decides how a stored value is interpreted.
### 2.3 Computing the change set
**Snapshot path (create/destroy).** Take the record's `audited_attributes()`, drop the
`non_audited_columns` (§4.3), then mask redacted and encrypted columns (§6.7–6.8). Each surviving
column is stored as a single value.
**Diff path (update).** Compute a JSON-equality diff between the *old* and *new*
`audited_attributes()` maps the caller supplied:
1. For each key, compare the old and new values by JSON equality; keep only keys whose value
actually changed. A missing old value is treated as JSON `null`.
2. Restrict to audited columns (drop `non_audited_columns`; with `only` set, the column set is
already narrowed — see §4.3).
3. Mask redacted columns → `[REDACTED]` and encrypted columns → `[FILTERED]` (§6.7–6.8).
4. Key order follows the *new* attribute map.
Dirty comparison is by **JSON value equality**: values that serialize identically produce no
change and therefore no entry (and, if nothing else changed, no audit — §6.4).
### 2.4 Stored values
Values are stored **exactly as the host's `audited_attributes()` produced them** (as
`serde_json::Value`). The crate does not introspect column types and performs no enum
normalization: if you want an enum stored as its integer discriminant rather than its label, return
the integer from `audited_attributes()`. The change set is serialized as JSON text, preserving
nested 2-element update arrays and stable key order (an ordered map is used throughout).
---
## 3. Version Numbering
- `version` is a **per-`(auditable_type, auditable_id)` monotonic counter**, 1-based.
- Computed **at write time**, inside the insert transaction:
- if `action == Create` → `version = 1` (forced, even if prior audits somehow exist).
- else → `version = (max existing version for this auditable) + 1`, with a `0` fallback when
there are no prior audits.
- Increments by 1 on **every audited action** regardless of type: create=1, update=2, destroy=3.
- **Pruning never renumbers** surviving audits (§6.6) — surviving versions keep their original
numbers (e.g. `[2, 3]`).
- **Concurrency:** version is computed inside the same transaction as the insert, and the unique
index on `(auditable_type, auditable_id, version)` rejects any duplicate produced by a concurrent
writer, so versions cannot collide under concurrent writes to the same record.
---
## 4. Configuration
### 4.1 Per-model options (`AuditOptions`)
Built with `AuditOptions::builder()` and returned from `Auditable::audit_options()`:
| `.only([..])` | unset (audit all non-ignored) | Whitelist: audit **only** these columns. Mutually exclusive with `except`. |
| `.except([..])` | unset | Blacklist: audit everything except these (beyond defaults). Applies on **create/destroy too**. |
| `.on([..])` | all actions | Which actions create audits (and which trigger comment-required checks). |
| `.comment_required(bool)` | `false` | Require a comment for audited actions (see §6.5). |
| `.update_with_comment_only(bool)` | `true` | Whether a comment alone (no attribute changes) triggers an update audit. |
| `.max_audits(n)` | global `max_audits` | Cap retained audits per record; older ones combined/pruned. |
| `.redacted([..])` | unset | Log that these columns changed, but replace values with the placeholder. |
| `.redaction_value(v)` | `[REDACTED]` | Custom placeholder for redacted columns. |
| `.encrypted([..])` | unset | Mask these columns as `[FILTERED]`. |
| `.associated_with(type)` | unset | Record a polymorphic associated/parent record on each audit. |
The audit storage class is **not** an option — persistence is the [`Backend`] passed to each audit
call.
### 4.2 Instance conditions and overrides
These are trait methods the host overrides on its model:
| `primary_key()` | `"id"` | The primary-key column name (excluded from diffs by default). |
| `inheritance_column()` | `None` | The column naming a row's concrete type (e.g. for single-table inheritance); excluded from diffs when set. |
| `audit_if(&self)` | `true` | Audit only when this returns `true`. |
| `audit_unless(&self)` | `false` | Audit only when this returns `false`. |
| `audit_associated(&self)` | `None` | Resolves the `(type, id)` of the associated/parent record at write time. |
### 4.3 Global configuration
`auditlog::config(|c| ...)` mutates the process-global `GlobalConfig`:
| `ignored_attributes` | `["lock_version", "created_at", "updated_at", "created_on", "updated_on"]` | Columns never recorded in any diff. |
| `max_audits` | `None` (unlimited) | Global retention cap per record; a per-model `max_audits` overrides it. |
| `set_auditing_enabled(bool)` / `auditing_enabled()` | `true` | Process-global master on/off switch. |
The global config is held behind an `RwLock`; the master switch is an atomic; per-type enable flags
live in a separate map (§6.12).
### 4.4 Derived column sets
- `default_ignored` = `[primary_key]` ∪ `[inheritance_column]` (if set) ∪ `ignored_attributes`.
- `non_audited_columns`:
- if `only` present → `(all_columns ∪ default_ignored) − only`
- else if `except` present → `default_ignored ∪ except`
- else → `default_ignored`
- `audited_columns` = `all_columns − non_audited_columns`.
`except` (and the default ignored set) apply on **create/destroy snapshots too**, not just updates.
---
## 5. When to Call the Audit Methods
The crate does not hook an ORM; the host calls the audit methods explicitly around its own
persistence. **Timing is load-bearing:**
| `audited_create` | **after** the DB write | The record must already have its assigned id and persisted state. |
| `audited_update(old)` | **before or after** the DB write | The caller supplies both the prior state (`old`) and the current `self`; the crate diffs them, so either ordering works as long as `old` is the pre-change state. |
| `audited_destroy` | **before** the DB write | The full snapshot must be captured **before the row is gone**. |
Each method returns `Result<Option<Audit>>`: `Ok(None)` when auditing was suppressed (disabled,
filtered out, or nothing to record), `Ok(Some(audit))` when a row was written. The `_with_comment`
variants attach a comment and enforce comment-required rules (§6.5).
---
## 6. Public API Surface
### 6.1 The `Auditable` trait
Required:
- `auditable_type() -> &'static str`
- `auditable_id(&self) -> AuditId`
- `audited_attributes(&self) -> ValueMap`
- `audit_options() -> AuditOptions`
Optional overrides: `primary_key`, `inheritance_column`, `audit_associated`, `audit_if`,
`audit_unless` (§4.2).
Provided async methods: `audited_create` / `audited_create_with_comment`, `audited_update` /
`audited_update_with_comment`, `audited_destroy` / `audited_destroy_with_comment`, plus the query
and revision methods below.
### 6.2 Querying audits
- `Type::audits(backend, id)` — all audit rows for this record, **ordered by `version` ascending**.
- `Type::query(backend, id)` — a fluent `AuditQueryBuilder` (§6.2.1).
- `Type::associated_query(backend, id)` — a query over the audits *associated with* this record.
- `Type::associated_audits(backend, id)` / `Type::own_and_associated_audits(backend, id)` (§6.11).
On an individual `Audit`:
- `audit.action`, `audit.version`, `audit.comment`, `audit.audited_changes`.
- `audit.user()` — the polymorphic record actor (when `user_id`/`user_type` set) **or** the
`username` string.
- `audit.new_attributes()` / `audit.old_attributes()` / `audit.changes()` (§2.2), interpreted
against the row's action.
- `audit.undo_plan()` — how to reverse this audit (§6.9).
#### 6.2.1 `AuditQueryBuilder`
`creates()` / `updates()` / `destroys()` (action filter), `from_version(n)` / `to_version(n)`,
`up_until(time)`, `ascending()` / `descending()`, `limit(n)` / `offset(n)`, terminated by
`fetch().await` (rows) or `count().await` (count).
### 6.3 Revisions (state reconstruction)
Reconstruct historical state by **folding change sets**. Each method returns `Revision` values
(`{ attributes, version, new_record }`) — an attribute map plus a flag, which the host applies to
its own model/ORM (the crate does not own persistence).
| `revisions(backend, id)` / `revisions_from(backend, id, from)` | One reconstructed revision per audit (from `from` onward). Empty if no audits. Seeds from audits before the window, then folds each subsequent audit's `new_attributes` on top, tagging each with its `version`. Tolerates stale keys no longer on the schema. |
| `revision(backend, id, version)` | State at a version. `revision(.., 1)` == original create state. **Out-of-range → `None`** (not an error). |
| `revision_previous(backend, id)` | The second-most-recent revision. |
| `revision_at(backend, id, time)` | Latest revision at/before `time` (`created_at <= t`); `None` if `t` precedes all audits. |
- A revision returns the folded attribute map plus `new_record`: `true` when the record was
destroyed at that point (re-applying it would re-insert the row).
- **Destroyed records** can be reconstructed — the destroy audit's full snapshot is sufficient,
even with no prior history.
### 6.4 Update write decision
`audited_update` writes an audit **unless** `(changes is empty AND (comment is blank OR
update_with_comment_only == false))`. Equivalently:
- Real changes present → **write**.
- No changes, comment present, `update_with_comment_only != false` → **write** (a comment-only audit).
- No changes, no comment → **skip**.
- No changes, comment present, `update_with_comment_only == false` → **skip**.
Comparison is by JSON value equality (§2.3): values that serialize identically produce no change.
### 6.5 Comment handling
- The `_with_comment` methods attach a comment to the audit being written.
- **Comment-required** (`comment_required == true`, gated on the action being in `on`, auditing
enabled, and at least one audited attribute actually changed): writing without a comment returns
`Err(AuditError::CommentRequired { action })`. For destroy this means the caller learns the
destroy must be aborted **before** deleting the row.
- Changing only non-audited/excluded attributes passes the comment requirement (no audited change ⇒
no requirement).
- Disabling auditing makes a write succeed without a comment.
### 6.6 `max_audits` pruning (combine)
After writing a **non-create** audit, the engine runs the combine step:
- Resolve the effective `max` (a per-model `max_audits` overrides the global one).
- If `max` is set and `extra = count - max > 0`: take the oldest `extra + 1` audits and **combine**
them:
- `target` = the **newest** audit of that group.
- `target.audited_changes` = the group's change sets merged left-to-right (**later/newer keys
win**). For consecutive update diffs this yields, per attribute, the **earliest old** and the
**latest new** (e.g. `{"name": ["Foobar","Awesome"], "username": ["brandon","keepers"]}`).
- `target.comment` is annotated: the existing comment is preserved with an appended note that the
audit is the result of multiple audits being combined.
- In a transaction: the `target` is updated and all older audits (`version < target.version`) are
deleted.
- `max == 0` is **not** a no-op: it collapses the entire history into a single combined audit on
every write.
- Surviving versions **keep their original numbers** (e.g. `[2, 3]`); pruning is not renumbering.
- On Postgres, a deadlock-class error during combine (SQLSTATE `40P01`/`40001` — a concurrent
identical combine already won) is treated as success and swallowed; all other errors propagate.
> Consequence: `audits.count` can stay flat while changes accumulate, and revision history before
> the cutoff is **lost** (not accurately reconstructable).
### 6.7 Redacted columns
- Configured via `.redacted([..])` + optional `.redaction_value(v)` (default `[REDACTED]`).
- Applied **after** computing the change shape:
- **update** → `[REDACTED, REDACTED]` (both sides replaced; the **fact** of change is kept, both
values lost).
- **create/destroy** → `REDACTED` (single value).
- A redacted column **only appears** in an update audit when it **actually changed**.
- Custom redaction values are used **verbatim** (including array values).
### 6.8 Encrypted attributes
- Columns flagged via `.encrypted([..])` are masked to the literal **`[FILTERED]`** — **distinct**
from the user-configured `[REDACTED]`.
- Redaction and encrypted-filtering share one masking routine parameterized by
`(column_list, placeholder)`: if the stored value is an array, **each element** is replaced;
otherwise the single value is replaced.
### 6.9 `undo`
`audit.undo_plan()` returns an `UndoPlan` describing how to reverse the recorded change (the host
applies it to its own store):
| `create` | `Delete` — remove the record. |
| `destroy` | `Recreate(map)` — re-insert the record from the snapshot. |
| `update` | `Restore(map)` — restore the **old** (`[0]`) values. |
### 6.10 Associated audits
- `.associated_with(type)` together with `audit_associated()` records a **polymorphic** associated
reference (`associated_type`/`associated_id`) on each audit (create, update, and destroy).
- The parent exposes:
- `associated_audits` — audits whose associated reference points at this record.
- `own_and_associated_audits` — the union of the record's own audits and its associated audits,
**ordered by `created_at` descending** (newest first).
### 6.11 Enable/disable controls (three granularities)
| Process-global | `set_auditing_enabled(bool)` | Atomic master switch. |
| Per type (persistent) | `Type::disable_auditing()` / `Type::enable_auditing()` / `Type::auditing_enabled()` | A per-type flag in a process-global map (default enabled). |
| Per scope | `without_auditing(fut).await` / `with_auditing(fut).await` | A task-local scope override, restored when the scope's future completes (including on error). |
**Effective auditing = master switch AND the type's flag AND no active `without_auditing` scope AND
the instance `if`/`unless` checks pass.**
- `without_auditing` / `with_auditing` set a scope override and restore the prior state on exit; they
nest correctly and are **task-isolated** (a scope in one task does not affect concurrent work in
another).
- `with_auditing` does **not** re-enable auditing when the global master switch is off.
### 6.12 Instance conditional check (`if`/`unless`)
Instance auditing is allowed when `audit_if(&self) == true` **and** `audit_unless(&self) == false`,
in addition to the global and per-type flags. Defaults (`audit_if → true`, `audit_unless → false`)
pass.
### 6.13 Current-user attribution
- `as_user(actor, fut).await` attributes all audits written while `fut` runs to `actor`, restoring
the prior context when the scope's future completes (including on error). Scopes nest correctly
(inner overrides, outer restored).
- `Actor::record(user_type, user_id)` sets `user_id`/`user_type` (clears `username`);
`Actor::name(string)` sets `username` (clears `user_id`/`user_type`). The two slots are mutually
exclusive; `audit.user()` returns the record actor if present, else the username string.
- `with_context(ctx, fut)` sets the full `AuditContext` (user + remote address + request id) at
once — the entry point for web middleware.
### 6.14 Other write-time context
Recorded on each audit at write time:
- `request_uuid` ← the context's `request_uuid`, else a **fresh UUID v4** (always populated).
- `remote_address` ← the context's `remote_address`, else `None` (no fallback).
### 6.15 Configuration introspection
`audit_config::<T>()` returns an `AuditConfigInfo` describing how a type is audited (audited
columns, actions, associated relation, comment-required, associated-parent flag), and
`audited_columns(all, primary_key, inheritance_column)` computes a type's audited column set. These
are the assertion helpers for tests that need to verify a model's audit configuration.
---
## 7. Behavioral Requirements Checklist
The crate **must** satisfy all of the following. Each is independently testable.
**Change set shape & serialization**
1. ☐ Create audits store a **flat map of single values** (the full filtered snapshot), never `[old,new]` pairs.
2. ☐ Update audits store `{column: [old, new]}` 2-element pairs.
3. ☐ Destroy audits store a **flat map of single values** (full filtered snapshot), like create.
4. ☐ `new_attributes`/`old_attributes` branch on action: create/destroy → value as-is; update → `[1]` / `[0]`. Tolerate legacy single-value updates.
5. ☐ Change sets serialize as JSON, preserving nested arrays and stable key order.
6. ☐ Dirty comparison is by JSON value equality; no real change ⇒ no audit.
**Versioning**
7. ☐ `version` is per-`(auditable_type, auditable_id)`, 1-based, create forced to 1, others `max+1`, computed in the insert transaction.
8. ☐ Versions increment across all action types (create=1, update=2, destroy=3).
9. ☐ Pruning never renumbers surviving versions.
10. ☐ A unique constraint on `(auditable_type, auditable_id, version)` guards against concurrency races.
**Action filtering & call ordering**
11. ☐ `audited_create` is called after the write; `audited_destroy` before it (capturing pre-deletion state); `audited_update` diffs caller-supplied old/new state.
12. ☐ `on` filters which actions write audits **and** which trigger comment-required checks.
13. ☐ Destroying a never-persisted record produces no audit and no error.
**Comments**
14. ☐ A comment alone triggers an update audit **unless** `update_with_comment_only == false`.
15. ☐ `comment_required` rejects create/update/destroy without a comment — gated on action ∈ `on`, auditing enabled, and an audited attribute actually changed — returning `AuditError::CommentRequired`.
16. ☐ The destroy comment requirement surfaces **before** the row is deleted, so the caller can abort.
**Filtering / redaction**
17. ☐ `only` and `except` are mutually exclusive; `except` applies on create/destroy too.
18. ☐ `non_audited_columns` derivation matches §4.4 exactly, including default ignored attributes, primary key, and inheritance column.
19. ☐ Redacted columns → `[REDACTED]` (update: `[REDACTED, REDACTED]`; create/destroy: single `REDACTED`); custom `redaction_value` used verbatim; a redacted column appears in an update audit only if it actually changed.
20. ☐ Encrypted columns → `[FILTERED]` (distinct from `[REDACTED]`); array-valued masked columns are masked element-wise.
**Retention**
21. ☐ `max_audits` combines the oldest `extra+1` audits into the newest of the group (merge, later keys win), deletes the rest in a transaction, and annotates the combined comment. Per-model overrides global. `max == 0` collapses all history. Postgres deadlock-class errors during combine are swallowed.
**Enable/disable & context**
22. ☐ Effective auditing = master switch AND per-type flag AND no active `without_auditing` scope AND instance `if`/`unless`.
23. ☐ `without_auditing`/`with_auditing` restore the prior state on scope exit (including on error), nest, are task-isolated, and never re-enable when the master switch is off.
24. ☐ Current-context values live in a per-task audit context.
**User attribution**
25. ☐ `as_user(actor, fut)` scopes the acting user for the future, restoring prior state on exit/error and nesting correctly.
26. ☐ The user slot is mutually exclusive: a record sets id+type & clears username; a string sets username & clears id+type.
27. ☐ Each audit captures `request_uuid` (context value or fresh UUID v4) and `remote_address` (context value or `None`).
**Revisions & undo**
28. ☐ `revisions`, `revision(n)`, `revision_previous`, `revision_at(t)` reconstruct by folding `new_attributes`; out-of-range/empty → `None`/empty.
29. ☐ Revision reconstruction tolerates stale keys and reports `new_record` for destroyed states.
30. ☐ Destroyed records reconstruct (even with no prior history); re-applying re-inserts the row.
31. ☐ `undo_plan` reverses by action (create→`Delete`, destroy→`Recreate`, update→`Restore` old).
**Associated audits**
32. ☐ `associated_with` + `audit_associated` record the polymorphic associated record on create/update/destroy; `associated_audits` and `own_and_associated_audits` (created_at desc) are exposed.
---
## 8. Design Notes
| **The context store** — per-request, task-isolated, auto-reset. | `tokio::task_local!` holding an `AuditContext`; scope-based cleanup resets it automatically when the scoped future ends. A `thread_local` would leak across the pooled worker threads of an async runtime. |
| **`AuditContext` fields.** | `AuditContext { user: Option<Actor>, remote_address: Option<String>, request_uuid: Option<String>, scope_override: Option<bool> }`. |
| **Populating the context per request.** | Web middleware extracts remote IP, request id, and the authenticated user, then runs the inner work inside `with_context(ctx, ...)`. Cleanup is automatic when the scoped future ends. |
| **`as_user` / `without_auditing` / `with_auditing`.** | Scope functions that set a value, run the supplied future, and restore the prior value on completion (including on error); they nest and are task-isolated. |
| **Polymorphic references** (`(id, type)` pairs). | Each polymorphic ref is a `(Option<AuditId>, Option<String>)` pair. `Actor` is `Record { user_type, user_id } | Name(String)`. Resolving a polymorphic id back to a concrete type is the host's job (the crate does not own loading). |
| **The audit store.** | Persistence is the `Backend` trait. `SqlxBackend` (SQLite & Postgres) and an in-memory `MemoryBackend` ship with the crate; any other store implements the same six methods. |
| **Serialization.** | Change sets are JSON (`serde_json::Value`), stored as text, with stable key order via an ordered map. |
| **Stored values.** | Stored exactly as `audited_attributes()` produces them; there is no enum/column-type introspection step. |
| **The `action` column.** | A strong `Action` enum `{ Create, Update, Destroy }` with `Display`/`FromStr` for the `"create"/"update"/"destroy"` column values (legacy `"touch"` parses to `Update`). |
| **Global config.** | A process-global `GlobalConfig` behind an `RwLock`, an atomic master switch, and a per-type enable map. |
| **Version races.** | Version is computed inside the write transaction; a unique constraint on `(auditable_type, auditable_id, version)` rejects duplicates. |
| **`request_uuid` fallback.** | The context value, else a fresh `Uuid::new_v4()`. |
| **Combine best-effort on Postgres.** | The prune step catches the deadlock error class and continues; other errors propagate. |
---
### Appendix A — Default constants
- `ignored_attributes` = `["lock_version", "created_at", "updated_at", "created_on", "updated_on"]`.
- `default_ignored` additionally includes the primary key and (when set) the inheritance column.
- Redaction placeholder = `"[REDACTED]"`; encrypted-filter placeholder = `"[FILTERED]"`.
- `update_with_comment_only` default = `true` (only literal `false` disables comment-only update audits).
- `auditing_enabled` default = `true`; `max_audits` default = unlimited.
---
This spec is exhaustive. The most error-prone areas to implement carefully are, in order:
**(1) the per-action change-set shape (§2)**, **(2) call ordering for create/update/destroy (§5)**,
**(3) the update write decision with comment interplay (§6.4)**, **(4) enable/disable precedence and
task-local restoration (§6.11)**, and **(5) `max_audits` combine semantics (§6.6)**.