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    /// Has an agent write to `file` ever been denied under `rule`? The
253    /// evidence half of the amended charter option B: a recorded deny plus a
254    /// staged change means the write happened through a surface `PreToolUse`
255    /// never saw.
256    ///
257    /// Deliberately CROSS-SESSION, unlike [`Self::attempts`]. The ladder
258    /// counts within one agent conversation; this answers "did any agent get
259    /// told no about this path", and the shell running `git commit` is never
260    /// the session that was denied.
261    ///
262    /// `since` is the commit time of the last commit that TOUCHED `file`,
263    /// not `HEAD`'s. That difference is the whole design: the question is
264    /// not "when was the deny" but "is the denied change still
265    /// uncommitted", and only a per-path boundary answers it. Once a human
266    /// commits the file, the boundary moves past the deny and later edits
267    /// pass — resolution is committing the change, which is what a human
268    /// owning the edit actually does.
269    ///
270    /// Comparison is on WHOLE SECONDS, because a git commit time resolves
271    /// to the second while `ts` carries microseconds. `substr(x, 1, 19)` is
272    /// exactly `YYYY-MM-DDTHH:MM:SS` — the whole second and nothing after
273    /// it — so both sides normalize to the one shape they share, however
274    /// each spells what follows: the `time` crate writes a fraction only
275    /// when it is nonzero and renders UTC as a literal `Z`, while git may
276    /// render the offset as `Z` or as `+00:00`.
277    ///
278    /// The earlier 20-character form is what made this fragile. At that
279    /// length the deciding character was whichever of `.` (0x2e), `Z`
280    /// (0x5a), or `+` (0x2b) the two formatters happened to emit, so the
281    /// verdict rode a formatting accident rather than time. Executed
282    /// evidence: with git rendering `Z`, a deny in the same second as the
283    /// commit compared as OLDER and was silently dropped.
284    ///
285    /// Both sides must therefore be UTC before they arrive: comparing a
286    /// local-time `since` against a UTC `ts` would be wrong by the offset.
287    /// `last_commit_touching` pins `TZ=UTC` on its `git log` for exactly
288    /// this reason, and that pin is load-bearing here.
289    ///
290    /// `>=` counts a deny in the same second as the boundary commit. That
291    /// direction is deliberate: git's second resolution cannot tell before
292    /// from after within a second, and the tie must fall toward blocking,
293    /// because a false block is visible and recoverable (commit the file,
294    /// or `--no-verify`) while a false pass silently defeats the gate. A
295    /// deny in a strictly earlier second is resolved history and does not
296    /// count.
297    ///
298    /// # Errors
299    /// Returns `EventLogError::Storage` on query failure.
300    pub fn denied_since(&self, rule: &str, file: &str, since: &str) -> Result<bool, EventLogError> {
301        let count: u64 = self.conn.query_row(
302            "SELECT COUNT(*) FROM events
303             WHERE decision = 'block' AND rule IN (?1, ?2)
304               AND file = ?3 AND substr(ts, 1, 19) >= substr(?4, 1, 19)",
305            (
306                rule,
307                crate::legacy::legacy_rule_id(rule).as_ref(),
308                file,
309                since,
310            ),
311            |row| row.get(0),
312        )?;
313        Ok(count > 0)
314    }
315
316    /// Count of block decisions in a session (any rule, any file).
317    ///
318    /// # Errors
319    /// Returns `EventLogError::Storage` on query failure.
320    pub fn block_count(&self, session: &SessionId) -> Result<u64, EventLogError> {
321        let count: u64 = self.conn.query_row(
322            "SELECT COUNT(*) FROM events WHERE session = ?1 AND decision = 'block'",
323            [session.as_str()],
324            |row| row.get(0),
325        )?;
326        Ok(count)
327    }
328
329    /// Count of events in a session filed under `rule` (telemetry included).
330    ///
331    /// # Errors
332    /// Returns `EventLogError::Storage` on query failure.
333    pub fn rule_count(&self, session: &SessionId, rule: &str) -> Result<u64, EventLogError> {
334        let count: u64 = self.conn.query_row(
335            "SELECT COUNT(*) FROM events WHERE session = ?1 AND rule IN (?2, ?3)",
336            (
337                session.as_str(),
338                rule,
339                crate::legacy::legacy_rule_id(rule).as_ref(),
340            ),
341            |row| row.get(0),
342        )?;
343        Ok(count)
344    }
345
346    /// Count of fail-open events in a session.
347    ///
348    /// # Errors
349    /// Returns `EventLogError::Storage` on query failure.
350    pub fn failopen_count(&self, session: &SessionId) -> Result<u64, EventLogError> {
351        let count: u64 = self.conn.query_row(
352            "SELECT COUNT(*) FROM events WHERE session = ?1 AND rule IN (?2, ?3)",
353            (
354                session.as_str(),
355                FAILOPEN_RULE,
356                crate::legacy::legacy_rule_id(FAILOPEN_RULE).as_ref(),
357            ),
358            |row| row.get(0),
359        )?;
360        Ok(count)
361    }
362
363    /// Aggregates for `pushkin stats` (spec §14): blocks by rule,
364    /// compression savings, nudge arms, fail-opens — across all sessions
365    /// in this repo's log. Historical rows keep their legacy-prefixed
366    /// ids (append-only); aggregation normalizes so the mixed population
367    /// groups as one rule (remediation pass 3, PART B2).
368    ///
369    /// # Errors
370    /// Returns `EventLogError::Storage` on query failure.
371    pub fn stats(&self) -> Result<StatsSummary, EventLogError> {
372        let mut blocks = self.conn.prepare(
373            "SELECT rule, COUNT(*) FROM events
374             WHERE decision = 'block' AND rule IS NOT NULL
375             GROUP BY rule ORDER BY COUNT(*) DESC, rule",
376        )?;
377        let raw_blocks = blocks
378            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
379            .collect::<Result<Vec<(String, u64)>, _>>()?;
380        let blocks_by_rule = group_modern(raw_blocks);
381        let (compression_events, compression_saved_chars): (u64, u64) = self.conn.query_row(
382            "SELECT COUNT(*), COALESCE(SUM(json_extract(payload, '$.saved_chars')), 0)
383             FROM events WHERE rule IN (?1, ?2)",
384            (
385                "pushkin.compression",
386                crate::legacy::legacy_rule_id("pushkin.compression").as_ref(),
387            ),
388            |row| Ok((row.get(0)?, row.get(1)?)),
389        )?;
390        let mut arms = self.conn.prepare(
391            "SELECT COALESCE(json_extract(payload, '$.arm'), 'unknown'), COUNT(*)
392             FROM events WHERE rule IN (?1, ?2) GROUP BY 1 ORDER BY 1",
393        )?;
394        let nudge_arms = arms
395            .query_map(
396                (
397                    "pushkin.nudge",
398                    crate::legacy::legacy_rule_id("pushkin.nudge").as_ref(),
399                ),
400                |row| Ok((row.get(0)?, row.get(1)?)),
401            )?
402            .collect::<Result<Vec<(String, u64)>, _>>()?;
403        let failopen_events: u64 = self.conn.query_row(
404            "SELECT COUNT(*) FROM events WHERE rule IN (?1, ?2)",
405            (
406                FAILOPEN_RULE,
407                crate::legacy::legacy_rule_id(FAILOPEN_RULE).as_ref(),
408            ),
409            |row| row.get(0),
410        )?;
411        Ok(StatsSummary {
412            blocks_by_rule,
413            compression_events,
414            compression_saved_chars,
415            nudge_arms,
416            failopen_events,
417        })
418    }
419
420    /// Decision counts for the compact statusline segment: (checks,
421    /// denials) — real gate decisions only, escalation marker rows
422    /// excluded so three denials read as three, not four.
423    ///
424    /// # Errors
425    /// Returns `EventLogError::Storage` on query failure.
426    pub fn decision_counts(&self) -> Result<(u64, u64), EventLogError> {
427        let row = self.conn.query_row(
428            "SELECT
429               COUNT(*) FILTER (WHERE decision IN ('allow', 'block')),
430               COUNT(*) FILTER (WHERE decision = 'block')
431             FROM events WHERE rule IS NULL OR rule NOT IN (?1, ?2)",
432            (
433                ESCALATION_RULE,
434                crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
435            ),
436            |row| Ok((row.get(0)?, row.get(1)?)),
437        )?;
438        Ok(row)
439    }
440
441    /// Whether the most recent session has hit the escalation ladder cap.
442    ///
443    /// # Errors
444    /// Returns `EventLogError::Storage` on query failure.
445    pub fn latest_session_escalated(&self) -> Result<bool, EventLogError> {
446        let escalated: u64 = self.conn.query_row(
447            "SELECT COUNT(*) FROM events
448             WHERE rule IN (?1, ?2) AND session =
449               (SELECT session FROM events ORDER BY id DESC LIMIT 1)",
450            (
451                ESCALATION_RULE,
452                crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
453            ),
454            |row| row.get(0),
455        )?;
456        Ok(escalated > 0)
457    }
458
459    /// Mean-time-to-compliance (integration doc §8): across sessions that
460    /// recovered (a block followed by an allow), the average number of
461    /// attempts — denials before the allow, plus the complying write.
462    /// `None` when no session has recovered yet.
463    ///
464    /// # Errors
465    /// Returns `EventLogError::Storage` on query failure.
466    pub fn mean_attempts_to_compliance(&self) -> Result<Option<f64>, EventLogError> {
467        let mut statement = self.conn.prepare(
468            "SELECT session, decision FROM events
469             WHERE decision IN ('allow', 'block')
470               AND (rule IS NULL OR rule NOT IN (?1, ?2))
471             ORDER BY session, seq",
472        )?;
473        let rows = statement
474            .query_map(
475                (
476                    ESCALATION_RULE,
477                    crate::legacy::legacy_rule_id(ESCALATION_RULE).as_ref(),
478                ),
479                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
480            )?
481            .collect::<Result<Vec<_>, _>>()?;
482
483        let mut recoveries: Vec<u64> = Vec::new();
484        let mut current_session = String::new();
485        let mut open_blocks: u64 = 0;
486        for (session, decision) in rows {
487            if session != current_session {
488                current_session = session;
489                open_blocks = 0;
490            }
491            match decision.as_str() {
492                "block" => open_blocks += 1,
493                "allow" if open_blocks > 0 => {
494                    recoveries.push(open_blocks + 1);
495                    open_blocks = 0;
496                }
497                _ => {}
498            }
499        }
500        if recoveries.is_empty() {
501            return Ok(None);
502        }
503        #[allow(clippy::cast_precision_loss)]
504        let mean = recoveries.iter().sum::<u64>() as f64 / recoveries.len() as f64;
505        Ok(Some(mean))
506    }
507
508    /// Current schema version.
509    ///
510    /// # Errors
511    /// Returns `EventLogError::Storage` on query failure.
512    pub fn schema_version(&self) -> Result<u32, EventLogError> {
513        let version: u32 = self
514            .conn
515            .query_row("SELECT version FROM schema_meta", [], |row| row.get(0))?;
516        Ok(version)
517    }
518
519    fn append_row(
520        &self,
521        session: &SessionId,
522        decision: &str,
523        rule: Option<String>,
524        file: Option<String>,
525        payload: String,
526    ) -> Result<GateEvent, EventLogError> {
527        let next_seq: u64 = self.conn.query_row(
528            "SELECT COALESCE(MAX(seq), 0) + 1 FROM events WHERE session = ?1",
529            [session.as_str()],
530            |row| row.get(0),
531        )?;
532        let ts = OffsetDateTime::now_utc().format(&Rfc3339)?;
533        self.conn.execute(
534            "INSERT INTO events (session, seq, ts, decision, rule, file, payload)
535             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
536            (
537                session.as_str(),
538                next_seq,
539                &ts,
540                decision,
541                rule,
542                file,
543                payload,
544            ),
545        )?;
546        Ok(GateEvent {
547            session: session.as_str().to_owned(),
548            seq: next_seq,
549            ts,
550        })
551    }
552}
553
554/// Folds mixed-spelling rule rows into their modern ids (legacy
555/// legacy-prefixed rows keep their stored ids; presentation groups them),
556/// preserving the count-desc, rule-asc order of the source query.
557fn group_modern(rows: Vec<(String, u64)>) -> Vec<(String, u64)> {
558    let mut merged: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
559    for (rule, count) in rows {
560        *merged
561            .entry(crate::legacy::modern_rule_id(&rule).into_owned())
562            .or_insert(0) += count;
563    }
564    let mut grouped: Vec<(String, u64)> = merged.into_iter().collect();
565    grouped.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
566    grouped
567}
568
569pub(crate) fn apply_migrations(conn: &Connection) -> Result<(), EventLogError> {
570    let current: u32 = conn
571        .query_row("SELECT version FROM schema_meta", [], |row| row.get(0))
572        .unwrap_or(0);
573    for (index, step) in MIGRATIONS.iter().enumerate() {
574        let step_version = u32::try_from(index).unwrap_or(u32::MAX).saturating_add(1);
575        if step_version > current {
576            conn.execute_batch(step)?;
577        }
578    }
579    if current == 0 {
580        conn.execute(
581            "INSERT INTO schema_meta (version) VALUES (?1)",
582            [SCHEMA_VERSION],
583        )?;
584    } else if current < SCHEMA_VERSION {
585        // Pre-existing DBs must record the upgrade, or every later open
586        // re-applies the tail steps and the version reads stale forever.
587        conn.execute("UPDATE schema_meta SET version = ?1", [SCHEMA_VERSION])?;
588    }
589    Ok(())
590}