ADR-010: Audit-Chain Default - Cost Measurement and Migration Path¶
Status: Accepted Date: 2026-07-24 Context: Bernstein audit log (src/bernstein/core/security/audit.py), issue #2690
Problem¶
The lineage spine and the replay journal are always on. The HMAC-chained audit log is not: it is enabled by --audit, BERNSTEIN_AUDIT=1, or a compliance preset (src/bernstein/core/orchestration/orchestrator.py, the audit-enable branch around line 814). An operator who never passes --audit gets reproducible runs and a lineage record, but no tamper-evident chain, and the gap only becomes visible when someone asks for one after the fact.
Making the chain on-by-default is defensible, but it must not be an unmeasured guess. Before changing the default we need three things: the cost of enabling the chain, a check that nothing depends on its absence, and a migration path that never reports a chain-less workspace as tampered.
Measurement¶
A hermetic micro-benchmark drives AuditLog directly - no network, no orchestrator, no real adapters - with a fixed key and synthetic scheduling-decision payloads: scripts/bench_audit_chain.py. Reproduce with:
Append compares a real AuditLog.log (chain-on) against the same canonical row written straight to a JSONL file with no prev_hmac/hmac and no chain-tail recovery (plain-append). The gap is the marginal cost attributable to the chain, on top of a line an audit-logging run would write anyway. Verify measures a full verify(), a cold scan_verified(), and a warm (cursor) scan_verified() re-scan after one more append.
Representative figures (Python 3.14, macOS, a loaded dev host - wall-clock numbers are host-dependent; the ratios and byte counts are not):
Append latency (per event)¶
| entry size | chain-on mean | chain-on p95 | plain-append mean | chain marginal |
|---|---|---|---|---|
| small | ~80 us | ~98 us | ~31 us | +~49 us |
| medium | ~81 us | ~97 us | ~33 us | +~48 us |
| large | ~94 us | ~113 us | ~36 us | +~57 us |
Bytes written per entry¶
| entry size | chain-on | plain-append | chain overhead |
|---|---|---|---|
| small | 376 B | 219 B | +157 B |
| medium | 502 B | 345 B | +157 B |
| large | 1603 B | 1446 B | +157 B |
Verify / scan throughput¶
| events | segments | verify (events/s) | cold scan (events/s) | warm-cursor tail |
|---|---|---|---|---|
| 1000 | 1 | ~72,000 | ~56,000 | ~99 us |
| 10000 | 1 | ~74,000 | ~56,000 | ~94 us |
| 9000 | 30 | ~75,000 | ~55,000 | ~367 us |
| 9000 | 90 | ~75,000 | ~54,000 | ~947 us |
Reading the numbers¶
- Append cost is negligible per decision. The chain adds roughly 50 us on top of a plain log write. A deterministic orchestrator emits scheduling and lifecycle events at human-review timescales, not in a tight loop; even at hundreds of decisions per second the chain is well under a millisecond of aggregate overhead.
- Byte overhead is constant, not proportional. The chain adds a fixed +157 B/entry - the two 64-hex chain fields (
prev_hmac+hmac) plus their JSON framing - regardless of payload size. Growth of the chain file is dominated by the payload an operator chose to log, not by the chain. A run that emits 10,000 audit events pays about 1.5 MB for tamper-evidence. - Verify is linear and fast. ~72-75k events/s, flat across total events and segment count. A full offline verify of a day's chain is sub-second.
- Warm re-verification is cheap but scales with segment count. A cursor scan re-reads only appended bytes, but still stats every segment: ~99 us at one segment rises to ~950 us at 90. In practice a single run writes one live segment and retention caps live segments at 90 days, so this is bounded; it is recorded as a follow-up below.
What breaks if it is on by default¶
A targeted review of the audit test surface (the full suite is not run here by policy):
- No golden snapshot or replay fixture depends on the chain being absent. The snapshot test (
tests/snapshot/test_audit_jsonl_snapshot.py) pins the on-disk format when the chain is present; nothing asserts.sdd/auditis missing or thatAuditLogis unconstructed by default. - No orchestrator-level test asserts audit-off-by-default (
_audit_log is None/_audit_modeis unset). The default lives only in the enable branch. - The one real behavioral change of flipping the default is operational, not a test breakage: every run would create
.sdd/audit/and generate an audit key on first boot. That is the surface the migration path below manages.
Confirmation step before any flip: run the audit and orchestrator test layers with the default flipped, behind the flag, in CI - not a claim to make from a static scan alone.
Decision¶
Keep the chain opt-in in this release. Adopt on-by-default as the target once the migration steps below are in place. This PR lands the measurement, the migration-safety invariant (locked by tests), and this record; it does not flip the default.
The measurement removes cost as an objection: append overhead is sub-millisecond and byte overhead is a constant 157 B/entry. The reason not to flip the default inside a measurement change is scope and operational rollout (key generation on existing installs), not performance. The flip is a small, separate follow-up that changes only default resolution and adds a louder first-run notice.
Of the three outcomes the issue admits - default-on, default-on above a run size, stay opt-in with a louder prompt - a run-size threshold is rejected: the per-decision cost does not vary enough with run length to justify a threshold, and a threshold would make "is this run audited?" depend on a size heuristic that is itself hard to audit.
Migration path to on-by-default¶
Enable-surface precedence (env / config)¶
Target resolution order, highest precedence first:
- Explicit opt-out -
--no-audit/BERNSTEIN_AUDIT=0(to be added with the flip). An operator who turns it off is never overridden by the default. - Explicit opt-in -
--audit/BERNSTEIN_AUDIT=1(the--auditflag sets the env var; seesrc/bernstein/cli/run_bootstrap.py). - Compliance preset -
audit_loggingon a preset selected via--compliance/BERNSTEIN_COMPLIANCE. A preset may force the chain on; it does not force it off against an explicit opt-in. - The default - opt-in today, opt-out (on) after the flip.
An explicit env var or flag always wins over the default. This keeps the flip a one-line change to step 4 plus the new opt-out at step 1.
Key-path precedence¶
The HMAC key lives outside .sdd/ so a log-writer cannot read or rotate it. Resolution (from _default_audit_key_path):
BERNSTEIN_AUDIT_KEY_PATH(explicit override).$XDG_STATE_HOME/bernstein/audit.key.~/.local/state/bernstein/audit.key(XDG default).
On first default-on boot the key is generated with mode 0600 if absent; on later boots the existing permissions are enforced (a group- or world-readable key is a hard error).
Existing-install behavior (the invariant to get right)¶
A workspace that predates the chain has no .sdd/audit segments. Enabling the chain must not report it as tampered - the append-only chain gives an operator no way to repair a bogus record after the fact. The existing code already satisfies this, and it is now locked by tests/unit/test_audit_chain_migration.py:
- An absent or empty audit dir recovers the chain tail from genesis, not a bogus tail;
verify()over it returns(True, []). - The first chained run anchors its first event to genesis; that run, and a fresh reader process, both verify clean.
- Sibling
.sdd/state (lineage spine, runtime files) is ignored:verify()reads only the audit dir's own*.jsonl/*.jsonl.gzsegments, so pre-existing state cannot be mistaken for chain history.
Net: turning the chain on for the first time starts a fresh chain from genesis in that workspace. There is nothing to compare the past against, and the verifier does not pretend otherwise.
Rollback¶
Rollback is a config change, not a data migration. Setting BERNSTEIN_AUDIT=0 (or --no-audit, or dropping the compliance preset) returns to opt-in. The segments already written stay valid and independently verifiable; nothing is deleted. Re-enabling later resumes the same chain, because chain recovery walks the existing segments (live, then archived) to find the tip. Because the chain is append-only and keyed outside the audit dir, neither disabling nor re-enabling rewrites history.
Consequences¶
Benefits¶
- The default-on decision is now backed by numbers, not intuition.
- The migration-safety invariant is locked by tests, so a future flip cannot silently regress a chain-less workspace into a false tamper report.
- The benchmark is reproducible and hermetic, so the cost can be re-measured on any host or after any change to the append/verify path.
Costs¶
- The chain remains opt-in for now; operators who do not pass
--auditstill get no tamper-evidence until the follow-up flip. - The benchmark's wall-clock figures are host-dependent and must be re-run to compare across machines; only the ratios and byte counts transfer.
Follow-ups (recorded, not done here)¶
- Flip the default to on, adding the
--no-audit/BERNSTEIN_AUDIT=0opt-out and a louder first-run notice, once the CI confirmation step above passes. Small, isolated change to default resolution. - Collapse the double canonical serialization in
AuditLog.log. The append path serializes the pre-HMAC entry (to compute the HMAC) and again with the HMAC field added (to write the line) - twojson.dumps(sort_keys=True)per append, part of the ~50 us marginal. Collapsing to one requires splicing thehmacfield into an already-serialized string at its sorted position, which risks the byte-exact on-disk format the tamper-evidence and snapshot tests depend on. Deferred as higher-risk than its payoff. - Bound the warm-cursor scan at O(appended), not O(segments). A warm
scan_verifiedstill stats every segment each call, so incremental re-verification cost grows with segment count (~99 us at 1 segment, ~950 us at 90). Candidate: skip the re-stat of segments already markedcomplete.
Done in this change¶
AuditLog.lognow derives the event timestamp and the daily-file name from a singledatetime.nowreading rather than two - one fewer clock read per append, and it closes a latent UTC-midnight straddle where an event could be filed under a day that disagreed with its own timestamp.