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    /// Where this intent came from when it was imported from another VCS
95    /// (#892): the source commit's SHA, author/committer identity and
96    /// dates, parents and any commits folded into it. `None` for native
97    /// intents; omitted from the serialized form (and the id hash) when
98    /// `None`, so every intent without an origin keeps its id byte-for-byte.
99    /// The commit *message* is not duplicated here — it stays in `prompt`.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub origin: Option<Origin>,
102    /// Wall-clock seconds since epoch when this intent was first
103    /// created. Excluded from `intent_id` so the dedup property
104    /// holds across runs.
105    pub created_at: u64,
106}
107
108impl Intent {
109    /// Build an intent and compute its content-addressed id.
110    /// `created_at` is filled in from the current wall clock; pass
111    /// to [`Intent::with_timestamp`] if you want to control it
112    /// explicitly (e.g. in tests).
113    pub fn new(
114        prompt: impl Into<String>,
115        session_id: impl Into<SessionId>,
116        model: ModelDescriptor,
117        parent_intent: Option<IntentId>,
118    ) -> Self {
119        let now = SystemTime::now()
120            .duration_since(UNIX_EPOCH)
121            .map(|d| d.as_secs())
122            .unwrap_or(0);
123        Self::with_timestamp(prompt, session_id, model, parent_intent, now)
124    }
125
126    /// Build an intent with a caller-controlled `created_at`. Used
127    /// in tests to keep golden hashes stable; production code uses
128    /// [`Intent::new`].
129    pub fn with_timestamp(
130        prompt: impl Into<String>,
131        session_id: impl Into<SessionId>,
132        model: ModelDescriptor,
133        parent_intent: Option<IntentId>,
134        created_at: u64,
135    ) -> Self {
136        let prompt = prompt.into();
137        let session_id = session_id.into();
138        let intent_id =
139            compute_intent_id(&prompt, &session_id, &model, parent_intent.as_deref(), None, None);
140        Self {
141            intent_id,
142            prompt,
143            session_id,
144            model,
145            parent_intent,
146            issue_id: None,
147            origin: None,
148            created_at,
149        }
150    }
151
152    /// Attach the typed issue this intent realizes (#949), recomputing the
153    /// id: "implement issue X" and the same prompt with no issue are
154    /// distinct intents. An intent without an issue serializes exactly as
155    /// before, so pre-existing ids are unchanged.
156    pub fn with_issue(mut self, issue_id: crate::issue::IssueId) -> Self {
157        self.intent_id = compute_intent_id(
158            &self.prompt,
159            &self.session_id,
160            &self.model,
161            self.parent_intent.as_deref(),
162            Some(issue_id.as_str()),
163            self.origin.as_ref(),
164        );
165        self.issue_id = Some(issue_id);
166        self
167    }
168
169    /// Attach the external-VCS provenance of this intent (#892),
170    /// recomputing the id: the same prompt imported from two different
171    /// commits (or authors, or dates) is two distinct intents. An intent
172    /// without an origin serializes and hashes exactly as before.
173    pub fn with_origin(mut self, origin: Origin) -> Self {
174        self.intent_id = compute_intent_id(
175            &self.prompt,
176            &self.session_id,
177            &self.model,
178            self.parent_intent.as_deref(),
179            self.issue_id.as_deref(),
180            Some(&origin),
181        );
182        self.origin = Some(origin);
183        self
184    }
185}
186
187/// A person as recorded by the source VCS (#892): identity plus the
188/// timestamp and timezone the source recorded for them. Plain values only
189/// (no maps), so the canonical encoding is deterministic.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct Person {
192    pub name: String,
193    pub email: String,
194    /// Seconds since the Unix epoch, as the source VCS recorded it.
195    pub when: i64,
196    /// UTC offset as the source wrote it, e.g. `"+0200"`. Kept verbatim
197    /// (not derived from `when`) so an export can reproduce it.
198    pub tz: String,
199}
200
201/// Provenance of an intent imported from another VCS (#892). Hashed into
202/// the intent id, so the field order below and the `Vec`/struct-only shape
203/// are part of the canonical form — do not reorder.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct Origin {
206    /// Source VCS kind; `"git"` today.
207    pub vcs: String,
208    /// The full source commit id (40/64 hex for git).
209    pub commit: String,
210    pub author: Person,
211    /// `None` when the source recorded no distinct committer.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub committer: Option<Person>,
214    /// All parents of the source commit, in source order; the first is the
215    /// line that was imported.
216    #[serde(default)]
217    pub parents: Vec<String>,
218    /// Source commits folded into this one because they were skipped
219    /// (e.g. did not type-check), oldest first.
220    #[serde(default)]
221    pub folded: Vec<String>,
222}
223
224fn compute_intent_id(
225    prompt: &str,
226    session_id: &str,
227    model: &ModelDescriptor,
228    parent_intent: Option<&str>,
229    issue_id: Option<&str>,
230    origin: Option<&Origin>,
231) -> IntentId {
232    let view = CanonicalIntentView {
233        prompt,
234        session_id,
235        model,
236        parent_intent,
237        issue_id,
238        origin,
239    };
240    canonical::hash(&view)
241}
242
243/// Hashable shadow of [`Intent`] omitting `intent_id` (we're
244/// computing it) and `created_at` (timestamp drift would break
245/// dedup). Lives only as a transient for hashing.
246#[derive(Serialize)]
247struct CanonicalIntentView<'a> {
248    prompt: &'a str,
249    session_id: &'a str,
250    model: &'a ModelDescriptor,
251    #[serde(skip_serializing_if = "Option::is_none")]
252    parent_intent: Option<&'a str>,
253    /// Omitted when `None` so an intent with no issue hashes exactly as it
254    /// did before #949 — id stability for every pre-existing intent.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    issue_id: Option<&'a str>,
257    /// Omitted when `None` so an intent with no origin hashes exactly as it
258    /// did before #892 — id stability for every pre-existing intent.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    origin: Option<&'a Origin>,
261}
262
263// ---- Persistence -------------------------------------------------
264
265/// Persistent log of [`Intent`] records. Mirrors [`crate::OpLog`]'s
266/// shape: one canonical-JSON file per intent, atomic writes via
267/// tempfile + rename, idempotent on re-puts.
268pub struct IntentLog {
269    dir: PathBuf,
270}
271
272impl IntentLog {
273    pub fn open(root: &Path) -> io::Result<Self> {
274        let dir = root.join("intents");
275        fs::create_dir_all(&dir)?;
276        Ok(Self { dir })
277    }
278
279    fn path(&self, id: &IntentId) -> PathBuf {
280        self.dir.join(format!("{id}.json"))
281    }
282
283    /// Persist an intent. Idempotent on existing ids — the bytes
284    /// must match by content addressing, so re-putting the same
285    /// intent is a no-op.
286    pub fn put(&self, intent: &Intent) -> io::Result<()> {
287        let path = self.path(&intent.intent_id);
288        if path.exists() {
289            return Ok(());
290        }
291        let bytes = serde_json::to_vec(intent)
292            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
293        let tmp = path.with_extension("json.tmp");
294        let mut f = fs::File::create(&tmp)?;
295        f.write_all(&bytes)?;
296        f.sync_all()?;
297        fs::rename(&tmp, &path)?;
298        Ok(())
299    }
300
301    pub fn get(&self, id: &IntentId) -> io::Result<Option<Intent>> {
302        let path = self.path(id);
303        if !path.exists() {
304            return Ok(None);
305        }
306        let bytes = fs::read(&path)?;
307        let intent: Intent = serde_json::from_slice(&bytes)
308            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
309        Ok(Some(intent))
310    }
311}
312
313// ---- Tests --------------------------------------------------------
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn anthropic() -> ModelDescriptor {
320        ModelDescriptor {
321            provider: "anthropic".into(),
322            name: "claude-opus-4-7".into(),
323            version: None,
324        }
325    }
326
327    #[test]
328    fn same_prompt_session_model_hashes_equal() {
329        // The load-bearing dedup invariant: the same logical
330        // intent (same prompt, same session, same model) should
331        // produce the same `intent_id` regardless of which agent
332        // session re-recorded it. `created_at` differs but is not
333        // in the hash.
334        let a = Intent::with_timestamp(
335            "fix the auth bug", "ses_abc", anthropic(), None, 1000,
336        );
337        let b = Intent::with_timestamp(
338            "fix the auth bug", "ses_abc", anthropic(), None, 99999,
339        );
340        assert_eq!(a.intent_id, b.intent_id);
341        assert_ne!(a.created_at, b.created_at);
342    }
343
344    #[test]
345    fn different_prompts_hash_differently() {
346        let a = Intent::with_timestamp(
347            "fix the auth bug", "ses_abc", anthropic(), None, 0,
348        );
349        let b = Intent::with_timestamp(
350            "fix the cache bug", "ses_abc", anthropic(), None, 0,
351        );
352        assert_ne!(a.intent_id, b.intent_id);
353    }
354
355    #[test]
356    fn different_sessions_hash_differently() {
357        let a = Intent::with_timestamp(
358            "fix the auth bug", "ses_abc", anthropic(), None, 0,
359        );
360        let b = Intent::with_timestamp(
361            "fix the auth bug", "ses_xyz", anthropic(), None, 0,
362        );
363        assert_ne!(a.intent_id, b.intent_id);
364    }
365
366    #[test]
367    fn different_models_hash_differently() {
368        let a = Intent::with_timestamp(
369            "fix the auth bug", "ses_abc", anthropic(), None, 0,
370        );
371        let mut model = anthropic();
372        model.name = "claude-sonnet-4-6".into();
373        let b = Intent::with_timestamp(
374            "fix the auth bug", "ses_abc", model, None, 0,
375        );
376        assert_ne!(a.intent_id, b.intent_id);
377    }
378
379    #[test]
380    fn refinement_chain_distinguishes_parent_intent() {
381        let a = Intent::with_timestamp(
382            "now also handle Y", "ses_abc", anthropic(), None, 0,
383        );
384        let b = Intent::with_timestamp(
385            "now also handle Y", "ses_abc", anthropic(),
386            Some("parent-intent-id".into()), 0,
387        );
388        assert_ne!(
389            a.intent_id, b.intent_id,
390            "an intent with a parent is causally distinct from one without",
391        );
392    }
393
394    #[test]
395    fn intent_id_is_64_char_lowercase_hex() {
396        let i = Intent::with_timestamp(
397            "test", "ses_abc", anthropic(), None, 0,
398        );
399        assert_eq!(i.intent_id.len(), 64);
400        assert!(i.intent_id.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
401    }
402
403    #[test]
404    fn round_trip_through_serde_json() {
405        let i = Intent::with_timestamp(
406            "fix the auth bug", "ses_abc", anthropic(),
407            Some("parent".into()), 12345,
408        );
409        let json = serde_json::to_string(&i).unwrap();
410        let back: Intent = serde_json::from_str(&json).unwrap();
411        assert_eq!(i, back);
412    }
413
414    /// Golden hash. If this changes, the canonical form has shifted
415    /// — every `IntentId` in every existing store has changed too.
416    /// That's a major-version event for the data model and should
417    /// be a deliberate decision; update with care. Same protective
418    /// shape as the operation.rs golden test.
419    #[test]
420    fn canonical_form_is_stable_for_a_known_input() {
421        let i = Intent::with_timestamp(
422            "fix the auth bug",
423            "ses_abc",
424            ModelDescriptor {
425                provider: "anthropic".into(),
426                name: "claude-opus-4-7".into(),
427                version: None,
428            },
429            None,
430            0,
431        );
432        assert_eq!(
433            i.intent_id,
434            "5ede62683a249cd00afff49fdf56e8f659fe878a668c8b61e36f5fbc1de7c734",
435        );
436    }
437
438    // ---- IntentLog ----
439
440    #[test]
441    fn intent_log_round_trips_through_disk() {
442        let tmp = tempfile::tempdir().unwrap();
443        let log = IntentLog::open(tmp.path()).unwrap();
444        let i = Intent::with_timestamp(
445            "fix the auth bug", "ses_abc", anthropic(), None, 100,
446        );
447        log.put(&i).unwrap();
448        let read_back = log.get(&i.intent_id).unwrap().unwrap();
449        assert_eq!(i, read_back);
450    }
451
452    #[test]
453    fn intent_log_get_unknown_returns_none() {
454        let tmp = tempfile::tempdir().unwrap();
455        let log = IntentLog::open(tmp.path()).unwrap();
456        assert!(log.get(&"nonexistent".to_string()).unwrap().is_none());
457    }
458
459    #[test]
460    fn intent_log_put_is_idempotent() {
461        let tmp = tempfile::tempdir().unwrap();
462        let log = IntentLog::open(tmp.path()).unwrap();
463        let i = Intent::with_timestamp(
464            "fix the auth bug", "ses_abc", anthropic(), None, 100,
465        );
466        log.put(&i).unwrap();
467        // Second put with the same content is a no-op (the file
468        // already exists; content addressing guarantees the bytes
469        // match).
470        log.put(&i).unwrap();
471        let read_back = log.get(&i.intent_id).unwrap().unwrap();
472        assert_eq!(i, read_back);
473    }
474}