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}