Skip to main content

lex_vcs/
intent.rs

1//! First-class `Intent` object linked to operations (#131).
2//!
3//! Today the op log records *what* changed (typed deltas on the
4//! AST). Intent captures *why* — the prompt that caused an agent
5//! to make the change, the model that interpreted it, and the
6//! session that grouped it with sibling ops.
7//!
8//! This matters for two reasons:
9//! 1. **Audit.** When an agent commits a regression, the maintainer
10//!    needs the prompt that led to it. The commit message can be
11//!    made up; the prompt is the actual causal event.
12//! 2. **Coordination.** When multiple agents work in parallel,
13//!    knowing which operations belong to which intent lets the
14//!    harness group them — agent A's work on intent-X is
15//!    independent of agent B's work on intent-Y.
16//!
17//! # Identity
18//!
19//! [`IntentId`] is the SHA-256 of the canonical form of
20//! `(prompt, session_id, model, parent_intent)` — `created_at` is
21//! deliberately *not* part of the hash, so two runs of the same
22//! prompt at different times still dedupe. The
23//! "same `(prompt, model, session)` → same `intent_id`" invariant
24//! is what #131's audit story rests on.
25//!
26//! # Storage
27//!
28//! `<root>/intents/<IntentId>.json` — same shape as `<root>/ops/`
29//! and `<root>/stages/`. Atomic writes via tempfile + rename;
30//! idempotent on existing IDs.
31//!
32//! # Privacy boundary
33//!
34//! Prompts may contain sensitive data. Keeping intents in their
35//! own addressable namespace (rather than inlining the prompt on
36//! every op) makes per-intent ACLs tractable as a follow-up
37//! without touching the op log itself.
38
39use serde::{Deserialize, Serialize};
40use std::fs;
41use std::io::{self, Write};
42use std::path::{Path, PathBuf};
43use std::time::{SystemTime, UNIX_EPOCH};
44
45use crate::canonical;
46
47/// Content-addressed identity of an intent. Lowercase-hex SHA-256
48/// of the canonical form of `(prompt, session_id, model,
49/// parent_intent)`. Excludes `created_at` so two runs of the same
50/// prompt produce the same id.
51pub type IntentId = String;
52
53/// Groups intents from the same agent session. Free-form string
54/// so callers can use whatever session model their harness has.
55pub type SessionId = String;
56
57/// Which model produced the intent. Tracked so audit / blame can
58/// answer "what model wrote this?" without joining against an
59/// external table.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ModelDescriptor {
62    /// Vendor / origin: `"anthropic"`, `"openai"`, `"local"`, etc.
63    pub provider: String,
64    /// The model name: `"claude-opus-4-7"`, `"gpt-5"`, etc.
65    pub name: String,
66    /// Optional version pin. `None` means "whatever the provider
67    /// served"; `Some("2026-04-01")` lets the harness record an
68    /// exact API revision.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub version: Option<String>,
71}
72
73/// The persisted intent. Carries the prompt that caused some
74/// operations to be produced, the model that interpreted it, and
75/// the session that grouped them. Many ops can share one intent;
76/// duplicating the prompt on each would be wasteful and break the
77/// "two equal ops hash equal" invariant.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct Intent {
80    pub intent_id: IntentId,
81    pub prompt: String,
82    pub session_id: SessionId,
83    pub model: ModelDescriptor,
84    /// For refinement chains ("the user said X, then said 'now also
85    /// handle Y'"). `None` for top-level intents.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub parent_intent: Option<IntentId>,
88    /// The typed issue this intent realizes (#949), so provenance links
89    /// issue ↔ intent ↔ ops ↔ attestation. `None` for intents not tied to
90    /// an issue; omitted from the serialized form (and the id hash) when
91    /// `None`, so pre-existing intents keep their ids byte-for-byte.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub issue_id: Option<crate::issue::IssueId>,
94    /// Wall-clock seconds since epoch when this intent was first
95    /// created. Excluded from `intent_id` so the dedup property
96    /// holds across runs.
97    pub created_at: u64,
98}
99
100impl Intent {
101    /// Build an intent and compute its content-addressed id.
102    /// `created_at` is filled in from the current wall clock; pass
103    /// to [`Intent::with_timestamp`] if you want to control it
104    /// explicitly (e.g. in tests).
105    pub fn new(
106        prompt: impl Into<String>,
107        session_id: impl Into<SessionId>,
108        model: ModelDescriptor,
109        parent_intent: Option<IntentId>,
110    ) -> Self {
111        let now = SystemTime::now()
112            .duration_since(UNIX_EPOCH)
113            .map(|d| d.as_secs())
114            .unwrap_or(0);
115        Self::with_timestamp(prompt, session_id, model, parent_intent, now)
116    }
117
118    /// Build an intent with a caller-controlled `created_at`. Used
119    /// in tests to keep golden hashes stable; production code uses
120    /// [`Intent::new`].
121    pub fn with_timestamp(
122        prompt: impl Into<String>,
123        session_id: impl Into<SessionId>,
124        model: ModelDescriptor,
125        parent_intent: Option<IntentId>,
126        created_at: u64,
127    ) -> Self {
128        let prompt = prompt.into();
129        let session_id = session_id.into();
130        let intent_id =
131            compute_intent_id(&prompt, &session_id, &model, parent_intent.as_deref(), None);
132        Self {
133            intent_id,
134            prompt,
135            session_id,
136            model,
137            parent_intent,
138            issue_id: None,
139            created_at,
140        }
141    }
142
143    /// Attach the typed issue this intent realizes (#949), recomputing the
144    /// id: "implement issue X" and the same prompt with no issue are
145    /// distinct intents. An intent without an issue serializes exactly as
146    /// before, so pre-existing ids are unchanged.
147    pub fn with_issue(mut self, issue_id: crate::issue::IssueId) -> Self {
148        self.intent_id = compute_intent_id(
149            &self.prompt,
150            &self.session_id,
151            &self.model,
152            self.parent_intent.as_deref(),
153            Some(issue_id.as_str()),
154        );
155        self.issue_id = Some(issue_id);
156        self
157    }
158}
159
160fn compute_intent_id(
161    prompt: &str,
162    session_id: &str,
163    model: &ModelDescriptor,
164    parent_intent: Option<&str>,
165    issue_id: Option<&str>,
166) -> IntentId {
167    let view = CanonicalIntentView {
168        prompt,
169        session_id,
170        model,
171        parent_intent,
172        issue_id,
173    };
174    canonical::hash(&view)
175}
176
177/// Hashable shadow of [`Intent`] omitting `intent_id` (we're
178/// computing it) and `created_at` (timestamp drift would break
179/// dedup). Lives only as a transient for hashing.
180#[derive(Serialize)]
181struct CanonicalIntentView<'a> {
182    prompt: &'a str,
183    session_id: &'a str,
184    model: &'a ModelDescriptor,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    parent_intent: Option<&'a str>,
187    /// Omitted when `None` so an intent with no issue hashes exactly as it
188    /// did before #949 — id stability for every pre-existing intent.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    issue_id: Option<&'a str>,
191}
192
193// ---- Persistence -------------------------------------------------
194
195/// Persistent log of [`Intent`] records. Mirrors [`crate::OpLog`]'s
196/// shape: one canonical-JSON file per intent, atomic writes via
197/// tempfile + rename, idempotent on re-puts.
198pub struct IntentLog {
199    dir: PathBuf,
200}
201
202impl IntentLog {
203    pub fn open(root: &Path) -> io::Result<Self> {
204        let dir = root.join("intents");
205        fs::create_dir_all(&dir)?;
206        Ok(Self { dir })
207    }
208
209    fn path(&self, id: &IntentId) -> PathBuf {
210        self.dir.join(format!("{id}.json"))
211    }
212
213    /// Persist an intent. Idempotent on existing ids — the bytes
214    /// must match by content addressing, so re-putting the same
215    /// intent is a no-op.
216    pub fn put(&self, intent: &Intent) -> io::Result<()> {
217        let path = self.path(&intent.intent_id);
218        if path.exists() {
219            return Ok(());
220        }
221        let bytes = serde_json::to_vec(intent)
222            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
223        let tmp = path.with_extension("json.tmp");
224        let mut f = fs::File::create(&tmp)?;
225        f.write_all(&bytes)?;
226        f.sync_all()?;
227        fs::rename(&tmp, &path)?;
228        Ok(())
229    }
230
231    pub fn get(&self, id: &IntentId) -> io::Result<Option<Intent>> {
232        let path = self.path(id);
233        if !path.exists() {
234            return Ok(None);
235        }
236        let bytes = fs::read(&path)?;
237        let intent: Intent = serde_json::from_slice(&bytes)
238            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
239        Ok(Some(intent))
240    }
241}
242
243// ---- Tests --------------------------------------------------------
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    fn anthropic() -> ModelDescriptor {
250        ModelDescriptor {
251            provider: "anthropic".into(),
252            name: "claude-opus-4-7".into(),
253            version: None,
254        }
255    }
256
257    #[test]
258    fn same_prompt_session_model_hashes_equal() {
259        // The load-bearing dedup invariant: the same logical
260        // intent (same prompt, same session, same model) should
261        // produce the same `intent_id` regardless of which agent
262        // session re-recorded it. `created_at` differs but is not
263        // in the hash.
264        let a = Intent::with_timestamp(
265            "fix the auth bug", "ses_abc", anthropic(), None, 1000,
266        );
267        let b = Intent::with_timestamp(
268            "fix the auth bug", "ses_abc", anthropic(), None, 99999,
269        );
270        assert_eq!(a.intent_id, b.intent_id);
271        assert_ne!(a.created_at, b.created_at);
272    }
273
274    #[test]
275    fn different_prompts_hash_differently() {
276        let a = Intent::with_timestamp(
277            "fix the auth bug", "ses_abc", anthropic(), None, 0,
278        );
279        let b = Intent::with_timestamp(
280            "fix the cache bug", "ses_abc", anthropic(), None, 0,
281        );
282        assert_ne!(a.intent_id, b.intent_id);
283    }
284
285    #[test]
286    fn different_sessions_hash_differently() {
287        let a = Intent::with_timestamp(
288            "fix the auth bug", "ses_abc", anthropic(), None, 0,
289        );
290        let b = Intent::with_timestamp(
291            "fix the auth bug", "ses_xyz", anthropic(), None, 0,
292        );
293        assert_ne!(a.intent_id, b.intent_id);
294    }
295
296    #[test]
297    fn different_models_hash_differently() {
298        let a = Intent::with_timestamp(
299            "fix the auth bug", "ses_abc", anthropic(), None, 0,
300        );
301        let mut model = anthropic();
302        model.name = "claude-sonnet-4-6".into();
303        let b = Intent::with_timestamp(
304            "fix the auth bug", "ses_abc", model, None, 0,
305        );
306        assert_ne!(a.intent_id, b.intent_id);
307    }
308
309    #[test]
310    fn refinement_chain_distinguishes_parent_intent() {
311        let a = Intent::with_timestamp(
312            "now also handle Y", "ses_abc", anthropic(), None, 0,
313        );
314        let b = Intent::with_timestamp(
315            "now also handle Y", "ses_abc", anthropic(),
316            Some("parent-intent-id".into()), 0,
317        );
318        assert_ne!(
319            a.intent_id, b.intent_id,
320            "an intent with a parent is causally distinct from one without",
321        );
322    }
323
324    #[test]
325    fn intent_id_is_64_char_lowercase_hex() {
326        let i = Intent::with_timestamp(
327            "test", "ses_abc", anthropic(), None, 0,
328        );
329        assert_eq!(i.intent_id.len(), 64);
330        assert!(i.intent_id.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
331    }
332
333    #[test]
334    fn round_trip_through_serde_json() {
335        let i = Intent::with_timestamp(
336            "fix the auth bug", "ses_abc", anthropic(),
337            Some("parent".into()), 12345,
338        );
339        let json = serde_json::to_string(&i).unwrap();
340        let back: Intent = serde_json::from_str(&json).unwrap();
341        assert_eq!(i, back);
342    }
343
344    /// Golden hash. If this changes, the canonical form has shifted
345    /// — every `IntentId` in every existing store has changed too.
346    /// That's a major-version event for the data model and should
347    /// be a deliberate decision; update with care. Same protective
348    /// shape as the operation.rs golden test.
349    #[test]
350    fn canonical_form_is_stable_for_a_known_input() {
351        let i = Intent::with_timestamp(
352            "fix the auth bug",
353            "ses_abc",
354            ModelDescriptor {
355                provider: "anthropic".into(),
356                name: "claude-opus-4-7".into(),
357                version: None,
358            },
359            None,
360            0,
361        );
362        assert_eq!(
363            i.intent_id,
364            "5ede62683a249cd00afff49fdf56e8f659fe878a668c8b61e36f5fbc1de7c734",
365        );
366    }
367
368    // ---- IntentLog ----
369
370    #[test]
371    fn intent_log_round_trips_through_disk() {
372        let tmp = tempfile::tempdir().unwrap();
373        let log = IntentLog::open(tmp.path()).unwrap();
374        let i = Intent::with_timestamp(
375            "fix the auth bug", "ses_abc", anthropic(), None, 100,
376        );
377        log.put(&i).unwrap();
378        let read_back = log.get(&i.intent_id).unwrap().unwrap();
379        assert_eq!(i, read_back);
380    }
381
382    #[test]
383    fn intent_log_get_unknown_returns_none() {
384        let tmp = tempfile::tempdir().unwrap();
385        let log = IntentLog::open(tmp.path()).unwrap();
386        assert!(log.get(&"nonexistent".to_string()).unwrap().is_none());
387    }
388
389    #[test]
390    fn intent_log_put_is_idempotent() {
391        let tmp = tempfile::tempdir().unwrap();
392        let log = IntentLog::open(tmp.path()).unwrap();
393        let i = Intent::with_timestamp(
394            "fix the auth bug", "ses_abc", anthropic(), None, 100,
395        );
396        log.put(&i).unwrap();
397        // Second put with the same content is a no-op (the file
398        // already exists; content addressing guarantees the bytes
399        // match).
400        log.put(&i).unwrap();
401        let read_back = log.get(&i.intent_id).unwrap().unwrap();
402        assert_eq!(i, read_back);
403    }
404}