Skip to main content

pushkin_core/
events.rs

1//! Append-only `SQLite` event log (charter N4, N7). Every gate decision is an
2//! event from Phase 1 onward; append-only is enforced in the schema itself
3//! via triggers, not by convention. Timestamps: UTC ISO-8601 (one convention,
4//! this table, documented here).
5
6use rusqlite::Connection;
7use std::path::Path;
8use thiserror::Error;
9use time::format_description::well_known::Rfc3339;
10use time::OffsetDateTime;
11use uuid::Uuid;
12
13use crate::envelope::CheckResult;
14
15pub const SCHEMA_VERSION: u32 = 2;
16
17/// Rule id recorded when a shim fails open on malformed input
18/// (review directive: drift must be visible, never silent).
19pub const FAILOPEN_RULE: &str = "pushkin.failopen.malformed_input";
20
21/// Rule id recorded when the escalation ladder reaches its attempt cap
22/// (addendum §7/§8: escalation is an event, not just prose).
23pub const ESCALATION_RULE: &str = "pushkin.escalation";
24
25/// Versioned, append-only migration steps shipped in the binary (AGENTS.md
26/// `SQLite` rules). Index = schema version - 1. Never edit a shipped step;
27/// add a new one.
28const MIGRATIONS: &[&str] = &[
29    "
30    CREATE TABLE IF NOT EXISTS schema_meta (
31        version INTEGER NOT NULL
32    );
33    CREATE TABLE IF NOT EXISTS events (
34        id INTEGER PRIMARY KEY AUTOINCREMENT,
35        session TEXT NOT NULL,
36        seq INTEGER NOT NULL,
37        ts TEXT NOT NULL, -- UTC ISO-8601 (RFC 3339)
38        decision TEXT NOT NULL,
39        rule TEXT,
40        file TEXT,
41        payload TEXT NOT NULL,
42        UNIQUE (session, seq)
43    );
44    CREATE TRIGGER IF NOT EXISTS events_no_update
45        BEFORE UPDATE ON events
46        BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
47    CREATE TRIGGER IF NOT EXISTS events_no_delete
48        BEFORE DELETE ON events
49        BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;
50    ",
51    // v2 — delivered-slice index (spec §7.3). Deliberately mutable working
52    // state, unlike events: compaction clears scopes, horizon math updates
53    // counters. No append-only triggers here BY DESIGN.
54    "
55    CREATE TABLE IF NOT EXISTS delivered_slices (
56        session TEXT NOT NULL,
57        cwd TEXT NOT NULL,
58        slice_key TEXT NOT NULL,
59        delivered_at_emission INTEGER NOT NULL,
60        PRIMARY KEY (session, cwd, slice_key)
61    );
62    CREATE TABLE IF NOT EXISTS delivery_counters (
63        session TEXT NOT NULL,
64        cwd TEXT NOT NULL,
65        emissions INTEGER NOT NULL,
66        PRIMARY KEY (session, cwd)
67    );
68    ",
69];
70
71#[derive(Debug, Error)]
72pub enum EventLogError {
73    #[error("event log storage error: {0}")]
74    Storage(#[from] rusqlite::Error),
75    #[error("event serialization error: {0}")]
76    Serialize(#[from] serde_json::Error),
77    #[error("timestamp formatting error: {0}")]
78    Timestamp(#[from] time::error::Format),
79}
80
81/// Session identifier newtype (AGENTS.md: newtypes for domain IDs).
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct SessionId(String);
84
85impl SessionId {
86    /// Adopts an agent-supplied session identifier (hook payloads carry the
87    /// agent's own session id; the ladder must count across process runs).
88    #[must_use]
89    pub fn from_name(name: &str) -> Self {
90        Self(name.to_owned())
91    }
92
93    #[must_use]
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97}
98
99/// Aggregates surfaced by `pushkin stats` (spec §14).
100#[derive(Debug)]
101pub struct StatsSummary {
102    pub blocks_by_rule: Vec<(String, u64)>,
103    pub compression_events: u64,
104    pub compression_saved_chars: u64,
105    pub nudge_arms: Vec<(String, u64)>,
106    pub failopen_events: u64,
107}
108
109/// One telemetry emission: the rule tag it files under and its payload.
110#[derive(Debug)]
111pub struct Telemetry<'a> {
112    pub rule: &'a str,
113    pub payload: String,
114}
115
116/// The event envelope of spec §8.3 (`event` field of the result JSON).
117#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct GateEvent {
120    pub session: String,
121    pub seq: u64,
122    pub ts: String,
123}
124
125pub struct EventLog {
126    conn: Connection,
127}
128
129impl EventLog {
130    /// Opens (creating if needed) the event log and applies pending
131    /// migration steps in order, idempotently.
132    ///
133    /// # Errors
134    /// Returns `EventLogError::Storage` on any `SQLite` failure.
135    pub fn open(path: impl AsRef<Path>) -> Result<Self, EventLogError> {
136        let conn = Connection::open(path)?;
137        apply_migrations(&conn)?;
138        Ok(Self { conn })
139    }
140
141    /// Starts a new session (fresh UUID; seq restarts at 1 within it).
142    ///
143    /// # Errors
144    /// Currently infallible in practice; `Result` for API stability.
145    pub fn begin_session(&self) -> Result<SessionId, EventLogError> {
146        Ok(SessionId(Uuid::new_v4().to_string()))
147    }
148
149    /// Appends one gate decision as an event and returns its envelope.
150    ///
151    /// # Errors
152    /// Returns `EventLogError` on storage or serialization failure.
153    pub fn append(
154        &self,
155        session: &SessionId,
156        result: &CheckResult,
157    ) -> Result<GateEvent, EventLogError> {
158        let rule = result.violations.first().map(|v| v.rule.clone());
159        let file = result.violations.first().map(|v| v.file.clone());
160        self.append_row(
161            session,
162            result.decision.as_str(),
163            rule,
164            file,
165            serde_json::to_string(result)?,
166        )
167    }
168
169    /// Records a fail-open occurrence (review directive: visible, queryable).
170    ///
171    /// # Errors
172    /// Returns `EventLogError` on storage failure.
173    pub fn append_failopen(
174        &self,
175        session: &SessionId,
176        detail: &str,
177    ) -> Result<GateEvent, EventLogError> {
178        let payload = serde_json::json!({ "failopen": true, "detail": detail }).to_string();
179        self.append_row(
180            session,
181            "allow",
182            Some(FAILOPEN_RULE.to_owned()),
183            None,
184            payload,
185        )
186    }
187
188    /// Records a ladder escalation (attempt cap reached — addendum §7/§8).
189    ///
190    /// # Errors
191    /// Returns `EventLogError` on storage failure.
192    pub fn append_escalation(
193        &self,
194        session: &SessionId,
195        detail: &str,
196    ) -> Result<GateEvent, EventLogError> {
197        let payload = serde_json::json!({ "escalation": true, "detail": detail }).to_string();
198        self.append_row(
199            session,
200            "block",
201            Some(ESCALATION_RULE.to_owned()),
202            None,
203            payload,
204        )
205    }
206
207    /// Records a non-decision telemetry event (spec §14: delivery, nudge
208    /// arms, and compression tiers ride the same stream as gate
209    /// decisions, distinguished by `decision = "telemetry"`).
210    ///
211    /// # Errors
212    /// Returns `EventLogError` on storage failure.
213    pub fn append_telemetry(
214        &self,
215        session: &SessionId,
216        telemetry: Telemetry<'_>,
217    ) -> Result<GateEvent, EventLogError> {
218        self.append_row(
219            session,
220            "telemetry",
221            Some(telemetry.rule.to_owned()),
222            None,
223            telemetry.payload,
224        )
225    }
226
227    /// Session-scoped rule-hit counter — the Phase 2 escalation-ladder
228    /// substrate (attempt N derives from this).
229    ///
230    /// # Errors
231    /// Returns `EventLogError::Storage` on query failure.
232    pub fn attempts(
233        &self,
234        session: &SessionId,
235        rule: &str,
236        file: &str,
237    ) -> Result<u64, EventLogError> {
238        let count: u64 = self.conn.query_row(
239            "SELECT COUNT(*) FROM events
240             WHERE session = ?1 AND rule IN (?2, ?3) AND file = ?4",
241            (
242                session.as_str(),
243                rule,
244                crate::legacy::legacy_rule_id(rule).as_ref(),
245                file,
246            ),
247            |row| row.get(0),
248        )?;
249        Ok(count)
250    }
251
252    /// Count of block decisions in a session (any rule, any file).
253    ///
254    /// # Errors
255    /// Returns `EventLogError::Storage` on query failure.
256    pub fn block_count(&self, session: &SessionId) -> Result<u64, EventLogError> {
257        let count: u64 = self.conn.query_row(
258            "SELECT COUNT(*) FROM events WHERE session = ?1 AND decision = 'block'",
259            [session.as_str()],
260            |row| row.get(0),
261        )?;
262        Ok(count)
263    }
264
265    /// Count of events in a session filed under `rule` (telemetry included).
266    ///
267    /// # Errors
268    /// Returns `EventLogError::Storage` on query failure.
269    pub fn rule_count(&self, session: &SessionId, rule: &str) -> Result<u64, EventLogError> {
270        let count: u64 = self.conn.query_row(
271            "SELECT COUNT(*) FROM events WHERE session = ?1 AND rule IN (?2, ?3)",
272            (
273                session.as_str(),
274                rule,
275                crate::legacy::legacy_rule_id(rule).as_ref(),
276            ),
277            |row| row.get(0),
278        )?;
279        Ok(count)
280    }
281
282    /// Count of fail-open events in a session.
283    ///
284    /// # Errors
285    /// Returns `EventLogError::Storage` on query failure.
286    pub fn failopen_count(&self, session: &SessionId) -> Result<u64, EventLogError> {
287        let count: u64 = self.conn.query_row(
288            "SELECT COUNT(*) FROM events WHERE session = ?1 AND rule IN (?2, ?3)",
289            (
290                session.as_str(),
291                FAILOPEN_RULE,
292                crate::legacy::legacy_rule_id(FAILOPEN_RULE).as_ref(),
293            ),
294            |row| row.get(0),
295        )?;
296        Ok(count)
297    }
298
299    /// Aggregates for `pushkin stats` (spec §14): blocks by rule,
300    /// compression savings, nudge arms, fail-opens — across all sessions
301    /// in this repo's log. Historical rows keep their legacy-prefixed
302    /// ids (append-only); aggregation normalizes so the mixed population
303    /// groups as one rule (remediation pass 3, PART B2).
304    ///
305    /// # Errors
306    /// Returns `EventLogError::Storage` on query failure.
307    pub fn stats(&self) -> Result<StatsSummary, EventLogError> {
308        let mut blocks = self.conn.prepare(
309            "SELECT rule, COUNT(*) FROM events
310             WHERE decision = 'block' AND rule IS NOT NULL
311             GROUP BY rule ORDER BY COUNT(*) DESC, rule",
312        )?;
313        let raw_blocks = blocks
314            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
315            .collect::<Result<Vec<(String, u64)>, _>>()?;
316        let blocks_by_rule = group_modern(raw_blocks);
317        let (compression_events, compression_saved_chars): (u64, u64) = self.conn.query_row(
318            "SELECT COUNT(*), COALESCE(SUM(json_extract(payload, '$.saved_chars')), 0)
319             FROM events WHERE rule IN (?1, ?2)",
320            (
321                "pushkin.compression",
322                crate::legacy::legacy_rule_id("pushkin.compression").as_ref(),
323            ),
324            |row| Ok((row.get(0)?, row.get(1)?)),
325        )?;
326        let mut arms = self.conn.prepare(
327            "SELECT COALESCE(json_extract(payload, '$.arm'), 'unknown'), COUNT(*)
328             FROM events WHERE rule IN (?1, ?2) GROUP BY 1 ORDER BY 1",
329        )?;
330        let nudge_arms = arms
331            .query_map(
332                (
333                    "pushkin.nudge",
334                    crate::legacy::legacy_rule_id("pushkin.nudge").as_ref(),
335                ),
336                |row| Ok((row.get(0)?, row.get(1)?)),
337            )?
338            .collect::<Result<Vec<(String, u64)>, _>>()?;
339        let failopen_events: u64 = self.conn.query_row(
340            "SELECT COUNT(*) FROM events WHERE rule IN (?1, ?2)",
341            (
342                FAILOPEN_RULE,
343                crate::legacy::legacy_rule_id(FAILOPEN_RULE).as_ref(),
344            ),
345            |row| row.get(0),
346        )?;
347        Ok(StatsSummary {
348            blocks_by_rule,
349            compression_events,
350            compression_saved_chars,
351            nudge_arms,
352            failopen_events,
353        })
354    }
355
356    /// Decision counts for the compact statusline segment: (checks,
357    /// denials) — real gate decisions only, escalation marker rows
358    /// excluded so three denials read as three, not four.
359    ///
360    /// # Errors
361    /// Returns `EventLogError::Storage` on query failure.
362    pub fn decision_counts(&self) -> Result<(u64, u64), EventLogError> {
363        let row = self.conn.query_row(
364            "SELECT
365               COUNT(*) FILTER (WHERE decision IN ('allow', 'block')),
366               COUNT(*) FILTER (WHERE decision = 'block')
367             FROM events WHERE rule IS NULL OR rule NOT IN (?1, ?2)",
368            (
369                ESCALATION_RULE,
370                crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
371            ),
372            |row| Ok((row.get(0)?, row.get(1)?)),
373        )?;
374        Ok(row)
375    }
376
377    /// Whether the most recent session has hit the escalation ladder cap.
378    ///
379    /// # Errors
380    /// Returns `EventLogError::Storage` on query failure.
381    pub fn latest_session_escalated(&self) -> Result<bool, EventLogError> {
382        let escalated: u64 = self.conn.query_row(
383            "SELECT COUNT(*) FROM events
384             WHERE rule IN (?1, ?2) AND session =
385               (SELECT session FROM events ORDER BY id DESC LIMIT 1)",
386            (
387                ESCALATION_RULE,
388                crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
389            ),
390            |row| row.get(0),
391        )?;
392        Ok(escalated > 0)
393    }
394
395    /// Mean-time-to-compliance (integration doc §8): across sessions that
396    /// recovered (a block followed by an allow), the average number of
397    /// attempts — denials before the allow, plus the complying write.
398    /// `None` when no session has recovered yet.
399    ///
400    /// # Errors
401    /// Returns `EventLogError::Storage` on query failure.
402    pub fn mean_attempts_to_compliance(&self) -> Result<Option<f64>, EventLogError> {
403        let mut statement = self.conn.prepare(
404            "SELECT session, decision FROM events
405             WHERE decision IN ('allow', 'block')
406               AND (rule IS NULL OR rule NOT IN (?1, ?2))
407             ORDER BY session, seq",
408        )?;
409        let rows = statement
410            .query_map(
411                (
412                    ESCALATION_RULE,
413                    crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
414                ),
415                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
416            )?
417            .collect::<Result<Vec<_>, _>>()?;
418
419        let mut recoveries: Vec<u64> = Vec::new();
420        let mut current_session = String::new();
421        let mut open_blocks: u64 = 0;
422        for (session, decision) in rows {
423            if session != current_session {
424                current_session = session;
425                open_blocks = 0;
426            }
427            match decision.as_str() {
428                "block" => open_blocks += 1,
429                "allow" if open_blocks > 0 => {
430                    recoveries.push(open_blocks + 1);
431                    open_blocks = 0;
432                }
433                _ => {}
434            }
435        }
436        if recoveries.is_empty() {
437            return Ok(None);
438        }
439        #[allow(clippy::cast_precision_loss)]
440        let mean = recoveries.iter().sum::<u64>() as f64 / recoveries.len() as f64;
441        Ok(Some(mean))
442    }
443
444    /// Current schema version.
445    ///
446    /// # Errors
447    /// Returns `EventLogError::Storage` on query failure.
448    pub fn schema_version(&self) -> Result<u32, EventLogError> {
449        let version: u32 = self
450            .conn
451            .query_row("SELECT version FROM schema_meta", [], |row| row.get(0))?;
452        Ok(version)
453    }
454
455    fn append_row(
456        &self,
457        session: &SessionId,
458        decision: &str,
459        rule: Option<String>,
460        file: Option<String>,
461        payload: String,
462    ) -> Result<GateEvent, EventLogError> {
463        let next_seq: u64 = self.conn.query_row(
464            "SELECT COALESCE(MAX(seq), 0) + 1 FROM events WHERE session = ?1",
465            [session.as_str()],
466            |row| row.get(0),
467        )?;
468        let ts = OffsetDateTime::now_utc().format(&Rfc3339)?;
469        self.conn.execute(
470            "INSERT INTO events (session, seq, ts, decision, rule, file, payload)
471             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
472            (
473                session.as_str(),
474                next_seq,
475                &ts,
476                decision,
477                rule,
478                file,
479                payload,
480            ),
481        )?;
482        Ok(GateEvent {
483            session: session.as_str().to_owned(),
484            seq: next_seq,
485            ts,
486        })
487    }
488}
489
490/// Folds mixed-spelling rule rows into their modern ids (legacy
491/// legacy-prefixed rows keep their stored ids; presentation groups them),
492/// preserving the count-desc, rule-asc order of the source query.
493fn group_modern(rows: Vec<(String, u64)>) -> Vec<(String, u64)> {
494    let mut merged: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
495    for (rule, count) in rows {
496        *merged
497            .entry(crate::legacy::modern_rule_id(&rule).into_owned())
498            .or_insert(0) += count;
499    }
500    let mut grouped: Vec<(String, u64)> = merged.into_iter().collect();
501    grouped.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
502    grouped
503}
504
505pub(crate) fn apply_migrations(conn: &Connection) -> Result<(), EventLogError> {
506    let current: u32 = conn
507        .query_row("SELECT version FROM schema_meta", [], |row| row.get(0))
508        .unwrap_or(0);
509    for (index, step) in MIGRATIONS.iter().enumerate() {
510        let step_version = u32::try_from(index).unwrap_or(u32::MAX).saturating_add(1);
511        if step_version > current {
512            conn.execute_batch(step)?;
513        }
514    }
515    if current == 0 {
516        conn.execute(
517            "INSERT INTO schema_meta (version) VALUES (?1)",
518            [SCHEMA_VERSION],
519        )?;
520    } else if current < SCHEMA_VERSION {
521        // Pre-existing DBs must record the upgrade, or every later open
522        // re-applies the tail steps and the version reads stale forever.
523        conn.execute("UPDATE schema_meta SET version = ?1", [SCHEMA_VERSION])?;
524    }
525    Ok(())
526}