# Audit Log Runbook

Operator-facing procedures for the `_System_AuditLog` pipeline. Architecture
and rationale live in [`design.md`](./design.md); this file is a flat checklist
for on-call engineers. For the design spec this operates under, see
[`design.md`](./design.md) — especially the Implementation map.

## Table of Contents

- [Outbox worker ops](#outbox-worker-ops) — design §3.2, §3.3, §3.4
- [FLS redaction semantics](#fls-redaction-semantics-41-ch-6) — design §4.1 (CH-6)
- [EXPORT_AUDIT meta-audit](#export_audit-meta-audit) — design §4.3 (CH-8)
- [Request capture](#request-capture-144--146--1411) — design §14.2, §14.3, §14.4, §14.6
- [Using the replay script](#using-the-replay-script) — design §14.5, §14.8
- [See also](#see-also)

---

## Feature flags & wiring defaults

The audit-v2 emit path, the outbox worker, and request capture are all
**default-off** after C17.3. Operators opt in explicitly once each
subsystem's prerequisites are met.

> **Migration note (PR #1850):** `AUDIT_V2_ENABLED` default flipped from
> `true` to `false` during this PR, after the 4-quadrant review flagged
> that V2 emit without the MySQL `OutboxStore` + worker produces empty
> `<Object>History` (head rows are filtered out by the SOQL path per
> §5.1; only field rows — produced by the worker — satisfy history
> reads). Deployments that relied on the prior default will need to set
> `AUDIT_V2_ENABLED=true` explicitly **after** a MySQL `OutboxStore`
> implementation is wired in `cmd/server`. See the rollout sequence
> below.

| Env var                              | Default | Enables what                                                     | Prerequisites before flipping to true                                                                                                                                                                                                             |
|--------------------------------------|---------|------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `AUDIT_V2_ENABLED`                   | `false` | PersistenceService emits head + outbox rows via `DBEmitter`.      | `_System_AuditOutbox` migration applied; a MySQL `OutboxStore` is wired in `cmd/server`; `AUDIT_V2_WORKER_ENABLED=true` (or a comparable fan-out path). Turning V2 on WITHOUT the worker makes `<Object>History` queries look empty because §5.1 filters `FieldName='__record__'` head rows — only field rows (produced by the worker) satisfy history reads. |
| `AUDIT_V2_WORKER_ENABLED`            | `false` | Auto-starts `OutboxWorker` alongside `cmd/server`.               | MySQL `OutboxStore` wired; DB credentials available; shutdown ctx plumbed.                                                                                                                                                                         |
| `NEXUSCRM_REQUEST_CAPTURE`           | `false` | Capture middleware writes rows to `_System_RequestCapture`.      | `_System_RequestCapture` schema present; retention policy decided (§14.7).                                                                                                                                                                         |
| `NEXUSCRM_REQUEST_CAPTURE_RESPONSE`  | `false` | Permits response-body capture for routes explicitly opted in.    | Blanket capture env on; `audit.EnableResponseCapture("/route")` called at startup.                                                                                                                                                                 |
| `NEXUSCRM_REQUEST_CAPTURE_STRICT`    | `false` | Drops capture rows for non-scrubbable content types (§14.3).     | None; safe to enable anywhere capture is on.                                                                                                                                                                                                       |

Rollout sequence for audit v2:
1. Apply migration; confirm `_System_AuditOutbox` exists.
2. Deploy code with `AUDIT_V2_ENABLED=false` (default). No behavior change.
3. Enable `AUDIT_V2_WORKER_ENABLED=true`. Worker idle — outbox stays empty.
4. Enable `AUDIT_V2_ENABLED=true`. Writes flow; worker drains; `<Object>History` lights up.
5. Monitor backlog / P99 lag (see below); rollback path is `AUDIT_V2_ENABLED=false`.

## Outbox worker ops

Design ref: §3.2 (sync/async split), §3.3 (atomicity + poison taxonomy), §3.4 (failure policy). Cross-ref: triple-test acceptance evidence in [`triple-test-aggregate.md`](./triple-test-aggregate.md).

### What the worker does

Drains rows from `_System_AuditOutbox` into field-level rows on
`_System_AuditLog`. One payload row in the outbox expands to N field rows,
one per changed field plus any §3.6 sentinels. Dedup is enforced by the
`uk_outbox(OutboxRowId, FieldName, ChangedAt)` unique key via `INSERT IGNORE`
— at-least-once delivery is safe.

### Lifecycle / startup

The worker is defined in `backend/internal/features/audit/outbox_worker.go`.
At the time this runbook was written the worker is **not** auto-started by
`backend/cmd/server`; wiring lands in a later commit. To start it manually
when wiring is in place, pass a `*MySQLOutboxStore` (thin adapter over the
existing `*sql.DB`) into `audit.NewOutboxWorker` and call `Run(ctx)` in a
goroutine bound to the server shutdown context.

Graceful shutdown is ctx-driven: cancel the parent ctx and the current poll
finishes its batch before returning. In-flight rows that were leased but not
yet finalized are retried automatically when their lease expires (default
30 s) — no manual intervention required.

### Config knobs (defaults in `OutboxWorkerConfig.withDefaults`)

| Knob | Default | When to change |
|---|---|---|
| `BatchSize` | 100 | Raise to 500 if sustained backlog; lower to 20 if single-row InsertFieldRows latency spikes. |
| `PollInterval` | 250 ms | Raise to 1 s to cut idle DB load on small orgs; lower below 100 ms only if you see scheduler-starved lag and confirm the DB can take it. |
| `LeaseDuration` | 30 s | Raise if slow networks or heavy batches cause lease-expiry races (rows being picked up by a second worker before the first finishes). Lower only for latency-insensitive, high-crash-rate operators. |
| `MaxAttempts` | 5 | Raise for environments with frequent transient blips and tolerant SLOs; never lower below 3 (first attempt often races startup). |
| `HolderID` | `"outbox-worker"` | Set to `<hostname>-<pid>` in production so lease contention is diagnosable via `SELECT LeaseHolderId` queries. |

### What to monitor

1. **Backlog** — `SELECT COUNT(*) FROM _System_AuditOutbox WHERE ProcessedAt IS NULL AND PoisonClass IS NULL`
   - `> 1000`: P1 alert (design §3.3).
   - `> 10 000`: UI shows "audit system busy" banner; escalate.
2. **P99 fan-out lag** — `SELECT PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY TIMESTAMPDIFF(MICROSECOND, EnqueuedAt, ProcessedAt)) / 1e6 FROM _System_AuditOutbox WHERE ProcessedAt IS NOT NULL AND EnqueuedAt > NOW() - INTERVAL 1 HOUR`
   - SLO: P99 < 5 s, P99.9 < 60 s.
3. **Poison rows by class** — `SELECT PoisonClass, COUNT(*) FROM _System_AuditOutbox WHERE PoisonClass IS NOT NULL GROUP BY PoisonClass`
   - Any `PARSE_FAIL` → page immediately (emitter bug).
   - Any `FK_VIOLATION` → P1 (data integrity).
   - `RETRY_EXHAUSTED > 0` trending up → investigate transient error source.
   - `UNKNOWN > 2` → page (classifier gap).
4. **Attempts histogram** — `SELECT Attempts, COUNT(*) FROM _System_AuditOutbox WHERE ProcessedAt IS NULL GROUP BY Attempts`
   - Bimodal (0 + 4-5) is expected; a pile at exactly 5 without RETRY_EXHAUSTED set means the worker is not running or not reaching that code path.
5. **Stale leases** — `SELECT COUNT(*) FROM _System_AuditOutbox WHERE ProcessedAt IS NULL AND LeaseExpiresAt < NOW(6)`
   - Expected to be ~0 during steady state; a non-zero sustained value means workers are dying mid-batch. Cross-check against `LeaseHolderId` distribution to find the flapping instance.

### Handling a poison row

1. Inspect the row: `SELECT Id, PoisonClass, LastError, Attempts, LEFT(Payload, 400) FROM _System_AuditOutbox WHERE Id = ?`
2. Identify root cause by class:
   - `PARSE_FAIL` — emitter produced malformed JSON. Fix the emitter bug, deploy.
   - `FK_VIOLATION` — `ChangedById` (or similar FK) references a deleted row. Confirm whether the parent actually existed; if it was cleaned up illegitimately, restore. If it was correctly removed but audit kept a dangling reference, re-classify as data-retention acceptable.
   - `RETRY_EXHAUSTED` — underlying transient source persisted past the retry budget. Check DB logs for deadlock patterns or connection saturation.
   - `UNKNOWN` — classifier gap. Read `LastError`, add a case to `audit.ClassifyError`, land a test, deploy, THEN reset this row.
3. After fixing root cause, retry ONE row:
   ```sql
   UPDATE _System_AuditOutbox SET PoisonClass = NULL, LastError = NULL, Attempts = 0 WHERE Id = ?;
   ```
   The next worker poll will pick it up.
4. Or archive (give up on this row): leave `PoisonClass` non-NULL; the row is excluded from the poll index and retained for compliance audit trail.

### Forbidden manual operations

| Action | Why forbidden |
|---|---|
| `UPDATE _System_AuditOutbox SET ProcessedAt = NOW(6) WHERE Id = ?` without also inserting the expanded field rows on `_System_AuditLog` | Silently destroys field-level history — worse than leaving the row pending. |
| `DELETE FROM _System_AuditOutbox WHERE ProcessedAt IS NOT NULL` | Even processed rows are the durable audit trail reconstruction source. Use a retention policy (out of scope of this runbook), not ad-hoc deletes. |
| Running two workers with the same `HolderID` | Lease attribution becomes ambiguous; lease-expiry race analysis breaks. |
| Setting `PoisonClass = 'RETRY_EXHAUSTED'` manually | Worker-assigned class; manual value undermines the Attempts budget semantics. If you want to stop a row, set `PoisonClass = 'UNKNOWN'` with an operator note in `LastError`. |

### Backlog drain procedure (>10 000 rows)

1. Scale up: raise `BatchSize` to 500 temporarily (redeploy with config override).
2. Confirm DB is not the bottleneck: check `_System_AuditLog` INSERT latency — should be sub-ms on indexed inserts.
3. If DB is healthy and lag persists, run a second worker instance (distinct `HolderID`) — the lease primitive guarantees no double-process.
4. After backlog < 1 000, revert to defaults.

### Emergency stop

Cancel the worker's parent ctx (deploy a build with the worker disabled, or restart without it). In-flight leased rows auto-release when `LeaseExpiresAt` passes; no manual cleanup required. Outbox is durable — the business txn already committed and enqueued; nothing is lost.

---

## FLS redaction semantics (§4.1, CH-6)

Design ref: §4.1 (CH-6 FLS plan-stage redaction), §5.1 (`<Object>History` caller).

Field-Level Security on `<Object>History` queries is enforced at the
**query-plan stage**, not at row-format time. Row-format redaction leaks
the value via `WHERE OldValue LIKE '...'` / `ORDER BY OldValue` side
channels (binary search on the denied value even though it never appears
in the response body). The invariant is implemented in
`backend/internal/features/audit/fls_plan.go`.

### Invariant

A user who lacks FLS Read on field `F` cannot probe `F`'s OldValue or
NewValue through any SOQL predicate — LIKE, equality, comparison. The
filter literal never reaches the database.

### Client-facing behavior

| Caller intent on denied field `F` | Response |
|-----------------------------------|----------|
| `SELECT OldValue WHERE FieldName = 'F'` only | 200 — zero rows for `F` (projection dropped, suppression predicate injected) |
| Any predicate on `F`'s `OldValue` / `NewValue` | **400 `FIELD_DENIED_BY_FLS`**, message `field F is denied by FLS` |
| Predicate on metadata (`FieldName`, `ChangedAt`, `ChangedById`) | 200 — always allowed |

Never 500, never a message echoing the user's filter literal (that would
itself be a leak channel). Clients switch on the `FIELD_DENIED_BY_FLS`
code, not on the message text.

### Internal behavior

- **Projection drop.** The field is silently removed from the SELECT list
  **and** a `FieldName != F` predicate is injected so no history rows for
  `F` surface. The caller does not learn whether `F` exists or whether
  any history was recorded for it.
- **Predicate reject.** A `PredicateValue` on `OldValue` / `NewValue`
  against a denied field fails fast *before* the planner touches
  projections — deterministic error ordering even when the plan has both
  a droppable projection and a rejectable predicate.

### Unblocking a user

Grant FLS Read on `F` — either on the user's Profile or via a
PermissionSet that adds a `FieldPermissions` row with
`PermissionsRead = true` for `(Object, F)`. No code change, no deploy.
The planner does a runtime lookup on every query.

### What this module does NOT do

- Does not parse SOQL. C11's `<Object>History` SOQL translator produces
  the `HistoryQueryPlan` struct this module operates on.
- Does not enforce `ViewAllAuditTrail` / `ViewSelfAuditTrail` — those
  live in the query service (§4.0). This module only handles per-field
  FLS.

## EXPORT_AUDIT meta-audit

Design ref: §4.3 (CH-8 meta-audit on bulk admin reads), §5.2 (admin API surface).

Design §4.3 (CH-8): when an admin bulk-reads the audit log itself, we
record a single `EXPORT_AUDIT` row so silent exfiltration of the audit
trail leaves a trace. This is "audit-the-audit" — compliance
observability on privileged access to the observability store.

### Configuration

| Env var | Default | Purpose |
|---|---|---|
| `AUDIT_EXPORT_META_ENABLED` | `false` | Master switch. Must be `true` (or `1`) to emit any meta-audit row. |
| `AUDIT_EXPORT_META_THRESHOLD` | `100` | Minimum row count returned by `GET /admin/audit/log` to qualify as a bulk export. Values below are treated as incident-forensics lookups and not recorded. |

Only `GET /admin/audit/log` (the native admin API) is instrumented.
Per-record `<Object>History` SOQL reads (design §5.1) are NOT
instrumented — those are per-record views, not bulk exports.

### Failure policy

Meta-audit emission runs AFTER the export response has been sent. An
emit failure (DB down, outbox write error, commit error) is WARN-logged
and SWALLOWED — the admin already received their legitimate response,
and compliance observability must not break a privileged admin action.
Monitor the `EXPORT_AUDIT meta-audit: *` WARN log for drop rate.

### Querying meta-audit events

```sql
-- Last 50 bulk audit-log exports
SELECT Id, ChangedById, ChangedAt, EntrypointDetail
FROM _System_AuditLog
WHERE Action = 'EXPORT_AUDIT'
ORDER BY ChangedAt DESC
LIMIT 50;
```

`EntrypointDetail` carries a compact JSON blob
(`{"object":..., "recordId":..., "limit":..., "offset":..., "rows":...}`)
with the filter parameters and returned row count. `ObjectApiName =
'_System_AuditLog'` (self-reference); `RecordId` is a SHA-256 short
hash of the filter params (not a real record id) so repeat identical
exports by the same admin correlate.

### Operational uses

- **Weekly compliance report** — export counts grouped by
  `ChangedById` to surface unusual export volume per admin.
- **Anomaly detection** — alert when a single user emits >10
  EXPORT_AUDIT events per hour, or when an account outside the
  compliance team's identity list emits any.
- **Post-incident forensics** — trace which admin queried a given
  RecordId's history when investigating access to sensitive records.

### FLS tradeoff

The recorded `EntrypointDetail` JSON contains the raw `object` and
`recordId` query parameters. These MAY reference an object or record
whose full field set a non-admin caller could not read. Admin API
callers hold the `ViewFieldAuditTrail` (or equivalent) privilege which
is the documented grant to bypass FLS for audit investigation, so
recording filter literals verbatim is acceptable and consistent with
how the handler itself returns un-redacted rows (§5.2).

Ops tooling ingesting `_System_AuditLog` should therefore be treated
as carrying the same privilege level as `ViewFieldAuditTrail`.

## Request capture (§14.4 / §14.6 / §14.11)

Design ref: §14.2 (schema), §14.3 (PII scrub), §14.4 (middleware), §14.6 (post-commit best-effort), §14.7 (retention + GDPR), §14.11 (env-gate opt-in).

`_System_RequestCapture` records inbound REST traffic so the replay
toolchain (`scripts/audit-replay/`) can reconstruct traffic against a
refactored backend and assert `_System_AuditLog` field-level
equivalence (§14.5). The middleware writing to it is gated on env.

### Enabling

Default: **OFF**. Middleware is a no-op, zero rows are written.

To enable:

```
export NEXUSCRM_REQUEST_CAPTURE=on   # accepts on/true/1/yes
```

Restart the backend. Each inbound REST request now writes one row to
`_System_RequestCapture` post-handler (best-effort — see failure
policy below).

### Response-body capture (double-gated)

Even with capture enabled, the response body is NOT stored. To opt in
for specific routes:

1. Set the blanket kill-switch off-switch:
   `export NEXUSCRM_REQUEST_CAPTURE_RESPONSE=on`
2. In code, call at startup:
   `audit.EnableResponseCapture("/services/data/v63.0/sobjects/Lead")`
   (exact-path match; call once per route).

Both gates must be true for a given route to have its response body
persisted. Rationale: blanket response capture doubles storage and
re-introduces PII risk (§14.4 / §14.7); route-by-route is the only
sanctioned path.

### Body size cap

Request bodies are buffered up to **1 MiB** (`DefaultBodyCapBytes`).
Larger bodies are truncated at the cap; `BodyTruncated=true` flags the
row and `BodySize` records the raw pre-scrub size for monitoring.
Response-body capture uses the same cap.

### Path length cap

`_System_RequestCapture.Path` is `VARCHAR(512)` — the InnoDB index-key-
length limit with utf8mb4 on the `idx_path` secondary index. Requests
with URL paths longer than 512 chars are WARN-logged at capture-write
time and the capture row is dropped (the request itself still succeeds).
Current route vocabulary in `backend/internal/interfaces/rest/**` maxes
at ~150 chars, so this is theoretical today but matters for deployments
that register custom endpoints with long path templates.

### Replay traffic is not re-captured

Requests carrying an `X-Replay-RequestId` header (issued by
`cmd/replay`) are skipped by the capture middleware and NOT written to
`_System_RequestCapture`. Without this short-circuit, single-DB replay
would re-capture its own POSTs, and the next `cmd/replay` invocation
over the same time window would re-replay those rows with a second-
level RequestId suffix — producing exponential capture-table growth on
repeat runs. The skip is unconditional; there is no way to re-capture
replay traffic (by design).

### SessionId provenance

The `SessionId` column on `_System_RequestCapture` is sourced from the
**validated JWT's `jti`** (`claims.RegisteredClaims.ID`), NEVER the raw
bearer token. A DB dump of the capture table therefore cannot yield
replayable credentials. Auth middleware exposes the jti via the
`constants.ContextKeySessionID` gin-context key; capture middleware reads
that key and never touches the `Authorization` header value.

### PII handling

- Header values for the §14.3 header blacklist (`Authorization`,
  `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Session-Token`,
  `Proxy-Authorization`, `X-Csrf-Token`, `X-Salesforce-Session`) are
  replaced with `***SCRUBBED***`.
- URL query strings are walked through `ScrubQueryString`; keys
  matching `PIIBodyBlacklist` (e.g. `token`, `password`, `apiToken`)
  are redacted before persistence. Safe params pass through.
- JSON-content-type request / response bodies walk through
  `ScrubJSONPayload`; keys matching `PIIBodyBlacklist` are redacted.
- `application/x-www-form-urlencoded` bodies walk through
  `ScrubFormBody`; same blacklist.
- XML / `text/plain` / `multipart/*` bodies are still stored verbatim
  within the 1 MiB cap (Phase 2B scrub work).

#### Content-type scrub coverage

| Content-Type                           | Treatment        |
|----------------------------------------|------------------|
| `application/json` (+ any `*/json`)    | `ScrubJSONPayload` |
| `application/x-www-form-urlencoded`    | `ScrubFormBody`    |
| `application/xml`, `text/xml`          | Pass-through (Phase 2B TODO) |
| `multipart/form-data`                  | Pass-through (Phase 2B TODO) |
| `text/plain`, binary                   | Pass-through (Phase 2B TODO) |

Operators note: absent strict mode, enabling capture on routes that
accept XML / multipart can persist PII fields in those bodies pending
Phase 2B work. Either avoid capture on those routes or enable strict
mode below.

#### Strict mode (opt-in)

To refuse capture entirely for non-scrubbable content types rather
than storing them raw:

```
export NEXUSCRM_REQUEST_CAPTURE_STRICT=on
```

In strict mode, requests with XML / multipart / text/plain bodies
cause the capture row to be DROPPED (not written) — the business
handler still runs unaffected. JSON and form-urlencoded requests are
captured (and scrubbed) as normal. Default off to preserve backward
compat with existing capture deployments.

### Failure policy (§14.6)

The INSERT runs post-handler in a goroutine with a 5s timeout. On
failure the middleware WARN-logs:

```
request_capture: DB write failed; request already completed
```

and swallows the error. The business request's HTTP response is never
affected — this is by design (§14.6) so the capture path is an
observability tool, not a new failure mode.

### Correlating with AuditLog

`_System_RequestCapture.RequestId` equals `_System_AuditLog.RequestId`
for the same HTTP request (§14.9). To find the captured HTTP context
for a given AuditLog entry:

```sql
SELECT rc.*
FROM _System_RequestCapture rc
JOIN _System_AuditLog al ON al.RequestId = rc.RequestId
WHERE al.Id = '<audit-log-id>';
```

`TraceId` / `SpanId` on the AuditLog side come from the tracing
middleware and can be used to correlate with external-system logs
(SES / HubSpot / …).

### Retention and storage

Default retention matches `_System_AuditLog` at 18 months. Capacity
estimate per §14.7: ~180K requests/day × 2KB scrubbed = ~190 GB /
18 months on hot storage. TiFlash tiering (Phase 2B) is the
medium-term answer; in the short term, tenants that only need replay
for a focused refactor should follow an "enable → exercise →
disable → archive" pattern rather than leaving capture on.

### GDPR right to erasure

Per §14.7, user-level erasure is served by:

```
cmd/setup erase-user --user-id=<id>
```

which executes `UPDATE _System_RequestCapture SET Body=NULL,
Headers=NULL, Query=NULL WHERE UserId=<id>` while preserving the row
headers (RequestId / timestamp / path / status) for audit
traceability. The operation itself writes a meta-audit record to
`_System_SetupAuditTrail`.

## Using the replay script

Design ref: §14.5 (replay contract — Tolerant-mode AuditLog equivalence), §14.8 (replay toolchain: RecordId rewrite, re-auth, idempotency).

> For the deep-dive reference — full pipeline diagram, CLI flag table, report
> schema, diff-kind taxonomy, known limitations, and live-evidence index —
> see [`replay-tool.md`](./replay-tool.md).

The `cmd/replay` binary is an ops/dev tool for behavior-equivalence
validation (design §14.5, §14.8). It reads captures from
`_System_RequestCapture` and the audit rows they produced in
`_System_AuditLog`, and — optionally — re-issues each captured request
against a target backend to diff "expected vs replay" audit trails.

### Build and flags

```
cd backend && go build -o /tmp/replay ./cmd/replay

/tmp/replay \
  --db-dsn='user:pw@tcp(host:4000)/nexuscrm?parseTime=true' \
  --since=2026-04-24T00:00:00Z \
  --until=2026-04-24T01:00:00Z \
  --diff-output=/tmp/replay.json \
  --limit=500 \
  --mode=tolerant
```

Flags:
- `--db-dsn` (required): source DB — holds both `_System_RequestCapture`
  and the corresponding `_System_AuditLog` rows.
- `--since`, `--until`: ISO-8601 window over `ReceivedAt`. Omit
  `--since` for "no lower bound"; omit `--until` for "now".
- `--request-id`: narrow to one capture (debug one request).
- `--user-id`: narrow to one actor.
- `--mode`: `tolerant` (default; ignores `Entrypoint` detail per §14.5)
  or `strict` (byte-identical on every field).
- `--limit`: max captures processed (default 100).
- `--diff-output`: path for the JSON report (default stdout).
- `--target-dsn` + `--target-url` + `--i-understand-this-writes`:
  enables replay-to-target. See next section.

### Dry-run (the default)

With no `--target-dsn`, the tool is read-only: it loads captures,
loads their audit rows, and writes an "Expected" listing per capture.
Useful for:
- Compliance-style audit reports ("what happened between X and Y?").
- Confirming capture coverage before launching a real replay.
- Reproducing a user-reported bug by inspecting the exact captured body.

Sample output (abbreviated):

```json
{
  "generated_at": "2026-04-24T12:34:56Z",
  "mode": "tolerant",
  "dry_run": true,
  "entries": [
    {
      "request_id": "req-abc",
      "method": "POST",
      "path": "/services/data/v63.0/sobjects/Lead",
      "expected": [
        {"ObjectApiName":"Lead","RecordId":"001","FieldName":"__record__","Action":"CREATE"}
      ],
      "diff": {"Match":true,"Mode":"tolerant","ExpectedSize":1,"ReplaySize":1}
    }
  ],
  "summary": {"captures":1,"matched":1,"mismatched":0,"skipped_no_audit":0,"http_errors":0}
}
```

### Replay-against-target

With `--target-dsn` + `--target-url` + `--i-understand-this-writes`,
the tool re-POSTs each captured request against `target-url`, waits
for the target backend to write its own audit rows, then diffs the
two trails. Intended for refactor-equivalence validation (§14.1).

**Required target:** a non-production, data-isolated backend
(dedicated branch or sandbox). Re-POSTing old captures to production
will mutate live records. `--i-understand-this-writes` is a literal
acknowledgement gate, not a style choice.

### Caveats (read before use)

- **PII scrubbed at capture.** The capture middleware hardcodes a PII
  blacklist (§14.3). Fields the scrubber redacted cannot be re-sent;
  a request that depended on a redacted field (password reset,
  credit-card, …) will fail on the target. Privileged un-scrubbed
  replay is a future RFC (secrets vault); not implemented.
- **Non-idempotent.** POSTing a `CREATE` twice creates two records.
  Replay to a fresh target DB, or document the expected duplicate
  shape in the diff report.
- **Non-JSON bodies are skipped.** The diff report records
  `replay_skipped: "non-JSON body…"` for those entries.
- **RecordId / auth rewrite (§14.8) is not implemented.** Multi-step
  tapes (where a later PATCH references a record created by an
  earlier POST) will 404 on the PATCH. This MVP targets the
  single-request diff case; multi-step replay is Phase 2B.
- **Orphan captures** (capture row exists, no audit rows) are
  reported under `summary.skipped_no_audit`. Typical causes: the
  request was captured but the business handler returned before
  emitting an audit event.

## See also

- Schema definition: `system_tables.json` — `_System_AuditOutbox` section.
- Schema definition: `system_tables.json` — `_System_RequestCapture` section.
- Design: `docs/audit-log/design.md` §3.2 (sync/async split), §3.3 (atomicity + poison taxonomy), §3.4 (failure policy), §4.1 (FLS CH-6), §4.3 (EXPORT_AUDIT CH-8), §14 (replay capture), §14.5 (replay contract), §14.8 (replay toolchain).
- Code: `backend/internal/features/audit/outbox_worker.go`, `poison.go`, `emitter_db.go`, `fls_plan.go`, `pii_scrub.go`, `request_capture_middleware.go`, `backend/internal/features/audit/replay/`, `backend/cmd/replay/main.go`, `backend/internal/interfaces/rest/admin_audit_meta.go`.
