pushkin-core 0.2.0

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
//! Waivers + the decision log (spec §15 Phase 5; integration doc §7).
//! `pushkin/waivers.toml` is an append-only human-plane record: signed,
//! scoped, expiring. The gate consults it to suppress matching denials;
//! doctor lints it for stale entries (expired, superseded) and surfaces
//! contradiction links. Agents cannot write it — `pushkin/` is built-in
//! gate surface (pipeline).

use globset::Glob;
use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
use uuid::Uuid;

use crate::envelope::{CheckResult, Decision};

#[derive(Debug, Error)]
pub enum WaiverError {
    #[error("cannot read waivers file: {0}")]
    Io(#[from] std::io::Error),
    #[error("waivers file rejected: {0}")]
    Parse(#[from] toml::de::Error),
    #[error("waiver serialization failed: {0}")]
    Emit(#[from] toml::ser::Error),
    #[error("invalid ttl '{ttl}': use <number><s|m|h|d>, e.g. 2h")]
    BadTtl { ttl: String },
    #[error("invalid waiver glob '{glob}': {message}")]
    BadGlob { glob: String, message: String },
    #[error("timestamp error: {0}")]
    Timestamp(#[from] time::error::Format),
}

/// One signed waiver record. Externally deserialized — unknown fields are
/// rejected loudly (AGENTS.md serde rule).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Waiver {
    pub id: String,
    pub rule: String,
    pub path: String,
    pub reason: String,
    pub author: String,
    /// UTC ISO-8601 (RFC 3339) — one convention, documented here.
    pub granted_at: String,
    pub expires_at: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub supersedes: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub contradicts: Option<String>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WaiverFile {
    #[serde(default)]
    waivers: Vec<Waiver>,
}

/// What a grant needs from the human plane; `Waiver::grant` adds identity
/// and timestamps.
#[derive(Debug)]
pub struct GrantRequest {
    pub rule: String,
    pub path: String,
    pub reason: String,
    pub author: String,
    pub ttl: Duration,
    pub supersedes: Option<String>,
    pub contradicts: Option<String>,
}

impl Waiver {
    /// Builds a signed record: fresh UUID, `granted_at` = now,
    /// `expires_at` = now + ttl (temporary by default is the point).
    ///
    /// # Errors
    /// Returns `WaiverError::Timestamp` on RFC 3339 formatting failure.
    pub fn grant(request: GrantRequest) -> Result<Self, WaiverError> {
        let now = OffsetDateTime::now_utc();
        Ok(Self {
            id: Uuid::new_v4().to_string(),
            rule: request.rule,
            path: request.path,
            reason: request.reason,
            author: request.author,
            granted_at: now.format(&Rfc3339)?,
            expires_at: (now + request.ttl).format(&Rfc3339)?,
            supersedes: request.supersedes,
            contradicts: request.contradicts,
        })
    }
}

/// Parses `<number><s|m|h|d>` into a duration.
///
/// # Errors
/// Returns `WaiverError::BadTtl` on any other shape.
pub fn parse_ttl(ttl: &str) -> Result<Duration, WaiverError> {
    let bad = || WaiverError::BadTtl {
        ttl: ttl.to_owned(),
    };
    let (digits, unit) = ttl.split_at(ttl.len().saturating_sub(1));
    let count: i64 = digits.parse().map_err(|_| bad())?;
    if count < 0 {
        return Err(bad());
    }
    match unit {
        "s" => Ok(Duration::seconds(count)),
        "m" => Ok(Duration::minutes(count)),
        "h" => Ok(Duration::hours(count)),
        "d" => Ok(Duration::days(count)),
        _ => Err(bad()),
    }
}

/// The loaded waiver set: gate-side matching + doctor-side lint.
#[derive(Debug, Default)]
pub struct WaiverSet {
    entries: Vec<Waiver>,
}

impl WaiverSet {
    /// Loads the waiver file; a missing file is an empty set (waivers are
    /// optional), any other failure is loud.
    ///
    /// # Errors
    /// Returns `WaiverError` on unreadable or malformed content.
    pub fn load(path: &Path) -> Result<Self, WaiverError> {
        let text = match std::fs::read_to_string(path) {
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                return Ok(Self::default())
            }
            other => other?,
        };
        let file: WaiverFile = toml::from_str(&text)?;
        Ok(Self {
            entries: file.waivers,
        })
    }

    /// Appends one record to the waiver file (creating file and parent dir
    /// as needed) and returns the stored entry.
    ///
    /// # Errors
    /// Returns `WaiverError` on read, parse, or write failure.
    pub fn append(path: &Path, waiver: Waiver) -> Result<Waiver, WaiverError> {
        // Validate the glob at grant time so a bad scope is a loud error
        // for the human now, not a silent no-op at gate time.
        Glob::new(&waiver.path).map_err(|error| WaiverError::BadGlob {
            glob: waiver.path.clone(),
            message: error.to_string(),
        })?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let mut file: WaiverFile = match std::fs::read_to_string(path) {
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => WaiverFile::default(),
            other => toml::from_str(&other?)?,
        };
        file.waivers.push(waiver.clone());
        std::fs::write(path, toml::to_string_pretty(&file)?)?;
        Ok(waiver)
    }

    #[must_use]
    pub fn entries(&self) -> &[Waiver] {
        &self.entries
    }

    /// True when an unexpired, unsuperseded waiver covers `rule` on `file`.
    /// The protected-path rule is never waivable — the harness cannot be
    /// negotiated with (integration doc §11). Matching normalizes
    /// legacy-prefixed rule ids so a waiver granted pre-rename still
    /// covers its rule (remediation pass 3, PART B2).
    #[must_use]
    pub fn suppresses(&self, rule: &str, file: &str, now: OffsetDateTime) -> bool {
        let rule = crate::legacy::modern_rule_id(rule);
        if rule == crate::pipeline::RULE_PROTECTED_PATH
            || rule == crate::pipeline::RULE_READ_ONLY_PATH
        {
            return false;
        }
        self.entries.iter().any(|waiver| {
            crate::legacy::modern_rule_id(&waiver.rule) == rule
                && !is_expired(waiver, now)
                && !self.is_superseded(&waiver.id)
                && glob_matches(&waiver.path, file)
        })
    }

    /// `apply` at the current instant — the gate's call site.
    #[must_use]
    pub fn apply_now(&self, result: CheckResult) -> CheckResult {
        self.apply(result, OffsetDateTime::now_utc())
    }

    /// Drops waived violations from a gate result, recomputing the decision.
    #[must_use]
    pub fn apply(&self, mut result: CheckResult, now: OffsetDateTime) -> CheckResult {
        result
            .violations
            .retain(|violation| !self.suppresses(&violation.rule, &violation.file, now));
        if result.violations.is_empty() {
            result.decision = Decision::Allow;
        }
        result
    }

    fn is_superseded(&self, id: &str) -> bool {
        self.entries
            .iter()
            .any(|other| other.supersedes.as_deref() == Some(id))
    }

    /// `lint` at the current instant — doctor's call site.
    #[must_use]
    pub fn lint_now(&self) -> Vec<String> {
        self.lint(OffsetDateTime::now_utc())
    }

    /// `active` at the current instant — report/statusline call site.
    #[must_use]
    pub fn active_now(&self) -> Vec<&Waiver> {
        self.active(OffsetDateTime::now_utc())
    }

    /// Waivers currently in force: unexpired and unsuperseded.
    #[must_use]
    pub fn active(&self, now: OffsetDateTime) -> Vec<&Waiver> {
        self.entries
            .iter()
            .filter(|waiver| !is_expired(waiver, now) && !self.is_superseded(&waiver.id))
            .collect()
    }

    /// Stale-entry lint findings for doctor (spec §15: "stale-entry lint").
    /// Expired and superseded entries are stale; contradiction links are
    /// surfaced as informational pairs for the human to resolve.
    #[must_use]
    pub fn lint(&self, now: OffsetDateTime) -> Vec<String> {
        let mut findings = Vec::new();
        for waiver in &self.entries {
            if is_expired(waiver, now) {
                findings.push(format!(
                    "waiver {} ({} on {}) is expired since {} — stale entry, prune it",
                    waiver.id, waiver.rule, waiver.path, waiver.expires_at
                ));
            }
            if self.is_superseded(&waiver.id) {
                findings.push(format!(
                    "waiver {} ({} on {}) is superseded — stale entry, prune it",
                    waiver.id, waiver.rule, waiver.path
                ));
            }
            if let Some(target) = &waiver.contradicts {
                findings.push(format!(
                    "waiver {} contradicts {} — resolve the pair; both remain on record",
                    waiver.id, target
                ));
            }
        }
        findings
    }
}

fn is_expired(waiver: &Waiver, now: OffsetDateTime) -> bool {
    // An unparsable expiry never grants suppression (fail closed).
    OffsetDateTime::parse(&waiver.expires_at, &Rfc3339).map_or(true, |expires| expires <= now)
}

fn glob_matches(glob: &str, file: &str) -> bool {
    Glob::new(glob).is_ok_and(|g| g.compile_matcher().is_match(file))
}