auditlog 0.1.0

Audit trail for your data models — an ORM-agnostic core with a pluggable, async sqlx backend (SQLite & Postgres).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
# `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):

| 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

| Column | Type | Null | Default | Purpose |
|---|---|---|---|---|
| `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).

| Index | Columns (in order) | Notes |
|---|---|---|
| `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

| Action | Shape | Example |
|---|---|---|
| **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`

| Method | Create/Destroy | Update |
|---|---|---|
| `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()`:

| Builder method | Default | Controls |
|---|---|---|
| `.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:

| Method | Default | Controls |
|---|---|---|
| `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`:

| Field / function | Default | Controls |
|---|---|---|
| `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:**

| Action | When to call | Why |
|---|---|---|
| `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).

| Method | Behavior |
|---|---|
| `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):

| Action | `UndoPlan` |
|---|---|
| `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)

| Scope | API | Mechanism |
|---|---|---|
| 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

| Concern | Approach |
|---|---|
| **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)**.