pub struct Issue {Show 30 fields
pub number: u32,
pub title: String,
pub status: Status,
pub labels: Labels,
pub priority: Priority,
pub assignee: Option<String>,
pub estimate: Option<String>,
pub commit: Option<String>,
pub gitlab_id: Option<String>,
pub linear_id: Option<String>,
pub mission: Option<String>,
pub task: Option<String>,
pub relationships: Vec<Relationship>,
pub parent: Option<u32>,
pub files: Vec<String>,
pub cycles: Option<Vec<String>>,
pub milestones: Option<Vec<String>>,
pub reviewer: Option<String>,
pub start_date: Option<String>,
pub due_date: Option<String>,
pub entered_at: Option<String>,
pub source: Option<String>,
pub created: String,
pub updated: String,
pub filename: String,
pub body: String,
pub history: History,
pub has_retired_links_key: bool,
pub extra_frontmatter: BTreeMap<String, String>,
pub archived: bool,
}Expand description
An issue: one markdown file with YAML frontmatter.
Field order and names are frozen by contracts/file-format.md FR-012.
number is a stored frontmatter key assigned by the storage layer at
create time by scanning the live issue set (issue #52; previously from
tix.toml [state].next_number, which is no longer persisted); this
module models and validates it but never allocates. filename and
body are memory-only (#[serde(skip)]).
Fields§
§number: u32Internal sequential id, never reused (C-007).
title: StringHuman title; drives the slug in the filename.
status: StatusColumn (board workflow stage) the issue sits in.
labels: LabelsFree-form labels.
priority: PriorityPriority; defaults to medium.
assignee: Option<String>Assignee. In memory: bare handle (alice) or full Name <email>
identity, verbatim. Files serialize per the rule documented on
[de_assignee]/[se_assignee] (legacy @alice tolerated on read).
estimate: Option<String>Free-form estimate (e.g. 2, S, 3d); default null.
commit: Option<String>Git commit recorded when moved to done (FR-026).
gitlab_id: Option<String>Remote GitLab issue id after export/import.
linear_id: Option<String>Remote Linear issue id after export/import.
mission: Option<String>Spec Kitty mission slug (e.g. v3-archive-review) this issue belongs to, if any.
task: Option<String>Spec Kitty work package id (e.g. WP01) this issue links to, if any.
relationships: Vec<Relationship>Typed relationships to other issues, replacing the retired generic
links: Vec<String> bag (C-001).
Each entry is a Relationship: a typed, directed edge with a stable
id and a human-readable display name. The vocabulary is the closed
four-kind set — blocks, blocked_by, duplicates, related — and
the kind that reaches disk is always the canonical one, so blocked_by
is normalized into blocks before it is stored and a pair is never
stored twice. See crate::relationships for the direction
convention, which this field does not restate.
Files without the key parse (default). An empty list is omitted
rather than written as []: the typed field is new, so there is no
legacy expectation to preserve for it, and omitting it keeps an issue
with no relationships free of a meaningless key.
parent: Option<u32>This issue’s decomposition parent, as an issue number.
A parent is a decomposition — “this work is part of that” — and is a
distinct concept from a typed dependency edge: blocks is a scheduling
dependency, and conflating the two in one field is prohibited (C-007). It is
also distinct from the mission and task Spec Kitty pointer fields, which
are governance metadata and not a hierarchy (research R2).
At most one parent (C-001). The type is a single Option<u32>, so two
parents are not expressible; the field is not a list and no code path appends
to it. Child membership is derived by inverting this pointer and is never
stored on the parent (C-003) — see crate::hierarchy, which owns that
derivation.
#[serde(default)] so every legacy file without the key parses unchanged,
and skipped when absent so a file that never had a parent serialises
byte-identically to its pre-mission form.
The value is an issue number, not a selector: a selector may be a title
fragment, and a title is not stable, so a stored title would silently break
every child when the parent was retitled. Selectors are the input to
tix parent set; the number is what lands in the file.
A reference whose target no longer exists is kept, not cleared: it becomes
the diagnosed UnresolvedParentState so it can be repaired.
files: Vec<String>Repo-relative file paths of the governing specification (e.g.
openspec/changes/<name>/specs/<cap>/spec.md). Default [].
cycles: Option<Vec<String>>Planning cycle names this issue belongs to (FR-005). Each entry must
name a [cycles.<name>] config entry. Option so legacy files lack
the key entirely; serialized only when non-empty.
milestones: Option<Vec<String>>Milestone names this issue belongs to (FR-005). Each entry must name a
milestone file in milestones_dir. Serialized only when non-empty.
reviewer: Option<String>Advisory reviewer identity (FR-006), free-form like assignee.
start_date: Option<String>Planning start date, YYYY-MM-DD (FR-006).
due_date: Option<String>Planning due date, YYYY-MM-DD; must be >= start_date (FR-007).
entered_at: Option<String>When the issue most recently entered the triage inbox, RFC 3339 UTC
(2026-09-30T09:12:44Z), if it ever did.
Mission triage-inbox-review-flow-01M3C600, data-model §2. Queue age is
measured from here and from nothing else: updated moves on every edit and
created predates the queue, so either would report a wait the issue did
not have (research R4). Read it through crate::triage::entry_time,
which is the one documented fallback for an issue that has none.
Optional and skipped when absent, so a repository that predates triage round-trips byte-identically instead of gaining a key on every file (C-001).
Refreshed on re-entry, not merged — a defer restarts the wait (data-model
§5) — and deliberately not stamped on every edit, which would turn it
into a second updated.
source: Option<String>Where the issue’s current entry came from: new, import, defer, or
any other origin the caller documents (data-model §2, source).
Optional for the same reason as Self::entered_at. It describes this
entry rather than the issue’s permanent provenance, so it is rewritten
whenever the issue re-enters.
created: StringCreation date, YYYY-MM-DD, equals the filename date prefix.
updated: StringLast-mutation date, YYYY-MM-DD.
filename: StringIssue file name — path relative to the issues dir (e.g. alice/2026-05-10-fix-login.md for grouped layout, or 2026-05-10-fix-login.md for legacy flat files per C-010).
body: StringRaw markdown body; memory-only, preserved verbatim.
history: HistoryThe append-only ## History section, parsed out of the body on read and
re-rendered on write; memory-only like Issue::body.
The section is stripped from body during parsing, so body holds
only the issue’s own prose and the two never disagree. Rendering puts
the section back immediately after the frontmatter, the position
data-model.md §8 pins.
has_retired_links_key: boolWhether the source frontmatter still carries a legacy links: key.
The key is never read as typed data (C-001) and never migrated; it is
recorded here so the service can report it as a
retired_links_key validation result. Memory-only, like the rest of the
file-surface state.
extra_frontmatter: BTreeMap<String, String>Top-level frontmatter keys this model does not know, as key -> raw block.
#1361. Before this, an unknown key was dropped on every write: Issue
deserialises a fixed set of keys and frontmatter re-emits exactly those, so
a key tix does not recognise was deleted by the next unrelated tix edit —
silently, because tix lint had no unknown-key rule at all.
The value is the raw source block, not a parsed value, and that is the whole design. Re-serialising a parsed value would re-quote and re-indent it, so a key that was written one way would come back another way and every write would produce a spurious diff. Splicing the original bytes back in makes the round trip byte-stable for content this model has no opinion about.
A BTreeMap rather than a HashMap so the render order is deterministic:
HashMap iteration order would reshuffle these keys on every write and
produce a diff in a file that had not changed.
Eq is preserved (unlike serde_yaml::Value, which is not Eq), and the
blocks are plain Strings, so Issue keeps every one of its derives.
The retired links: key is excluded — see Issue::parse. It is
recognised, reported by its own finding, and dropped (C-001); round-tripping it
would silently undo the typed-relationship migration.
archived: boolWhether the issue is archived (issue #1492).
The filename is the marker, never a frontmatter field: an archived
issue’s markdown file is dot-prefixed in place
(docs/issues/<assignee>/.<file>.md). This field is therefore derived
state, not data — it mirrors the leading dot of Issue::filename and
is set by crate::storage::Store::list_all, never written to disk.
#[serde(skip)] keeps it out of the frontmatter, and it defaults to
false for every parse.
Implementations§
Source§impl Issue
impl Issue
Sourcepub fn parse(contents: &str) -> Result<Self>
pub fn parse(contents: &str) -> Result<Self>
Parse a full issue file (frontmatter + body) from its text.
The first line must be ---; the closing --- terminates the
frontmatter. The body after it is preserved verbatim (the single
structural separator blank line is consumed).
Sourcepub fn frontmatter(&self) -> String
pub fn frontmatter(&self) -> String
Serialize just the frontmatter YAML (no delimiters), with None
fields omitted except created/updated which are always present.
Sourcepub fn to_file_contents(&self) -> String
pub fn to_file_contents(&self) -> String
Render the full issue file: ---, frontmatter, closing ---,
blank separator and the raw body.
Sourcepub fn issue_view(&self) -> IssueView<'_>
pub fn issue_view(&self) -> IssueView<'_>
A borrowed IssueView of this issue, for the relationship service.
Keeps the service off the full Issue surface: it needs only the
number, title, status, relationships, and whether the legacy links:
key is still present.
Sourcepub fn filename_stem(&self) -> String
pub fn filename_stem(&self) -> String
The date-prefixed filename stem: {created}-{slug} (C-008).
Sourcepub fn bump_updated(&mut self)
pub fn bump_updated(&mut self)
Record a mutation by stamping today’s date into updated.
Sourcepub fn set_dates(
&mut self,
start_date: Option<String>,
due_date: Option<String>,
) -> Result<()>
pub fn set_dates( &mut self, start_date: Option<String>, due_date: Option<String>, ) -> Result<()>
Set start_date/due_date together after atomic validation (FR-007):
both must parse as calendar dates and due_date >= start_date when
both are present (delegates to validate_issue_dates).
Sourcepub fn description(&self) -> &str
pub fn description(&self) -> &str
The description: everything above the first top-level ## Comments
heading, trimmed of trailing whitespace. With no section, the whole
body is the description.
Sourcepub fn comments(&self) -> Vec<Comment>
pub fn comments(&self) -> Vec<Comment>
The comments parsed from the ## Comments section, in file order
(NFR-005). With no section — or a section with no ### heads —
returns an empty vec.
Sourcepub fn add_comment(&mut self, author: &str, text: &str)
pub fn add_comment(&mut self, author: &str, text: &str)
Append a comment to the ## Comments section.
Whitespace-only text is discarded and the body is left untouched (FR-004 case 4). Otherwise the section is created if absent and the comment block is appended after the last existing comment. The description and any existing comments are never rewritten.
Trait Implementations§
Source§impl<'de> Deserialize<'de> for Issue
impl<'de> Deserialize<'de> for Issue
Source§fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where
__D: Deserializer<'de>,
fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where
__D: Deserializer<'de>,
impl Eq for Issue
Source§impl HierarchyNode for Issue
Read in place, rather than through a borrowed projection.
impl HierarchyNode for Issue
Read in place, rather than through a borrowed projection.
Implemented here because hierarchy cannot import model without a cycle, and
because it is Issue — not IssueView — that every store-level caller already
holds. Without this each of them builds a Vec<IssueView> first, which is a
projection nobody asked for and one more place for two services to disagree about
the same snapshot.
impl StructuralPartialEq for Issue
Auto Trait Implementations§
impl Freeze for Issue
impl RefUnwindSafe for Issue
impl Send for Issue
impl Sync for Issue
impl Unpin for Issue
impl UnsafeUnpin for Issue
impl UnwindSafe for Issue
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> DeserializeOwned for Twhere
T: for<'de> Deserialize<'de>,
Source§impl<Q, K> Equivalent<K> for Q
impl<Q, K> Equivalent<K> for Q
Source§fn equivalent(&self, key: &K) -> bool
fn equivalent(&self, key: &K) -> bool
key and return true if they are equal.Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left is true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
self into a Left variant of Either<Self, Self>
if into_left(&self) returns true.
Converts self into a Right variant of Either<Self, Self>
otherwise. Read more