ticket: add typed thread transition events

This commit is contained in:
Keisuke Hirata 2026-06-07 07:34:01 +09:00
parent 2cc0ea9f31
commit e87b14fa1e
No known key found for this signature in database
3 changed files with 607 additions and 23 deletions

View File

@ -35,6 +35,8 @@ const REQUIRED_FIELDS: [&str; 11] = [
"assignee",
"legacy_ticket",
];
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
pub type Result<T> = std::result::Result<T, TicketError>;
@ -211,6 +213,8 @@ pub enum TicketEventKind {
Decision,
ImplementationReport,
Review,
StateChanged,
IntakeSummary,
StatusChanged,
Close,
Other(String),
@ -225,6 +229,8 @@ impl TicketEventKind {
Self::Decision => "decision",
Self::ImplementationReport => "implementation_report",
Self::Review => "review",
Self::StateChanged => "state_changed",
Self::IntakeSummary => "intake_summary",
Self::StatusChanged => "status_changed",
Self::Close => "close",
Self::Other(value) => value.as_str(),
@ -239,6 +245,8 @@ impl TicketEventKind {
Self::Decision => "Decision".to_string(),
Self::ImplementationReport => "Implementation report".to_string(),
Self::Review => "Review".to_string(),
Self::StateChanged => "State changed".to_string(),
Self::IntakeSummary => "Intake summary".to_string(),
Self::StatusChanged => "Status changed".to_string(),
Self::Close => "Closed".to_string(),
Self::Other(value) => value.clone(),
@ -255,6 +263,8 @@ impl From<&str> for TicketEventKind {
"decision" => Self::Decision,
"implementation_report" => Self::ImplementationReport,
"review" => Self::Review,
"state_changed" => Self::StateChanged,
"intake_summary" => Self::IntakeSummary,
"status_changed" => Self::StatusChanged,
"close" | "closed" => Self::Close,
other => Self::Other(other.to_string()),
@ -322,6 +332,51 @@ impl NewTicketEvent {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketStateChange {
pub from: String,
pub to: String,
pub author: Option<String>,
pub reason: String,
pub body: MarkdownText,
pub references: Vec<TicketReference>,
}
impl TicketStateChange {
pub fn new(
from: impl Into<String>,
to: impl Into<String>,
reason: impl Into<String>,
body: impl Into<MarkdownText>,
) -> Self {
Self {
from: from.into(),
to: to.into(),
author: None,
reason: reason.into(),
body: body.into(),
references: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketIntakeSummary {
pub author: Option<String>,
pub body: MarkdownText,
pub references: Vec<TicketReference>,
}
impl TicketIntakeSummary {
pub fn new(body: impl Into<MarkdownText>) -> Self {
Self {
author: None,
body: body.into(),
references: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketReview {
pub result: TicketReviewResult,
@ -457,9 +512,14 @@ pub struct TicketEvent {
pub author: Option<String>,
pub at: Option<String>,
pub status: Option<String>,
pub from: Option<String>,
pub to: Option<String>,
pub reason: Option<String>,
pub state_field: Option<String>,
pub heading: Option<String>,
pub body: MarkdownText,
pub references: Vec<TicketReference>,
pub attributes: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -529,6 +589,14 @@ pub trait TicketBackend {
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
fn create(&self, input: NewTicket) -> Result<TicketRef>;
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>;
fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>;
fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>;
fn set_state_field(
&self,
id: TicketIdOrSlug,
field: &str,
change: TicketStateChange,
) -> Result<()>;
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
@ -668,9 +736,18 @@ impl LocalTicketBackend {
heading: &str,
author: &str,
status: Option<&str>,
attrs: &[(&str, &str)],
body: &MarkdownText,
) -> Result<()> {
let at = now_utc();
let mut event_attrs = vec![("event", event), ("author", author), ("at", at.as_str())];
if let Some(status) = status {
event_attrs.push(("status", status));
}
event_attrs.extend_from_slice(attrs);
let comment = render_event_comment(&event_attrs)?;
let entry = format!("\n{comment}\n\n## {heading}\n\n{}\n\n---\n", body.as_str());
let thread = dir.join("thread.md");
if !thread.exists() {
File::create(&thread).map_err(|e| io_err(&thread, e))?;
@ -679,17 +756,53 @@ impl LocalTicketBackend {
.append(true)
.open(&thread)
.map_err(|e| io_err(&thread, e))?;
write!(file, "\n<!-- event: {event} author: {author} at: {at}")
.map_err(|e| io_err(&thread, e))?;
if let Some(status) = status {
write!(file, " status: {status}").map_err(|e| io_err(&thread, e))?;
}
write!(file, " -->\n\n## {heading}\n\n{}\n\n---\n", body.as_str())
file.write_all(entry.as_bytes())
.map_err(|e| io_err(&thread, e))?;
file.sync_data().map_err(|e| io_err(&thread, e))?;
self.set_frontmatter_fields(&dir.join("item.md"), &[("updated_at", at.as_str())])
}
fn append_state_changed_event(
&self,
dir: &Path,
change: &TicketStateChange,
state_field: Option<&str>,
) -> Result<()> {
validate_state_change(change)?;
let author = change.author.clone().unwrap_or_else(default_author);
let mut attrs = vec![
("from", change.from.as_str()),
("to", change.to.as_str()),
("reason", change.reason.as_str()),
];
if let Some(state_field) = state_field {
attrs.push(("field", state_field));
}
self.append_thread_event(
dir,
TicketEventKind::StateChanged.as_str(),
&TicketEventKind::StateChanged.heading(),
&author,
None,
&attrs,
&change.body,
)
}
fn append_intake_summary_event(&self, dir: &Path, summary: &TicketIntakeSummary) -> Result<()> {
validate_intake_summary(summary)?;
let author = summary.author.clone().unwrap_or_else(default_author);
self.append_thread_event(
dir,
TicketEventKind::IntakeSummary.as_str(),
&TicketEventKind::IntakeSummary.heading(),
&author,
None,
&[],
&summary.body,
)
}
fn set_frontmatter_fields(&self, item: &Path, updates: &[(&str, &str)]) -> Result<()> {
let content = fs::read_to_string(item).map_err(|e| io_err(item, e))?;
let updated = replace_frontmatter_fields(&content, updates).map_err(|message| {
@ -765,9 +878,18 @@ impl TicketBackend for LocalTicketBackend {
)));
}
ensure_child_of(&self.root, &dir)?;
let created = now_utc();
let author = input
.author
.unwrap_or_else(|| "LocalTicketBackend".to_string());
let create_comment = render_event_comment(&[
("event", TicketEventKind::Create.as_str()),
("author", &author),
("at", &created),
])?;
fs::create_dir_all(dir.join("artifacts")).map_err(|e| io_err(&dir, e))?;
atomic_write(&dir.join("artifacts/.gitkeep"), b"")?;
let created = now_utc();
let mut fields = Vec::new();
fields.push(("id".to_string(), id.clone()));
fields.push(("slug".to_string(), slug.clone()));
@ -800,11 +922,8 @@ impl TicketBackend for LocalTicketBackend {
}
let item = serialize_item(&fields, input.body.as_str());
atomic_write(&dir.join("item.md"), item.as_bytes())?;
let author = input
.author
.unwrap_or_else(|| "LocalTicketBackend".to_string());
let thread = format!(
"<!-- event: create author: {author} at: {created} -->\n\n## Created\n\nCreated by LocalTicketBackend create.\n\n---\n"
"{create_comment}\n\n## Created\n\nCreated by LocalTicketBackend create.\n\n---\n"
);
atomic_write(&dir.join("thread.md"), thread.as_bytes())?;
Ok(TicketRef {
@ -824,10 +943,49 @@ impl TicketBackend for LocalTicketBackend {
&event.kind.heading(),
&author,
None,
&[],
&event.body,
)
}
fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> {
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
self.append_state_changed_event(&dir, &change, None)
}
fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()> {
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
self.append_intake_summary_event(&dir, &summary)
}
fn set_state_field(
&self,
id: TicketIdOrSlug,
field: &str,
change: TicketStateChange,
) -> Result<()> {
validate_state_field_name(field)?;
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
let item = dir.join("item.md");
let parsed = read_item_file(&item)?;
let current = parsed
.frontmatter
.get(field)
.map(String::as_str)
.unwrap_or("");
if current != change.from.as_str() {
return Err(TicketError::Conflict(format!(
"state field `{field}` changed concurrently: expected `{}`, found `{current}`",
change.from
)));
}
self.append_state_changed_event(&dir, &change, Some(field))?;
self.set_frontmatter_fields(&item, &[(field, change.to.as_str())])
}
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()> {
let _lock = self.acquire_lock()?;
let dir = self.find_ticket_dir(&id)?;
@ -838,6 +996,7 @@ impl TicketBackend for LocalTicketBackend {
&review.result.heading(),
&author,
Some(review.result.as_str()),
&[],
&review.body,
)
}
@ -873,6 +1032,7 @@ impl TicketBackend for LocalTicketBackend {
"Status changed",
&author,
Some(status.as_str()),
&[],
&body,
)
}
@ -915,6 +1075,7 @@ impl TicketBackend for LocalTicketBackend {
"Closed",
&author,
Some("closed"),
&[],
&resolution,
)
}
@ -1270,6 +1431,114 @@ fn replace_frontmatter_fields(
Ok(out)
}
fn render_event_comment(attrs: &[(&str, &str)]) -> Result<String> {
let mut out = String::from("<!--");
for (key, value) in attrs {
validate_event_attr(key, value)?;
out.push(' ');
out.push_str(key);
out.push_str(": ");
out.push_str(&format_event_attr_value(value));
}
out.push_str(" -->");
Ok(out)
}
fn format_event_attr_value(value: &str) -> String {
if !value.is_empty()
&& !value.chars().any(char::is_whitespace)
&& !value.contains('"')
&& !value.contains('\\')
&& !value.contains("-->")
{
return value.to_string();
}
let mut out = String::from("\"");
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
_ => out.push(ch),
}
}
out.push('"');
out
}
fn validate_event_attr(key: &str, value: &str) -> Result<()> {
if key.trim().is_empty() || key.chars().any(char::is_whitespace) || key.contains(':') {
return Err(TicketError::Conflict(format!(
"thread event attribute key is invalid: {key:?}"
)));
}
if value.contains('\n') || value.contains('\r') || value.contains("-->") {
return Err(TicketError::Conflict(format!(
"thread event attribute `{key}` must be a single safe comment value"
)));
}
Ok(())
}
fn validate_required_event_value(label: &str, value: &str) -> Result<()> {
if value.trim().is_empty() {
return Err(TicketError::Conflict(format!(
"state_changed event requires non-empty {label}"
)));
}
validate_event_attr(label, value)
}
fn validate_state_change(change: &TicketStateChange) -> Result<()> {
validate_required_event_value("from", &change.from)?;
validate_required_event_value("to", &change.to)?;
validate_required_event_value("reason", &change.reason)?;
if change.reason.len() > MAX_STATE_CHANGE_REASON_BYTES {
return Err(TicketError::Conflict(format!(
"state_changed reason exceeds {MAX_STATE_CHANGE_REASON_BYTES} bytes"
)));
}
if let Some(author) = change.author.as_deref() {
validate_required_event_value("author", author)?;
}
if change.body.as_str().len() > MAX_INTAKE_SUMMARY_BODY_BYTES {
return Err(TicketError::Conflict(format!(
"state_changed body exceeds {MAX_INTAKE_SUMMARY_BODY_BYTES} bytes"
)));
}
Ok(())
}
fn validate_intake_summary(summary: &TicketIntakeSummary) -> Result<()> {
let body = summary.body.as_str();
if body.trim().is_empty() {
return Err(TicketError::Conflict(
"intake_summary event requires a non-empty body".to_string(),
));
}
if body.len() > MAX_INTAKE_SUMMARY_BODY_BYTES {
return Err(TicketError::Conflict(format!(
"intake_summary body exceeds {MAX_INTAKE_SUMMARY_BODY_BYTES} bytes"
)));
}
if let Some(author) = summary.author.as_deref() {
validate_required_event_value("author", author)?;
}
Ok(())
}
fn validate_state_field_name(field: &str) -> Result<()> {
if field.trim().is_empty()
|| field.chars().any(char::is_whitespace)
|| field.contains(':')
|| field.contains("--")
{
return Err(TicketError::Conflict(format!(
"state field name is invalid: {field:?}"
)));
}
Ok(())
}
fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
let mut events = Vec::new();
@ -1318,9 +1587,14 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
author: attrs.get("author").cloned(),
at: attrs.get("at").cloned(),
status: attrs.get("status").cloned(),
from: attrs.get("from").cloned(),
to: attrs.get("to").cloned(),
reason: attrs.get("reason").cloned(),
state_field: attrs.get("field").cloned(),
heading,
body: MarkdownText::new(body),
references: Vec::new(),
attributes: attrs,
});
} else {
idx += 1;
@ -1330,16 +1604,69 @@ fn parse_thread(path: &Path) -> Result<Vec<TicketEvent>> {
}
fn parse_event_comment(comment: &str) -> BTreeMap<String, String> {
// Thread event comments use unquoted `key: value` pairs separated by spaces.
// Values currently do not contain spaces; this parser intentionally preserves
// the local file format instead of treating thread.md as strict YAML.
let mut attrs = BTreeMap::new();
let mut iter = comment.split_whitespace().peekable();
while let Some(token) = iter.next() {
if let Some(key) = token.strip_suffix(':') {
if let Some(value) = iter.next() {
attrs.insert(key.to_string(), value.to_string());
let mut chars = comment.char_indices().peekable();
while let Some((_, ch)) = chars.peek().copied() {
if ch.is_whitespace() {
chars.next();
continue;
}
let start = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len());
while let Some((_, ch)) = chars.peek().copied() {
if ch == ':' || ch.is_whitespace() {
break;
}
chars.next();
}
let end = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len());
if chars.peek().map(|(_, ch)| *ch) != Some(':') {
while let Some((_, ch)) = chars.peek().copied() {
if ch.is_whitespace() {
break;
}
chars.next();
}
continue;
}
chars.next();
while let Some((_, ch)) = chars.peek().copied() {
if ch.is_whitespace() {
chars.next();
} else {
break;
}
}
let value = if chars.peek().map(|(_, ch)| *ch) == Some('"') {
chars.next();
let mut value = String::new();
let mut escaped = false;
for (_, ch) in chars.by_ref() {
if escaped {
value.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
break;
} else {
value.push(ch);
}
}
value
} else {
let value_start = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len());
while let Some((_, ch)) = chars.peek().copied() {
if ch.is_whitespace() {
break;
}
chars.next();
}
let value_end = chars.peek().map(|(idx, _)| *idx).unwrap_or(comment.len());
comment[value_start..value_end].to_string()
};
let key = &comment[start..end];
if !key.is_empty() {
attrs.insert(key.to_string(), value);
}
}
attrs
@ -1347,6 +1674,7 @@ fn parse_event_comment(comment: &str) -> BTreeMap<String, String> {
fn doctor_thread_events(path: &Path, report: &mut TicketDoctorReport) -> Result<()> {
let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
let mut intake_summary_lines = Vec::new();
for (line_no, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("<!-- event:") && !trimmed.ends_with("-->") {
@ -1364,7 +1692,13 @@ fn doctor_thread_events(path: &Path, report: &mut TicketDoctorReport) -> Result<
.and_then(|v| v.strip_suffix(" -->"))
{
let attrs = parse_event_comment(comment);
if attrs.contains_key("event") && attrs.get("at").is_none() {
let Some(event) = attrs.get("event").map(String::as_str) else {
continue;
};
if attrs
.get("at")
.map_or(true, |value| value.trim().is_empty())
{
report.push_error(
format!(
"thread event missing at: {}:{}",
@ -1374,8 +1708,8 @@ fn doctor_thread_events(path: &Path, report: &mut TicketDoctorReport) -> Result<
Some(path.to_path_buf()),
);
}
if attrs.get("event").map(String::as_str) == Some("review") {
match attrs.get("status").map(String::as_str) {
match event {
"review" => match attrs.get("status").map(String::as_str) {
Some("approve" | "request_changes") => {}
_ => report.push_warning(
format!(
@ -1385,7 +1719,56 @@ fn doctor_thread_events(path: &Path, report: &mut TicketDoctorReport) -> Result<
),
Some(path.to_path_buf()),
),
},
"state_changed" => {
for key in ["from", "to", "reason", "author"] {
if attrs.get(key).map_or(true, |value| value.trim().is_empty()) {
report.push_error(
format!(
"state_changed event missing {key}: {}:{}",
path.display(),
line_no + 1
),
Some(path.to_path_buf()),
);
}
}
}
"intake_summary" => {
if attrs
.get("author")
.map_or(true, |value| value.trim().is_empty())
{
report.push_error(
format!(
"intake_summary event missing author: {}:{}",
path.display(),
line_no + 1
),
Some(path.to_path_buf()),
);
}
intake_summary_lines.push(line_no + 1);
}
_ => {}
}
}
}
if !intake_summary_lines.is_empty() {
let summaries = parse_thread(path)?
.into_iter()
.filter(|event| event.kind == TicketEventKind::IntakeSummary);
for (idx, event) in summaries.enumerate() {
if event.body.as_str().trim().is_empty() {
let line = intake_summary_lines.get(idx).copied().unwrap_or_default();
report.push_error(
format!(
"intake_summary event missing body at {}:{}",
path.display(),
line
),
Some(path.to_path_buf()),
);
}
}
}
@ -1618,6 +2001,202 @@ action_required: none
assert!(report.is_ok(), "{:?}", report.diagnostics);
}
#[test]
fn invalid_thread_event_attributes_do_not_modify_thread() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend
.create(NewTicket::new("Append Safety Ticket"))
.unwrap();
let thread_path = tmp
.path()
.join("tickets/open")
.join(&ticket.id)
.join("thread.md");
let original = fs::read_to_string(&thread_path).unwrap();
let mut comment = NewTicketEvent::new(TicketEventKind::Comment, "This must not append.");
comment.author = Some("bad\nauthor".into());
assert!(matches!(
backend.add_event(TicketIdOrSlug::Id(ticket.id.clone()), comment),
Err(TicketError::Conflict(_))
));
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
let mut review = TicketReview::approve("This must not append either.");
review.author = Some("bad-->author".into());
assert!(matches!(
backend.review(TicketIdOrSlug::Id(ticket.id.clone()), review),
Err(TicketError::Conflict(_))
));
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
let invalid_kind = NewTicketEvent::new(
TicketEventKind::Other("bad\nevent".into()),
"Invalid event kind.",
);
assert!(matches!(
backend.add_event(TicketIdOrSlug::Id(ticket.id.clone()), invalid_kind),
Err(TicketError::Conflict(_))
));
assert_eq!(fs::read_to_string(&thread_path).unwrap(), original);
}
#[test]
fn create_rejects_invalid_author_before_writing_ticket_record() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let mut input = NewTicket::new("Invalid Author Ticket");
input.slug = Some("invalid-author-ticket".into());
input.author = Some("bad-->author".into());
assert!(matches!(
backend.create(input),
Err(TicketError::Conflict(_))
));
let open_dir = tmp.path().join("tickets/open");
let entries = fs::read_dir(open_dir).unwrap().count();
assert_eq!(entries, 0);
}
#[test]
fn state_changed_and_intake_summary_events_round_trip() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend
.create(NewTicket::new("Typed Thread Ticket"))
.unwrap();
let mut change = TicketStateChange::new(
"preflight",
"implementation-ready",
"preflight approved",
"Preflight finished; implementation can begin.",
);
change.author = Some("orchestrator".into());
backend
.add_state_changed(TicketIdOrSlug::Id(ticket.id.clone()), change)
.unwrap();
let mut summary = TicketIntakeSummary::new("## Accepted intent\n\nImplement typed events.");
summary.author = Some("intake".into());
backend
.add_intake_summary(TicketIdOrSlug::Id(ticket.id.clone()), summary)
.unwrap();
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
let state_event = record
.events
.iter()
.find(|event| event.kind == TicketEventKind::StateChanged)
.unwrap();
assert_eq!(state_event.from.as_deref(), Some("preflight"));
assert_eq!(state_event.to.as_deref(), Some("implementation-ready"));
assert_eq!(state_event.reason.as_deref(), Some("preflight approved"));
assert_eq!(state_event.author.as_deref(), Some("orchestrator"));
assert_eq!(
state_event.attributes.get("reason").map(String::as_str),
Some("preflight approved")
);
assert!(
record
.events
.iter()
.any(|event| event.kind == TicketEventKind::IntakeSummary
&& event.body.as_str().contains("Accepted intent"))
);
let thread = fs::read_to_string(
tmp.path()
.join("tickets/open")
.join(&ticket.id)
.join("thread.md"),
)
.unwrap();
assert!(thread.contains("event: state_changed"));
assert!(thread.contains("reason: \"preflight approved\""));
assert!(thread.contains("event: intake_summary"));
let report = backend.doctor().unwrap();
assert!(report.is_ok(), "{:?}", report.diagnostics);
}
#[test]
fn set_state_field_updates_frontmatter_and_appends_transition() {
let tmp = TempDir::new().unwrap();
let backend = backend(&tmp);
let ticket = backend
.create(NewTicket::new("State Field Ticket"))
.unwrap();
let item = tmp
.path()
.join("tickets/open")
.join(&ticket.id)
.join("item.md");
backend
.set_frontmatter_fields(&item, &[("readiness", "preflight")])
.unwrap();
let mut change = TicketStateChange::new(
"preflight",
"implementation-ready",
"requirements accepted",
"Implementation is authorized.",
);
change.author = Some("orchestrator".into());
backend
.set_state_field(TicketIdOrSlug::Id(ticket.id.clone()), "readiness", change)
.unwrap();
let record = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
assert_eq!(
record.meta.readiness.as_deref(),
Some("implementation-ready")
);
let event = record
.events
.iter()
.find(|event| event.kind == TicketEventKind::StateChanged)
.unwrap();
assert_eq!(event.state_field.as_deref(), Some("readiness"));
let stale = TicketStateChange::new(
"preflight",
"done",
"stale update",
"This must be rejected.",
);
assert!(matches!(
backend.set_state_field(TicketIdOrSlug::Id(ticket.id), "readiness", stale),
Err(TicketError::Conflict(_))
));
}
#[test]
fn doctor_validates_typed_thread_event_attributes() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("tickets");
fs::create_dir_all(root.join("open/bad/artifacts")).unwrap();
fs::write(
root.join("open/bad/item.md"),
"---\nid: bad\nslug: bad\ntitle: Bad\nstatus: open\nkind: task\npriority: P2\nlabels: []\ncreated_at: x\nupdated_at: x\nassignee: null\nlegacy_ticket: null\n---\n",
)
.unwrap();
fs::write(
root.join("open/bad/thread.md"),
"<!-- event: state_changed author: bot at: now from: queued -->\n\n## State changed\n\n---\n\n<!-- event: intake_summary author: bot at: now -->\n\n## Intake summary\n\n---\n",
)
.unwrap();
fs::create_dir_all(root.join("pending")).unwrap();
fs::create_dir_all(root.join("closed")).unwrap();
let report = LocalTicketBackend::new(&root).doctor().unwrap();
let messages = report
.diagnostics
.iter()
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(!report.is_ok());
assert!(messages.contains("state_changed event missing to"));
assert!(messages.contains("state_changed event missing reason"));
assert!(messages.contains("intake_summary event missing body"));
}
#[test]
fn doctor_reports_core_consistency_errors() {
let tmp = TempDir::new().unwrap();

View File

@ -607,6 +607,11 @@ fn ticket_json(
"author": event.author,
"at": event.at,
"status": event.status,
"from": event.from,
"to": event.to,
"reason": event.reason,
"state_field": event.state_field,
"attributes": event.attributes,
"heading": event.heading,
"body": truncate_text(event.body.as_str(), body_max_bytes),
})

View File

@ -353,7 +353,7 @@ The current LocalTicketBackend stores records under:
resolution.md # closed Tickets only
```
Backend integrations must preserve this format until an explicit migration changes it. The repository-root `work-items/` path is no longer a live mutable backend; do not recreate it for Ticket records. Human users should prefer `yoi panel`, Ticket tools, or `yoi ticket ...` when working directly with repository records.
Backend integrations must preserve this format until an explicit migration changes it. `thread.md` is an append-only typed event log: existing events such as `create`, `comment`, `plan`, `decision`, `implementation_report`, `review`, `status_changed`, and `close` remain valid, while `state_changed` records durable transition metadata (`from`, `to`, `reason`, optional `field`, plus `author` and `at`) and `intake_summary` records the bounded Intake outcome body. Thread events are audit history, not current-state authority; current state belongs in `item.md` frontmatter or the owning backend record. The repository-root `work-items/` path is no longer a live mutable backend; do not recreate it for Ticket records. Human users should prefer `yoi panel`, Ticket tools, or `yoi ticket ...` when working directly with repository records.
## Validation