# m4p3 — Policy Enforcement and Audit (✅ COMPLETE 2026-02-21) Phase spec and acceptance criteria: [ROADMAP · Milestone 4 · Phase 3](../ROADMAP.md). Milestone index: [README.md](README.md). Backfilled record. ## What shipped 1. **Policy evaluation on the write path** (`tidal/src/session/policy.rs`). Every `session_signal` call is checked before any state mutation. The evaluator returns a typed `PolicyViolation { kind, .. }` rather than a stringly-typed reason. The four kinds M4 shipped: | `PolicyViolationKind` | Trigger | |-----------------------|---------| | `Denied` | signal type appears in `denied_signals` | | `NotAllowed` | `allowed_signals` is non-empty and the type is absent from it | | `CountCap` | `signals_written` reached `max_signals_per_session` | | `Expired` | wall clock passed `max_session_duration` | 2. **Error surface** (`tidal/src/db/sessions.rs:405-417`). `Expired` maps to `TidalError::SessionExpired`; every other kind maps to `TidalError::PolicyViolation` (`tidal/src/schema/error.rs:123`). A rejected write mutates no session signal state — only the rejection counter and the audit log advance. 3. **Bounded audit log** (`tidal/src/session/audit.rs`). `AuditEntry` carries `timestamp_ns`, the signal type, an `accepted` flag, an `AuditKind`, and an optional `reason`. It is a `VecDeque` capped at `MAX_AUDIT_ENTRIES = 10_000`; past the cap the oldest entries are evicted and the snapshot's `audit_truncated` flag is set, so a reader can tell a complete log from a truncated one instead of silently believing a partial record. 4. **`db.session_audit(session_id)`** reads the live log for an active session; for a closed session the log ships inside the archived `SessionSnapshot` (`audit_log` field), so it is still readable after close and after restart. ## Evidence | Criterion | Proof | |-----------|-------| | A denied signal is rejected, counted, and audited with a reason | `m4_uat.rs::step4_policy_rejects_denied_signal` — asserts `signals_rejected == 1`, `signals_written == 0`, `audit.len() == 1`, `!audit[0].accepted`, `audit[0].reason.is_some()` | | A signal absent from a non-empty allow list is rejected | `m4_uat.rs::step5_policy_rejects_non_allowed_signal` | | Accepted writes are audited as accepted | `m4_uat.rs::step3_session_signal_and_audit` | | Truncation is observable, not silent | `session_durability.rs::audit_truncation_marker_set_when_cap_exceeded` | | Audit survives close | `m4_uat.rs::step7_closed_session_snapshot`, `session_durability.rs::archived_session_readable_after_close_and_reopen` | ## Divergence from the plan - **Typed kinds, not free-text reasons.** The ROADMAP specified `AuditEntry = { timestamp, signal_type, outcome: Accepted | Rejected(reason) }` and a `reason: String` on the error. Shipped design keeps the human-readable reason but adds `PolicyViolationKind` and `AuditKind` enums, so callers branch on a variant instead of matching on prose. This is strictly better and is what M9/M10 later extended. - **The `< 1 µs` policy-evaluation criterion has no recorded measurement.** The ROADMAP marks it `[x]` "(benchmarked)". It is a `HashMap` lookup on the write path, which makes the claim plausible, but no benchmark isolates it and no recorded run exists. `tidal/benches/session.rs::session_signal` measures the whole write including WAL, not policy evaluation alone. Treat the figure as unevidenced. - **The property test in the ROADMAP criteria is not present as a property test.** "For any sequence of allowed and denied signal writes, the audit log exactly matches the write outcomes and no denied signal modifies session state" is proven by the concrete `m4_uat.rs` steps 3–5 and by `session_durability.rs`, not by a `proptest`. The invariant is covered; the stated *form* (randomized property test) was not built. ## Extended later M9/M10 governance (`d8e4083`) added seven further `PolicyViolationKind` variants — `CommunityWriteDenied`, `CommunityWriteNotAllowed`, `ReadDenied`, `ReadNotAllowed`, `AttributeReadDenied`, `AttributeReadNotAllowed`, `ProfileOverrideNotAllowed` — and the matching `AuditKind` variants (`ReadDenied`, `AttributeReadDenied`, `ProfileOverrideRejected`) plus the `overrides_rejected` counter on `SessionSnapshot`. The M4 mechanism is the one they extended: read-path and community policy reuse this evaluator and this audit log rather than adding a parallel one.