kranz_engine/ticket_notes.rs
1//! Ticket discussion notes — `.kranz/tickets/<slug>.notes.jsonl` (D-BW-3,
2//! adopted from beads' flat `{author, text, created_at}` comment model).
3//!
4//! One JSON object per line — `{ts, author, text}` — appended only: there is
5//! no edit or delete, mirroring the event log's honesty posture. Notes are
6//! the ticket-scoped "why" channel the frontmatter (structured) and body
7//! (authored once) cannot carry; they are COMMITTED artifacts (same class as
8//! the ticket `.md` itself — see AGENTS.md's tracked-vs-runtime list), not
9//! gitignored runtime state.
10//!
11//! Durability follows the [`crate::event_log`] idiom: open O_APPEND (creating
12//! on first append), one `write_all` of the full line, flush, fsync. Appends
13//! are clock-stamped at write time; file order IS chronological order, so
14//! reads never re-sort.
15
16use crate::error::{EngineError, Result};
17use crate::ticket::Ticket;
18use serde::{Deserialize, Serialize};
19use std::io::Write as _;
20use std::path::{Path, PathBuf};
21
22/// How many notes [`draft_context`] folds into the drafter's seed — the most
23/// recent N, so a long discussion cannot blow up the prompt.
24pub const MAX_DRAFT_NOTES: usize = 50;
25
26/// One note on a ticket. Field order is the on-disk key order (`ts`,
27/// `author`, `text`); additive-only like every persisted shape here.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct TicketNote {
30 pub ts: chrono::DateTime<chrono::Utc>,
31 pub author: String,
32 pub text: String,
33}
34
35/// Path of the notes sidecar for a slug. Slug validation happens in
36/// [`append_note`]/[`read_notes`] BEFORE this is ever joined, so a
37/// traversal-shaped slug cannot escape `.kranz/tickets/` (the same
38/// safe-id discipline as ticket reads).
39fn notes_path(repo_root: &Path, slug: &str) -> PathBuf {
40 Ticket::tickets_dir(repo_root).join(format!("{slug}.notes.jsonl"))
41}
42
43/// Append one note to a ticket's discussion, creating the sidecar on first
44/// append. `author` is supplied by the caller (the CLI resolves
45/// `KRANZ_NOTE_AUTHOR` → `operator`); `text` is trimmed, scrubbed (the file
46/// is committed — the same posture as text the engine writes into ticket
47/// bodies), and must not be empty. Returns the stored note.
48pub fn append_note(repo_root: &Path, slug: &str, author: &str, text: &str) -> Result<TicketNote> {
49 Ticket::ensure_valid_slug(slug)?;
50 let author = author.trim();
51 if author.is_empty() {
52 return Err(EngineError::Config(
53 "note author must not be empty (set KRANZ_NOTE_AUTHOR or omit it for 'operator')"
54 .to_string(),
55 ));
56 }
57 let text = crate::scrub::scrub(text.trim());
58 if text.is_empty() {
59 return Err(EngineError::Config(format!(
60 "refusing to record an empty note on ticket '{slug}'"
61 )));
62 }
63 let note = TicketNote {
64 ts: chrono::Utc::now(),
65 author: author.to_string(),
66 text,
67 };
68 let mut line = serde_json::to_string(¬e)?;
69 line.push('\n');
70
71 let dir = Ticket::tickets_dir(repo_root);
72 std::fs::create_dir_all(&dir)?;
73 let path = notes_path(repo_root, slug);
74 // Never append through a planted symlink (5th-pass review): a committed
75 // `<slug>.notes.jsonl` symlink would redirect the append into any
76 // same-user writable file. O_NOFOLLOW on unix makes the open itself
77 // refuse (ELOOP); off-unix the check-then-open window is documented
78 // (Windows symlink creation needs privileges).
79 #[cfg(unix)]
80 let mut file = {
81 use std::os::unix::fs::OpenOptionsExt as _;
82 std::fs::OpenOptions::new()
83 .append(true)
84 .create(true)
85 .custom_flags(libc::O_NOFOLLOW)
86 .open(&path)
87 .map_err(|e| {
88 if e.raw_os_error() == Some(libc::ELOOP) {
89 EngineError::InvalidState(format!(
90 "refusing ticket notes sidecar that is a symlink: {}",
91 path.display()
92 ))
93 } else {
94 EngineError::Io(e)
95 }
96 })?
97 };
98 #[cfg(not(unix))]
99 let mut file = {
100 crate::paths::ensure_absent_or_regular_file(&path)?;
101 std::fs::OpenOptions::new()
102 .append(true)
103 .create(true)
104 .open(&path)?
105 };
106 // The event-log idiom: O_APPEND + create, a single write of the whole
107 // line, then fsync — a concurrent appender can interleave between notes
108 // but never within one.
109 file.write_all(line.as_bytes())?;
110 file.flush()?;
111 file.sync_data()?;
112 Ok(note)
113}
114
115/// Read every note in file order (== chronological order, oldest first). A
116/// missing sidecar is the normal "no notes yet" case and reads as empty. A
117/// malformed line is an [`EngineError::Config`] naming the file and line —
118/// notes are a committed, tool-maintained record, so corruption is surfaced
119/// for repair rather than silently skipped.
120pub fn read_notes(repo_root: &Path, slug: &str) -> Result<Vec<TicketNote>> {
121 Ticket::ensure_valid_slug(slug)?;
122 let path = notes_path(repo_root, slug);
123 let text = match crate::paths::open_read_nofollow(&path) {
124 Ok(mut file) => {
125 use std::io::Read as _;
126 let mut text = String::new();
127 file.read_to_string(&mut text)?;
128 text
129 }
130 Err(e) if matches!(&e, EngineError::Io(io) if io.kind() == std::io::ErrorKind::NotFound) => {
131 return Ok(Vec::new())
132 }
133 Err(e) => return Err(e),
134 };
135 let mut notes = Vec::new();
136 for (i, line) in text.lines().enumerate() {
137 if line.trim().is_empty() {
138 continue;
139 }
140 let note = serde_json::from_str::<TicketNote>(line).map_err(|e| {
141 EngineError::Config(format!(
142 "corrupt ticket notes file {} (line {}): {e}",
143 path.display(),
144 i + 1
145 ))
146 })?;
147 notes.push(note);
148 }
149 Ok(notes)
150}
151
152/// The markdown section [`crate::draft::drive_draft`] appends to the
153/// drafter's seed: the most recent [`MAX_DRAFT_NOTES`] notes as bullets, with
154/// an omission marker when older ones were capped. `None` when the ticket
155/// has no notes (the seed is then exactly the folded ticket, as before).
156pub fn draft_context(repo_root: &Path, slug: &str) -> Result<Option<String>> {
157 let notes = read_notes(repo_root, slug)?;
158 if notes.is_empty() {
159 return Ok(None);
160 }
161 let start = notes.len().saturating_sub(MAX_DRAFT_NOTES);
162 let mut out = String::from("\n\n## Ticket notes\n");
163 if start > 0 {
164 out.push_str(&format!(
165 "(most recent {MAX_DRAFT_NOTES} of {} notes)\n",
166 notes.len()
167 ));
168 }
169 for note in ¬es[start..] {
170 out.push_str(&format!(
171 "- [{}] {}: {}\n",
172 note.ts.to_rfc3339(),
173 note.author,
174 note.text
175 ));
176 }
177 Ok(Some(out))
178}