Skip to main content

rch_common/
proof_intent.rs

1//! Durable proof-intent schema and store (bd-session-history-remediation-ocv9i.5.2).
2//!
3//! When a proof-mode attempt is *denied*, RCH records a [`ProofIntent`]: enough
4//! to replay (or refuse to replay) the attempt later, and safe to reference in
5//! a Beads handoff. The record is **redaction-safe by construction** — it stores
6//! a `command_digest` (a hash, never the raw command line), an `env_allowlist`
7//! of variable *names* (never their values), and source *fingerprints* (hashes,
8//! never content). There is deliberately no field that could carry a secret;
9//! `test_no_raw_or_secret_fields` enforces that.
10//!
11//! The [`ProofIntentStore`] is an append-only JSONL log, deduplicated by
12//! `intent_id` (latest write wins) and corruption-tolerant on read, mirroring
13//! [`crate::incident_ledger`]. [`validate_replay`] decides whether a stored
14//! intent may be replayed given the current revision / source fingerprints /
15//! age, honoring the intent's [`StaleSourcePolicy`] and [`ReplayConstraints`].
16
17use std::collections::BTreeMap;
18use std::fs::{self, OpenOptions};
19use std::io::{BufRead, BufReader, Write};
20use std::path::{Path, PathBuf};
21
22use schemars::generate::SchemaSettings;
23use schemars::{JsonSchema, Schema};
24use serde::{Deserialize, Serialize};
25
26use crate::incident::IncidentReasonCode;
27use crate::schema_versions::{SchemaComponent, current_version};
28
29/// A source file's identity by hash (never its contents).
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31pub struct SourceFingerprint {
32    /// Path relative to the project root.
33    pub path: String,
34    /// blake3 hex digest of the file.
35    pub blake3: String,
36}
37
38/// How to treat source that changed since the intent was recorded.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(rename_all = "snake_case")]
41pub enum StaleSourcePolicy {
42    /// Replay only if the source is byte-identical (fingerprints match).
43    RejectIfChanged,
44    /// Replay even if source changed (the proof was source-independent).
45    AllowIfChanged,
46    /// Never replay — the intent is informational only.
47    AlwaysReject,
48}
49
50/// Constraints that must hold for a stored intent to be replayed.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
52pub struct ReplayConstraints {
53    /// Replay only at the same repo revision.
54    #[serde(default)]
55    pub require_same_revision: bool,
56    /// Replay only if every recorded source fingerprint still matches.
57    #[serde(default)]
58    pub require_unchanged_sources: bool,
59    /// Maximum age (seconds) before the intent is too old to replay.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub max_age_secs: Option<u64>,
62}
63
64/// A durable record of a denied proof attempt.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
66pub struct ProofIntent {
67    /// Schema version (`SchemaComponent::ProofIntent`).
68    pub schema_version: String,
69    /// Stable id (see [`derive_intent_id`]).
70    pub intent_id: String,
71    /// Hash of the classified command — NOT the raw command line.
72    pub command_digest: String,
73    /// Working directory the command ran in.
74    pub cwd: String,
75    /// Repo revision (e.g. git rev), if any.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub repo_revision: Option<String>,
78    /// Source fingerprints (hashes) captured at denial time.
79    #[serde(default)]
80    pub source_fingerprints: Vec<SourceFingerprint>,
81    /// Names of env vars that were allowlisted (never their values).
82    #[serde(default)]
83    pub env_allowlist: Vec<String>,
84    /// Effective target-dir policy.
85    pub target_dir_policy: String,
86    /// Cargo package scope (`-p` selection).
87    #[serde(default)]
88    pub package_scope: Vec<String>,
89    /// Test scope (test filters), if any.
90    #[serde(default)]
91    pub test_scope: Vec<String>,
92    /// Explicitly requested worker, if any.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub requested_worker: Option<String>,
95    /// Explicitly requested profile, if any.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub requested_profile: Option<String>,
98    /// Why the proof was denied (stable incident reason vocabulary).
99    /// Serializes as its `RCH-Innn` string; described to schemars as a string.
100    #[schemars(with = "String")]
101    pub denial_reason: IncidentReasonCode,
102    /// Constraints for a future replay.
103    pub replay_constraints: ReplayConstraints,
104    /// Stale-source policy.
105    pub stale_source_policy: StaleSourcePolicy,
106    /// Denial time as Unix epoch milliseconds (caller-supplied → deterministic).
107    pub recorded_at_unix_ms: u64,
108    /// Free-form, redaction-safe details.
109    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
110    pub details: BTreeMap<String, String>,
111}
112
113impl ProofIntent {
114    /// Construct an intent, stamping the schema version and deriving the
115    /// `intent_id` from (command_digest, cwd, repo_revision).
116    #[must_use]
117    #[allow(clippy::too_many_arguments)]
118    pub fn new(
119        command_digest: impl Into<String>,
120        cwd: impl Into<String>,
121        repo_revision: Option<String>,
122        target_dir_policy: impl Into<String>,
123        denial_reason: IncidentReasonCode,
124        stale_source_policy: StaleSourcePolicy,
125        replay_constraints: ReplayConstraints,
126        recorded_at_unix_ms: u64,
127    ) -> Self {
128        let command_digest = command_digest.into();
129        let cwd = cwd.into();
130        let intent_id = derive_intent_id(&command_digest, &cwd, repo_revision.as_deref());
131        Self {
132            schema_version: proof_intent_schema_version().to_string(),
133            intent_id,
134            command_digest,
135            cwd,
136            repo_revision,
137            source_fingerprints: Vec::new(),
138            env_allowlist: Vec::new(),
139            target_dir_policy: target_dir_policy.into(),
140            package_scope: Vec::new(),
141            test_scope: Vec::new(),
142            requested_worker: None,
143            requested_profile: None,
144            denial_reason,
145            replay_constraints,
146            stale_source_policy,
147            recorded_at_unix_ms,
148            details: BTreeMap::new(),
149        }
150    }
151
152    #[must_use]
153    pub fn with_source_fingerprints(mut self, fps: Vec<SourceFingerprint>) -> Self {
154        self.source_fingerprints = fps;
155        self
156    }
157
158    #[must_use]
159    pub fn with_env_allowlist(mut self, names: Vec<String>) -> Self {
160        self.env_allowlist = names;
161        self
162    }
163}
164
165/// Stable intent id: `pi-<blake3(command_digest|cwd|revision)[..16]>`.
166#[must_use]
167pub fn derive_intent_id(command_digest: &str, cwd: &str, repo_revision: Option<&str>) -> String {
168    let mut hasher = blake3::Hasher::new();
169    hasher.update(command_digest.as_bytes());
170    hasher.update(b"\0");
171    hasher.update(cwd.as_bytes());
172    hasher.update(b"\0");
173    hasher.update(repo_revision.unwrap_or("").as_bytes());
174    let hex = hasher.finalize().to_hex();
175    format!("pi-{}", &hex[..16])
176}
177
178/// The current proof-intent schema version.
179#[must_use]
180pub fn proof_intent_schema_version() -> &'static str {
181    current_version(SchemaComponent::ProofIntent)
182}
183
184/// Export the JSON Schema for [`ProofIntent`].
185#[must_use]
186pub fn proof_intent_schema() -> Schema {
187    SchemaSettings::draft07()
188        .into_generator()
189        .into_root_schema_for::<ProofIntent>()
190}
191
192/// Current state used to decide whether a stored intent may be replayed.
193#[derive(Debug, Clone, Default)]
194pub struct ReplayContext {
195    pub current_revision: Option<String>,
196    pub current_fingerprints: Vec<SourceFingerprint>,
197    pub age_secs: u64,
198}
199
200/// The replay decision for a stored intent.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ReplayDecision {
203    Replayable,
204    Rejected {
205        reason: IncidentReasonCode,
206        detail: String,
207    },
208}
209
210/// Decide whether `intent` may be replayed in `ctx`, honoring its stale-source
211/// policy and replay constraints.
212#[must_use]
213pub fn validate_replay(intent: &ProofIntent, ctx: &ReplayContext) -> ReplayDecision {
214    if intent.stale_source_policy == StaleSourcePolicy::AlwaysReject {
215        return ReplayDecision::Rejected {
216            reason: IncidentReasonCode::ProofRefusal,
217            detail: "intent is informational only (always_reject)".to_string(),
218        };
219    }
220    if let Some(max) = intent.replay_constraints.max_age_secs
221        && ctx.age_secs > max
222    {
223        return ReplayDecision::Rejected {
224            reason: IncidentReasonCode::ProofRefusal,
225            detail: format!("intent age {}s exceeds max {}s", ctx.age_secs, max),
226        };
227    }
228    if intent.replay_constraints.require_same_revision
229        && intent.repo_revision != ctx.current_revision
230    {
231        return ReplayDecision::Rejected {
232            reason: IncidentReasonCode::ProofRefusal,
233            detail: "repo revision changed since intent was recorded".to_string(),
234        };
235    }
236    let must_match = intent.replay_constraints.require_unchanged_sources
237        || intent.stale_source_policy == StaleSourcePolicy::RejectIfChanged;
238    if must_match && !fingerprints_match(&intent.source_fingerprints, &ctx.current_fingerprints) {
239        return ReplayDecision::Rejected {
240            reason: IncidentReasonCode::ProofRefusal,
241            detail: "source fingerprint mismatch (source changed)".to_string(),
242        };
243    }
244    ReplayDecision::Replayable
245}
246
247/// Exact-set fingerprint equality (path → blake3), order-independent.
248fn fingerprints_match(recorded: &[SourceFingerprint], current: &[SourceFingerprint]) -> bool {
249    if recorded.len() != current.len() {
250        return false;
251    }
252    let map: BTreeMap<&str, &str> = current
253        .iter()
254        .map(|f| (f.path.as_str(), f.blake3.as_str()))
255        .collect();
256    recorded
257        .iter()
258        .all(|f| map.get(f.path.as_str()) == Some(&f.blake3.as_str()))
259}
260
261/// Append-only, dedup-by-`intent_id`, corruption-tolerant proof-intent store.
262#[derive(Debug, Clone)]
263pub struct ProofIntentStore {
264    path: PathBuf,
265}
266
267impl ProofIntentStore {
268    #[must_use]
269    pub fn with_path(path: impl Into<PathBuf>) -> Self {
270        Self { path: path.into() }
271    }
272
273    #[must_use]
274    pub fn path(&self) -> &Path {
275        &self.path
276    }
277
278    /// Append an intent. Duplicate `intent_id`s are tolerated on write
279    /// (latest-wins is resolved on read), so `put` stays O(1) on the hot path.
280    pub fn put(&self, intent: &ProofIntent) -> std::io::Result<()> {
281        if let Some(parent) = self.path.parent()
282            && !parent.as_os_str().is_empty()
283        {
284            fs::create_dir_all(parent)?;
285        }
286        let mut line = serde_json::to_string(intent)
287            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
288        line.push('\n');
289        let mut file = OpenOptions::new()
290            .create(true)
291            .append(true)
292            .open(&self.path)?;
293        file.write_all(line.as_bytes())?;
294        file.flush()
295    }
296
297    /// All current intents, deduplicated by `intent_id` (latest write wins),
298    /// in first-seen order. Corrupt lines are skipped.
299    #[must_use]
300    pub fn all(&self) -> Vec<ProofIntent> {
301        let file = match fs::File::open(&self.path) {
302            Ok(f) => f,
303            Err(_) => return Vec::new(),
304        };
305        // Preserve first-seen order, but let a later record replace an earlier
306        // one with the same id.
307        let mut order: Vec<String> = Vec::new();
308        let mut latest: BTreeMap<String, ProofIntent> = BTreeMap::new();
309        for line in BufReader::new(file).lines() {
310            let Ok(line) = line else { break };
311            let trimmed = line.trim();
312            if trimmed.is_empty() {
313                continue;
314            }
315            if let Ok(intent) = serde_json::from_str::<ProofIntent>(trimmed) {
316                if !latest.contains_key(&intent.intent_id) {
317                    order.push(intent.intent_id.clone());
318                }
319                latest.insert(intent.intent_id.clone(), intent);
320            }
321        }
322        order
323            .into_iter()
324            .filter_map(|id| latest.remove(&id))
325            .collect()
326    }
327
328    /// Fetch a single intent by id (latest wins).
329    #[must_use]
330    pub fn get(&self, intent_id: &str) -> Option<ProofIntent> {
331        self.all().into_iter().find(|i| i.intent_id == intent_id)
332    }
333
334    /// Intents matching a denial reason.
335    #[must_use]
336    pub fn by_reason(&self, reason: IncidentReasonCode) -> Vec<ProofIntent> {
337        self.all()
338            .into_iter()
339            .filter(|i| i.denial_reason == reason)
340            .collect()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    fn intent() -> ProofIntent {
349        ProofIntent::new(
350            "blake3:abc123",
351            "/data/projects/foo",
352            Some("rev-1".to_string()),
353            "pooled",
354            IncidentReasonCode::ProofRefusal,
355            StaleSourcePolicy::RejectIfChanged,
356            ReplayConstraints {
357                require_same_revision: true,
358                require_unchanged_sources: true,
359                max_age_secs: Some(3600),
360            },
361            1_700_000_000_000,
362        )
363        .with_source_fingerprints(vec![SourceFingerprint {
364            path: "src/lib.rs".to_string(),
365            blake3: "deadbeef".to_string(),
366        }])
367        .with_env_allowlist(vec![
368            "CARGO_TARGET_DIR".to_string(),
369            "RUSTFLAGS".to_string(),
370        ])
371    }
372
373    #[test]
374    fn test_no_raw_or_secret_fields() {
375        // The record must never carry a raw command or any secret value.
376        let v = serde_json::to_value(intent()).unwrap();
377        let obj = v.as_object().unwrap();
378        for forbidden in [
379            "command",
380            "raw_command",
381            "env",
382            "env_values",
383            "secret",
384            "password",
385            "token",
386        ] {
387            assert!(
388                !obj.contains_key(forbidden),
389                "must not expose `{forbidden}`"
390            );
391        }
392        // Only the digest + allowlisted NAMES survive.
393        assert!(obj.contains_key("command_digest"));
394        assert_eq!(v["env_allowlist"][0], "CARGO_TARGET_DIR");
395    }
396
397    #[test]
398    fn intent_id_is_stable_and_derived() {
399        let a = derive_intent_id("d", "/cwd", Some("r"));
400        let b = derive_intent_id("d", "/cwd", Some("r"));
401        assert_eq!(a, b);
402        assert!(a.starts_with("pi-"));
403        assert_ne!(a, derive_intent_id("d", "/cwd", Some("r2")));
404        assert_eq!(
405            intent().intent_id,
406            derive_intent_id("blake3:abc123", "/data/projects/foo", Some("rev-1"))
407        );
408    }
409
410    #[test]
411    fn schema_version_stamped_and_versioned() {
412        assert_eq!(intent().schema_version, "1.0.0");
413        assert_eq!(proof_intent_schema_version(), "1.0.0");
414        let text = serde_json::to_string(&proof_intent_schema()).unwrap();
415        assert!(text.contains("ProofIntent"));
416        for f in [
417            "command_digest",
418            "intent_id",
419            "denial_reason",
420            "replay_constraints",
421            "stale_source_policy",
422        ] {
423            assert!(text.contains(f), "schema omits {f}");
424        }
425    }
426
427    #[test]
428    fn serde_roundtrips() {
429        let i = intent();
430        let back: ProofIntent = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap();
431        assert_eq!(i, back);
432    }
433
434    #[test]
435    fn replay_rejected_on_fingerprint_mismatch() {
436        let i = intent();
437        // Same revision, but the source hash changed.
438        let ctx = ReplayContext {
439            current_revision: Some("rev-1".to_string()),
440            current_fingerprints: vec![SourceFingerprint {
441                path: "src/lib.rs".to_string(),
442                blake3: "CHANGED".to_string(),
443            }],
444            age_secs: 10,
445        };
446        match validate_replay(&i, &ctx) {
447            ReplayDecision::Rejected { reason, detail } => {
448                assert_eq!(reason, IncidentReasonCode::ProofRefusal);
449                assert!(detail.contains("fingerprint"));
450            }
451            other => panic!("expected rejection, got {other:?}"),
452        }
453    }
454
455    #[test]
456    fn replay_ok_when_unchanged() {
457        let i = intent();
458        let ctx = ReplayContext {
459            current_revision: Some("rev-1".to_string()),
460            current_fingerprints: vec![SourceFingerprint {
461                path: "src/lib.rs".to_string(),
462                blake3: "deadbeef".to_string(),
463            }],
464            age_secs: 10,
465        };
466        assert_eq!(validate_replay(&i, &ctx), ReplayDecision::Replayable);
467    }
468
469    #[test]
470    fn replay_rejected_on_revision_change_and_age() {
471        let i = intent();
472        let rev = ReplayContext {
473            current_revision: Some("rev-2".to_string()),
474            current_fingerprints: i.source_fingerprints.clone(),
475            age_secs: 10,
476        };
477        assert!(matches!(
478            validate_replay(&i, &rev),
479            ReplayDecision::Rejected { .. }
480        ));
481
482        let old = ReplayContext {
483            current_revision: Some("rev-1".to_string()),
484            current_fingerprints: i.source_fingerprints.clone(),
485            age_secs: 7200, // > max_age 3600
486        };
487        match validate_replay(&i, &old) {
488            ReplayDecision::Rejected { detail, .. } => assert!(detail.contains("age")),
489            other => panic!("expected age rejection, got {other:?}"),
490        }
491    }
492
493    #[test]
494    fn always_reject_policy_never_replays() {
495        let mut i = intent();
496        i.stale_source_policy = StaleSourcePolicy::AlwaysReject;
497        let ctx = ReplayContext {
498            current_revision: i.repo_revision.clone(),
499            current_fingerprints: i.source_fingerprints.clone(),
500            age_secs: 0,
501        };
502        assert!(matches!(
503            validate_replay(&i, &ctx),
504            ReplayDecision::Rejected { .. }
505        ));
506    }
507
508    #[test]
509    fn store_dedups_duplicate_intents() {
510        let dir = tempfile::tempdir().unwrap();
511        let store = ProofIntentStore::with_path(dir.path().join("proofs.jsonl"));
512        let i = intent();
513        store.put(&i).unwrap();
514        store.put(&i).unwrap(); // duplicate intent_id
515        // A later write with the same id replaces (latest wins, one record).
516        let mut updated = i.clone();
517        updated.target_dir_policy = "isolated".to_string();
518        store.put(&updated).unwrap();
519
520        let all = store.all();
521        assert_eq!(all.len(), 1, "duplicates dedup by intent_id");
522        assert_eq!(all[0].target_dir_policy, "isolated", "latest write wins");
523    }
524
525    #[test]
526    fn store_query_and_corruption_tolerance() {
527        let dir = tempfile::tempdir().unwrap();
528        let path = dir.path().join("proofs.jsonl");
529        let store = ProofIntentStore::with_path(&path);
530        store.put(&intent()).unwrap();
531        // Inject a corrupt line; it must be skipped, not crash the read.
532        {
533            let mut f = OpenOptions::new().append(true).open(&path).unwrap();
534            writeln!(f, "{{ not json").unwrap();
535        }
536        let mut other = ProofIntent::new(
537            "blake3:zzz",
538            "/data/projects/bar",
539            None,
540            "pooled",
541            IncidentReasonCode::ProofRefusal,
542            StaleSourcePolicy::AllowIfChanged,
543            ReplayConstraints::default(),
544            1_700_000_000_001,
545        );
546        other.requested_worker = Some("css".to_string());
547        store.put(&other).unwrap();
548
549        assert_eq!(store.all().len(), 2);
550        assert_eq!(store.by_reason(IncidentReasonCode::ProofRefusal).len(), 2);
551        assert!(store.get(&other.intent_id).is_some());
552        assert!(store.get("pi-nonexistent").is_none());
553    }
554
555    #[test]
556    fn missing_store_reads_empty() {
557        let dir = tempfile::tempdir().unwrap();
558        let store = ProofIntentStore::with_path(dir.path().join("nope.jsonl"));
559        assert!(store.all().is_empty());
560    }
561}