pushkin-core 0.2.1

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
//! Result/event envelope + append-only `SQLite` event log conformance
//! (Phase 1 test plan — read-only once committed).

use pushkin_core::envelope::{CheckResult, Decision, Severity, Violation};
use pushkin_core::events::{EventLog, GateEvent};

fn sample_violation() -> Violation {
    Violation {
        file: "app/api/users/route.ts".to_owned(),
        line: 1,
        rule: "contract.boundary.unvalidated_input".to_owned(),
        contract: Some("user".to_owned()),
        fix_hint: "Parse the request body with UserCreateSchema before use".to_owned(),
        suggestions: vec!["contract_show user".to_owned()],
        severity: Severity::Error,
    }
}

#[test]
fn result_json_matches_spec_8_3_shape() {
    let result = CheckResult {
        decision: Decision::Block,
        violations: vec![sample_violation()],
        duration_ms: 38.0,
    };
    let json = serde_json::to_value(&result).unwrap();

    assert_eq!(json["decision"], "block");
    assert_eq!(json["violations"][0]["file"], "app/api/users/route.ts");
    assert_eq!(json["violations"][0]["line"], 1);
    assert_eq!(
        json["violations"][0]["rule"],
        "contract.boundary.unvalidated_input"
    );
    assert_eq!(json["violations"][0]["contract"], "user");
    assert!(
        json["violations"][0]["fixHint"].is_string(),
        "camelCase per spec 8.3"
    );
    assert!(json["violations"][0]["suggestions"].is_array());
    assert_eq!(json["violations"][0]["severity"], "error");
    assert!(json["durationMs"].is_number(), "camelCase per spec 8.3");
}

#[test]
fn unknown_fields_rejected_on_ingest() {
    let bad = r#"{"decision":"block","violations":[],"durationMs":1.0,"extra":true}"#;
    let parsed: Result<CheckResult, _> = serde_json::from_str(bad);
    assert!(
        parsed.is_err(),
        "deny_unknown_fields everywhere (charter N2)"
    );
}

#[test]
fn event_has_session_seq_ts() {
    let dir = tempfile::tempdir().unwrap();
    let log = EventLog::open(dir.path().join("events.db")).unwrap();
    let session = log.begin_session().unwrap();

    let result = CheckResult {
        decision: Decision::Allow,
        violations: vec![],
        duration_ms: 2.0,
    };
    let event = log.append(&session, &result).unwrap();

    assert_eq!(event.seq, 1);
    let next = log.append(&session, &result).unwrap();
    assert_eq!(next.seq, 2, "seq increments per session");
    // Timestamp convention: UTC ISO-8601 (AGENTS.md SQLite rules).
    assert!(
        event.ts.ends_with('Z') || event.ts.contains("+00"),
        "UTC timestamp: {}",
        event.ts
    );
}

#[test]
fn events_are_append_only() {
    let dir = tempfile::tempdir().unwrap();
    let db_path = dir.path().join("events.db");
    let log = EventLog::open(&db_path).unwrap();
    let session = log.begin_session().unwrap();
    let result = CheckResult {
        decision: Decision::Block,
        violations: vec![sample_violation()],
        duration_ms: 1.0,
    };
    log.append(&session, &result).unwrap();
    drop(log);

    // Raw connection: UPDATE and DELETE on events must be refused by trigger.
    let conn = rusqlite::Connection::open(&db_path).unwrap();
    let update = conn.execute("UPDATE events SET decision = 'allow'", []);
    assert!(update.is_err(), "UPDATE on events must fail");
    let delete = conn.execute("DELETE FROM events", []);
    assert!(delete.is_err(), "DELETE on events must fail");
}

#[test]
fn migration_steps_apply_in_order_idempotently() {
    let dir = tempfile::tempdir().unwrap();
    let db_path = dir.path().join("events.db");
    {
        let _first = EventLog::open(&db_path).unwrap();
    }
    // Re-open: migrations must be idempotent, version recorded.
    let log = EventLog::open(&db_path).unwrap();
    assert!(log.schema_version().unwrap() >= 1);
}

#[test]
fn attempts_counter_query_counts_rule_hits() {
    // Phase 1 substrate for the Phase 2 escalation ladder (review directive):
    // attempts(session, rule, path) derived from events.
    let dir = tempfile::tempdir().unwrap();
    let log = EventLog::open(dir.path().join("events.db")).unwrap();
    let session = log.begin_session().unwrap();
    let block = CheckResult {
        decision: Decision::Block,
        violations: vec![sample_violation()],
        duration_ms: 1.0,
    };
    log.append(&session, &block).unwrap();
    log.append(&session, &block).unwrap();

    let attempts = log
        .attempts(
            &session,
            "contract.boundary.unvalidated_input",
            "app/api/users/route.ts",
        )
        .unwrap();
    assert_eq!(attempts, 2);

    let other = log
        .attempts(&session, "pushkin.protected_path", "x.ts")
        .unwrap();
    assert_eq!(other, 0);
}

#[test]
fn failopen_event_is_recordable() {
    // Review directive: every fail-open must be visible in the log.
    let dir = tempfile::tempdir().unwrap();
    let log = EventLog::open(dir.path().join("events.db")).unwrap();
    let session = log.begin_session().unwrap();

    let event = log
        .append_failopen(&session, "malformed PreToolUse payload")
        .unwrap();
    assert_eq!(event.seq, 1);
    let count = log.failopen_count(&session).unwrap();
    assert_eq!(count, 1);
}

#[test]
fn gate_event_serializes_with_envelope() {
    let event = GateEvent {
        session: "s-1".to_owned(),
        seq: 214,
        ts: "2026-08-13T00:00:00Z".to_owned(),
    };
    let json = serde_json::to_value(&event).unwrap();
    assert_eq!(json["session"], "s-1");
    assert_eq!(json["seq"], 214);
    assert!(json["ts"].is_string());
}