Skip to main content

lex_vcs/
issue.rs

1//! Typed issues — units of work with a declared, verifiable acceptance (#949).
2//!
3//! A GitHub issue is free text and "done" is a human judgment, which is why an
4//! issue can never have a 1-1 relation to its implementation: there is nothing
5//! to check against. Here an issue is a **typed intent with a declared oracle**
6//! — its [`Acceptance`] — and *done is a proof the gate verifies at HEAD*
7//! (phase 2, #951), not a status someone sets. This is the always-valid-HEAD
8//! invariant extended from code to work items.
9//!
10//! The record mirrors [`crate::Intent`]: content-addressed identity (so the
11//! same logical issue dedups and travels idempotently), one canonical-JSON
12//! file per issue in the store, and transfer over the same object-sync path
13//! as stages, intents and locks. An op that realizes an issue carries its id
14//! in the op's intent, so provenance links issue ↔ intent ↔ ops ↔ attestation.
15//!
16//! Five acceptance shapes (the oracle kinds — deliberately not one mold):
17//! typed delta, failing example, metric/invariant, evidence, free-form. Shapes
18//! 1–4 are machine-evaluable; shape 5 is the explicit, human-closed exception.
19
20use serde::{Deserialize, Serialize};
21use std::collections::BTreeSet;
22use std::fs;
23use std::io::{self, Write};
24use std::path::{Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use crate::canonical;
28
29/// Content-addressed identity of an issue: lowercase-hex SHA-256 of the
30/// canonical form of everything but `created_at` (timestamp drift must not
31/// change what issue this is).
32pub type IssueId = String;
33
34/// How a declared public-API entry is expected to change.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum ApiChangeKind {
38    #[default]
39    Added,
40    Changed,
41    Removed,
42}
43
44/// One entry of a typed delta: a public declaration and the signature it
45/// should have after the work (the shape `api-diff` reports, so the evaluator
46/// can compare like with like).
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ApiEntry {
49    pub name: String,
50    pub signature: String,
51    #[serde(default)]
52    pub kind: ApiChangeKind,
53}
54
55/// The declared oracle — what must hold for the issue to be done. Tagged by
56/// `shape` in JSON (`{"shape": "typed_delta", ...}`).
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "shape", rename_all = "snake_case")]
59pub enum Acceptance {
60    /// Declares the public API that should exist after, plus behavioral
61    /// examples (Lex `examples {}` source). Done when `api-diff(base, head)`
62    /// realizes the delta and the examples pass. Features, new modules,
63    /// analytics/finance *functions*.
64    TypedDelta {
65        api: Vec<ApiEntry>,
66        #[serde(default, skip_serializing_if = "Vec::is_empty")]
67        examples: Vec<String>,
68    },
69    /// A behavioral example that fails at `base`; fixed = it passes. A bug
70    /// report *is* a reproducible failing example.
71    FailingExample { example: String },
72    /// A typed predicate over the event backbone that must hold over a
73    /// window (`p99 < 200ms for 7d`, `churn <= X`, `balances reconcile`).
74    /// Monitoring, ops, growth, product *outcomes*, finance invariants.
75    MetricInvariant { predicate: String, window: String },
76    /// An attested evidence chain exists and its invariants hold (finance
77    /// close, compliance, custody).
78    Evidence {
79        subject: String,
80        #[serde(default, skip_serializing_if = "Vec::is_empty")]
81        invariants: Vec<String>,
82    },
83    /// Human-closed. The explicit exception — kept small, never the default.
84    FreeForm {},
85}
86
87impl Acceptance {
88    /// The shape's stable name, as it appears in JSON.
89    pub fn shape(&self) -> &'static str {
90        match self {
91            Acceptance::TypedDelta { .. } => "typed_delta",
92            Acceptance::FailingExample { .. } => "failing_example",
93            Acceptance::MetricInvariant { .. } => "metric_invariant",
94            Acceptance::Evidence { .. } => "evidence",
95            Acceptance::FreeForm {} => "free_form",
96        }
97    }
98
99    /// Whether the gate can evaluate this shape mechanically. Only the
100    /// free-form shape is not — a human closes it and the record says so.
101    pub fn is_machine_evaluable(&self) -> bool {
102        !matches!(self, Acceptance::FreeForm {})
103    }
104}
105
106/// The persisted issue.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Issue {
109    pub issue_id: IssueId,
110    pub title: String,
111    /// Free text is welcome — it just isn't the acceptance.
112    #[serde(default, skip_serializing_if = "String::is_empty")]
113    pub body: String,
114    pub acceptance: Acceptance,
115    /// The head the acceptance is declared against (`api-diff(base, head)`
116    /// for a typed delta; where a failing example is observed to fail).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub base: Option<String>,
119    /// Blocking dependencies: this issue is *blocked* until each is verified.
120    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
121    pub deps: BTreeSet<IssueId>,
122    /// Optional project membership (a project is a subgraph with a goal).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub project: Option<String>,
125    /// Wall-clock seconds since epoch at creation. Excluded from `issue_id`.
126    pub created_at: u64,
127}
128
129impl Issue {
130    /// Build an issue and compute its content-addressed id, stamping the
131    /// current wall clock. Use [`Issue::with_timestamp`] to control the
132    /// timestamp (tests).
133    pub fn new(
134        title: impl Into<String>,
135        body: impl Into<String>,
136        acceptance: Acceptance,
137        base: Option<String>,
138        deps: BTreeSet<IssueId>,
139        project: Option<String>,
140    ) -> Self {
141        let now = SystemTime::now()
142            .duration_since(UNIX_EPOCH)
143            .map(|d| d.as_secs())
144            .unwrap_or(0);
145        Self::with_timestamp(title, body, acceptance, base, deps, project, now)
146    }
147
148    #[allow(clippy::too_many_arguments)]
149    pub fn with_timestamp(
150        title: impl Into<String>,
151        body: impl Into<String>,
152        acceptance: Acceptance,
153        base: Option<String>,
154        deps: BTreeSet<IssueId>,
155        project: Option<String>,
156        created_at: u64,
157    ) -> Self {
158        let title = title.into();
159        let body = body.into();
160        let issue_id = compute_issue_id(
161            &title,
162            &body,
163            &acceptance,
164            base.as_deref(),
165            &deps,
166            project.as_deref(),
167        );
168        Self { issue_id, title, body, acceptance, base, deps, project, created_at }
169    }
170
171    /// The id this issue's content hashes to. Equal to `issue_id` for a
172    /// well-formed record; a peer receiving issues over the wire compares the
173    /// two so a mismatched id (tampered or miscomputed) is refused instead of
174    /// being filed under a name its content doesn't own.
175    pub fn computed_id(&self) -> IssueId {
176        compute_issue_id(
177            &self.title,
178            &self.body,
179            &self.acceptance,
180            self.base.as_deref(),
181            &self.deps,
182            self.project.as_deref(),
183        )
184    }
185
186    /// `issue_id` matches the content hash.
187    pub fn id_is_consistent(&self) -> bool {
188        self.issue_id == self.computed_id()
189    }
190}
191
192fn compute_issue_id(
193    title: &str,
194    body: &str,
195    acceptance: &Acceptance,
196    base: Option<&str>,
197    deps: &BTreeSet<IssueId>,
198    project: Option<&str>,
199) -> IssueId {
200    let view = CanonicalIssueView { title, body, acceptance, base, deps, project };
201    canonical::hash(&view)
202}
203
204/// Hashable shadow of [`Issue`] omitting `issue_id` (being computed) and
205/// `created_at` (timestamp drift would break dedup).
206#[derive(Serialize)]
207struct CanonicalIssueView<'a> {
208    title: &'a str,
209    body: &'a str,
210    acceptance: &'a Acceptance,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    base: Option<&'a str>,
213    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
214    deps: &'a BTreeSet<IssueId>,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    project: Option<&'a str>,
217}
218
219// ---- Persistence -------------------------------------------------
220
221/// Persistent log of [`Issue`] records: one canonical-JSON file per issue
222/// under `<root>/issues/`, atomic writes via tempfile + rename, idempotent on
223/// re-puts. Mirrors [`crate::IntentLog`].
224pub struct IssueLog {
225    dir: PathBuf,
226}
227
228impl IssueLog {
229    pub fn open(root: &Path) -> io::Result<Self> {
230        let dir = root.join("issues");
231        fs::create_dir_all(&dir)?;
232        Ok(Self { dir })
233    }
234
235    fn path(&self, id: &IssueId) -> PathBuf {
236        self.dir.join(format!("{id}.json"))
237    }
238
239    /// Persist an issue. Idempotent on existing ids (content-addressed, so
240    /// the bytes must match).
241    pub fn put(&self, issue: &Issue) -> io::Result<()> {
242        let path = self.path(&issue.issue_id);
243        if path.exists() {
244            return Ok(());
245        }
246        let bytes = serde_json::to_vec(issue)
247            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
248        let tmp = path.with_extension("json.tmp");
249        let mut f = fs::File::create(&tmp)?;
250        f.write_all(&bytes)?;
251        f.sync_all()?;
252        fs::rename(&tmp, &path)?;
253        Ok(())
254    }
255
256    pub fn get(&self, id: &IssueId) -> io::Result<Option<Issue>> {
257        let path = self.path(id);
258        if !path.exists() {
259            return Ok(None);
260        }
261        let bytes = fs::read(&path)?;
262        let issue: Issue = serde_json::from_slice(&bytes)
263            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
264        Ok(Some(issue))
265    }
266
267    /// Every issue id in the log, sorted. Issues can exist before any op
268    /// references them (open work), so sync moves the whole log rather than
269    /// only the ids reachable from pushed ops.
270    pub fn list_ids(&self) -> io::Result<Vec<IssueId>> {
271        let mut ids = Vec::new();
272        for entry in fs::read_dir(&self.dir)? {
273            let path = entry?.path();
274            if path.extension().and_then(|e| e.to_str()) != Some("json") {
275                continue;
276            }
277            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
278                ids.push(stem.to_string());
279            }
280        }
281        ids.sort();
282        Ok(ids)
283    }
284}
285
286// ---- Tests --------------------------------------------------------
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn gcd_delta() -> Acceptance {
293        Acceptance::TypedDelta {
294            api: vec![ApiEntry {
295                name: "gcd".into(),
296                signature: "(Int, Int) -> Int".into(),
297                kind: ApiChangeKind::Added,
298            }],
299            examples: vec!["gcd(12, 8) == 4".into()],
300        }
301    }
302
303    #[test]
304    fn same_content_hashes_equal_regardless_of_timestamp() {
305        let a = Issue::with_timestamp("add gcd", "", gcd_delta(), None, BTreeSet::new(), None, 1);
306        let b = Issue::with_timestamp("add gcd", "", gcd_delta(), None, BTreeSet::new(), None, 999);
307        assert_eq!(a.issue_id, b.issue_id, "created_at must not affect identity");
308    }
309
310    #[test]
311    fn different_acceptance_hashes_differ() {
312        let a = Issue::with_timestamp("x", "", gcd_delta(), None, BTreeSet::new(), None, 1);
313        let b = Issue::with_timestamp(
314            "x", "", Acceptance::FailingExample { example: "gcd(12, 8) == 4".into() },
315            None, BTreeSet::new(), None, 1,
316        );
317        assert_ne!(a.issue_id, b.issue_id);
318    }
319
320    #[test]
321    fn shape_tag_round_trips_through_json() {
322        let i = Issue::with_timestamp("x", "b", gcd_delta(), Some("op_1".into()), BTreeSet::new(), None, 1);
323        let json = serde_json::to_string(&i).unwrap();
324        assert!(json.contains("\"shape\":\"typed_delta\""), "{json}");
325        let back: Issue = serde_json::from_str(&json).unwrap();
326        assert_eq!(back, i);
327        let ff = Issue::with_timestamp("y", "", Acceptance::FreeForm {}, None, BTreeSet::new(), None, 1);
328        let json = serde_json::to_string(&ff).unwrap();
329        assert!(json.contains("\"shape\":\"free_form\""), "{json}");
330        assert!(!ff.acceptance.is_machine_evaluable());
331        assert!(i.acceptance.is_machine_evaluable());
332    }
333
334    #[test]
335    fn log_put_get_list_and_idempotent_put() {
336        let tmp = tempfile::tempdir().unwrap();
337        let log = IssueLog::open(tmp.path()).unwrap();
338        let i = Issue::with_timestamp("x", "", gcd_delta(), None, BTreeSet::new(), None, 1);
339        log.put(&i).unwrap();
340        log.put(&i).unwrap(); // idempotent
341        assert_eq!(log.get(&i.issue_id).unwrap(), Some(i.clone()));
342        assert_eq!(log.list_ids().unwrap(), vec![i.issue_id.clone()]);
343        assert_eq!(log.get(&"missing".to_string()).unwrap(), None);
344    }
345}