ticket: use base32 project record ids

This commit is contained in:
2026-06-09 22:10:47 +09:00
parent 0803bc3725
commit 4203988d74
798 changed files with 477 additions and 105 deletions
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "project-record"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
+182
View File
@@ -0,0 +1,182 @@
//! Shared path-safe project-record identifiers.
//!
//! Record IDs are fixed-width Crockford base32 encodings of Unix epoch
//! milliseconds. The fixed width keeps lexicographic order aligned with
//! chronological order.
use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
pub const RECORD_ID_WIDTH: usize = 13;
pub const RECORD_ID_ALPHABET: &str = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
pub const MAX_COLLISION_PROBES: u64 = 1000;
const ALPHABET_BYTES: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecordIdError {
InvalidLength { value: String },
InvalidCharacter { value: String, ch: char },
TimestampOverflow,
TimeBeforeUnixEpoch,
ExcessiveCollisions { base_millis: u64, attempts: u64 },
}
impl fmt::Display for RecordIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidLength { value } => write!(
f,
"invalid record id length for {value:?}: expected {RECORD_ID_WIDTH}"
),
Self::InvalidCharacter { value, ch } => {
write!(f, "invalid record id character {ch:?} in {value:?}")
}
Self::TimestampOverflow => f.write_str("record id timestamp overflow"),
Self::TimeBeforeUnixEpoch => f.write_str("system time is before Unix epoch"),
Self::ExcessiveCollisions {
base_millis,
attempts,
} => write!(
f,
"too many record id collisions for timestamp {base_millis} after {attempts} attempts"
),
}
}
}
impl std::error::Error for RecordIdError {}
pub fn unix_epoch_millis_now() -> Result<u64, RecordIdError> {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| RecordIdError::TimeBeforeUnixEpoch)?;
u64::try_from(duration.as_millis()).map_err(|_| RecordIdError::TimestampOverflow)
}
pub fn encode_unix_epoch_millis(millis: u64) -> String {
let mut value = millis;
let mut out = [b'0'; RECORD_ID_WIDTH];
for slot in out.iter_mut().rev() {
*slot = ALPHABET_BYTES[(value & 0b11111) as usize];
value >>= 5;
}
String::from_utf8(out.to_vec()).expect("record id alphabet is ASCII")
}
pub fn decode_unix_epoch_millis(id: &str) -> Result<u64, RecordIdError> {
if id.len() != RECORD_ID_WIDTH {
return Err(RecordIdError::InvalidLength {
value: id.to_string(),
});
}
let mut value = 0_u64;
for ch in id.chars() {
let digit = decode_digit(ch, id)? as u64;
value = value
.checked_mul(32)
.and_then(|value| value.checked_add(digit))
.ok_or(RecordIdError::TimestampOverflow)?;
}
Ok(value)
}
pub fn validate_record_id(id: &str) -> Result<(), RecordIdError> {
decode_unix_epoch_millis(id).map(|_| ())
}
pub fn allocate_record_id<F>(base_millis: u64, mut exists: F) -> Result<String, RecordIdError>
where
F: FnMut(&str) -> bool,
{
for offset in 0..MAX_COLLISION_PROBES {
let millis = base_millis
.checked_add(offset)
.ok_or(RecordIdError::TimestampOverflow)?;
let id = encode_unix_epoch_millis(millis);
if !exists(&id) {
return Ok(id);
}
}
Err(RecordIdError::ExcessiveCollisions {
base_millis,
attempts: MAX_COLLISION_PROBES,
})
}
fn decode_digit(ch: char, value: &str) -> Result<u8, RecordIdError> {
let digit = match ch {
'0'..='9' => ch as u8 - b'0',
'A'..='H' => ch as u8 - b'A' + 10,
'J'..='K' => ch as u8 - b'J' + 18,
'M'..='N' => ch as u8 - b'M' + 20,
'P'..='T' => ch as u8 - b'P' + 22,
'V'..='Z' => ch as u8 - b'V' + 27,
_ => {
return Err(RecordIdError::InvalidCharacter {
value: value.to_string(),
ch,
});
}
};
Ok(digit)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_fixed_width_crockford_base32() {
assert_eq!(encode_unix_epoch_millis(0), "0000000000000");
assert_eq!(encode_unix_epoch_millis(31), "000000000000Z");
assert_eq!(encode_unix_epoch_millis(32), "0000000000010");
assert_eq!(decode_unix_epoch_millis("0000000000010").unwrap(), 32);
}
#[test]
fn lexicographic_order_matches_numeric_order() {
let values = [0, 1, 31, 32, 33, 1024, 1_782_554_447_000, u64::MAX];
let encoded = values
.iter()
.map(|value| encode_unix_epoch_millis(*value))
.collect::<Vec<_>>();
let mut sorted = encoded.clone();
sorted.sort();
assert_eq!(encoded, sorted);
}
#[test]
fn rejects_ambiguous_or_path_unsafe_characters() {
for id in [
"000000000000I",
"000000000000L",
"000000000000O",
"00000000000/0",
"ZZZZZZZZZZZZZ",
] {
assert!(validate_record_id(id).is_err(), "{id} should be invalid");
}
}
#[test]
fn collision_allocation_increments_milliseconds_without_suffixes() {
let base = 1_782_554_447_000;
let first = encode_unix_epoch_millis(base);
let second = encode_unix_epoch_millis(base + 1);
let allocated = allocate_record_id(base, |id| id == first).unwrap();
assert_eq!(allocated, second);
}
#[test]
fn collision_allocation_is_bounded() {
let err = allocate_record_id(42, |_| true).unwrap_err();
assert_eq!(
err,
RecordIdError::ExcessiveCollisions {
base_millis: 42,
attempts: MAX_COLLISION_PROBES,
}
);
}
}
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
project-record = { workspace = true }
async-trait = { workspace = true }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
fs4 = { workspace = true, features = ["sync"] }
+32 -30
View File
@@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf};
use chrono::Utc;
use fs4::fs_std::FileExt;
use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id};
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use thiserror::Error;
@@ -1305,21 +1306,17 @@ impl TicketBackend for LocalTicketBackend {
"ticket title must not be empty".to_string(),
));
}
let stamp = compact_now_utc();
let mut counter = 1_u32;
let (id, dir) = loop {
let candidate = format!("{stamp}-{counter:03}");
let dir = self.ticket_dir(&candidate)?;
if !dir.exists() {
break (candidate, dir);
}
counter += 1;
if counter > 999 {
return Err(TicketError::Conflict(format!(
"too many ticket id collisions for timestamp {stamp}"
)));
}
};
let base_millis = unix_epoch_millis_now().map_err(|err| {
TicketError::Conflict(format!("failed to read ticket id timestamp: {err}"))
})?;
let id = allocate_record_id(base_millis, |candidate| match self.ticket_dir(candidate) {
Ok(dir) => dir.exists(),
Err(_) => true,
})
.map_err(|err| {
TicketError::Conflict(format!("failed to allocate unique ticket id: {err}"))
})?;
let dir = self.ticket_dir(&id)?;
let created = now_utc();
let author = input
.author
@@ -2180,6 +2177,9 @@ fn ticket_id_from_dir(dir: &Path) -> Result<String> {
)));
};
ensure_safe_component(name)?;
validate_record_id(name).map_err(|err| {
TicketError::InvalidPathComponent(format!("{name} is not a canonical record id: {err}"))
})?;
Ok(name.to_string())
}
@@ -3536,9 +3536,9 @@ queued_at: 2026-06-05T00:01:00Z
## Body
"#;
let parsed = parse_item(item).unwrap();
let meta = ticket_meta(parsed.frontmatter, "20260609-000000-001".to_string());
assert_eq!(meta.id, "20260609-000000-001");
assert_eq!(meta.slug, "20260609-000000-001");
let meta = ticket_meta(parsed.frontmatter, "0000000000001".to_string());
assert_eq!(meta.id, "0000000000001");
assert_eq!(meta.slug, "0000000000001");
assert!(meta.labels.is_empty());
assert_eq!(meta.readiness.as_deref(), Some("implementation-ready"));
assert_eq!(meta.risk_flags, vec!["low", "local"]);
@@ -3558,7 +3558,7 @@ state: planning
"#,
)
.unwrap();
let meta = ticket_meta(frontmatter, "20260609-000000-001".to_string());
let meta = ticket_meta(frontmatter, "0000000000001".to_string());
assert!(meta.labels.is_empty());
assert_eq!(meta.risk_flags, vec!["low", "local"]);
assert_eq!(meta.assignee, None);
@@ -3600,6 +3600,8 @@ state: planning
assert!(dir.join("thread.md").exists());
assert!(dir.join("artifacts/.gitkeep").exists());
assert!(!ticket.id.contains("example"));
assert_eq!(ticket.id.len(), project_record::RECORD_ID_WIDTH);
validate_record_id(&ticket.id).unwrap();
assert_eq!(ticket.slug, ticket.id);
let item = fs::read_to_string(dir.join("item.md")).unwrap();
assert!(
@@ -3892,14 +3894,14 @@ state: planning
let backend = backend(&tmp);
let missing_meta = ticket_meta(
parse_ticket_frontmatter("title: Missing State").expect("missing state parses"),
"20260609-000000-001".to_string(),
"0000000000001".to_string(),
);
assert_eq!(missing_meta.workflow_state, TicketWorkflowState::Planning);
assert!(!missing_meta.workflow_state_explicit);
let closed_meta = ticket_meta(
parse_ticket_frontmatter("state: closed").expect("closed state parses"),
"20260609-000000-002".to_string(),
"0000000000002".to_string(),
);
assert_eq!(closed_meta.workflow_state, TicketWorkflowState::Closed);
assert!(closed_meta.workflow_state_explicit);
@@ -4028,13 +4030,13 @@ state: planning
fn doctor_reports_invalid_state() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("tickets");
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
fs::create_dir_all(root.join("0000000000001/artifacts")).unwrap();
fs::write(
root.join("20260609-000000-001/item.md"),
root.join("0000000000001/item.md"),
"---\ntitle: Bad\nstate: almost\ncreated_at: x\nupdated_at: x\n---\n",
)
.unwrap();
fs::write(root.join("20260609-000000-001/thread.md"), "").unwrap();
fs::write(root.join("0000000000001/thread.md"), "").unwrap();
let report = LocalTicketBackend::new(&root).doctor().unwrap();
let messages = report
@@ -4051,14 +4053,14 @@ state: planning
fn doctor_validates_typed_thread_event_attributes() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("tickets");
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
fs::create_dir_all(root.join("0000000000001/artifacts")).unwrap();
fs::write(
root.join("20260609-000000-001/item.md"),
root.join("0000000000001/item.md"),
"---\ntitle: Bad\nstate: planning\ncreated_at: x\nupdated_at: x\n---\n",
)
.unwrap();
fs::write(
root.join("20260609-000000-001/thread.md"),
root.join("0000000000001/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();
@@ -4086,14 +4088,14 @@ state: planning
)
.unwrap();
fs::write(root.join("open/legacy/thread.md"), "").unwrap();
fs::create_dir_all(root.join("20260609-000000-001/artifacts")).unwrap();
fs::create_dir_all(root.join("0000000000001/artifacts")).unwrap();
fs::write(
root.join("20260609-000000-001/item.md"),
root.join("0000000000001/item.md"),
"---\nid: old\nslug: old\ntitle: Bad\nstatus: pending\nworkflow_state: ready\nkind: task\nlabels: []\naction_required: human\nattention_required: true\ncreated_at: x\nupdated_at: x\n---\n",
)
.unwrap();
fs::write(
root.join("20260609-000000-001/thread.md"),
root.join("0000000000001/thread.md"),
"<!-- event: review author: a at: now -->\n",
)
.unwrap();
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
project-record = { workspace = true }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
client = { workspace = true }
memory = { workspace = true }
+24 -27
View File
@@ -3,6 +3,7 @@ use std::fs;
use std::path::{Component, Path, PathBuf};
use chrono::Utc;
use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id};
use serde::Deserialize;
use ticket::config::TicketConfig;
@@ -198,21 +199,13 @@ fn create(
let root = objective_root(workspace);
fs::create_dir_all(&root)?;
let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string();
let mut counter = 1_u32;
let (id, dir) = loop {
let candidate = format!("{stamp}-{counter:03}");
let dir = root.join(&candidate);
if !dir.exists() {
break (candidate, dir);
}
counter += 1;
if counter > 999 {
return Err(ObjectiveCliError::new(format!(
"too many objective id collisions for timestamp {stamp}"
)));
}
};
let base_millis = unix_epoch_millis_now().map_err(|error| {
ObjectiveCliError::new(format!("failed to read objective id timestamp: {error}"))
})?;
let id = allocate_record_id(base_millis, |candidate| root.join(candidate).exists()).map_err(
|error| ObjectiveCliError::new(format!("failed to allocate unique objective id: {error}")),
)?;
let dir = root.join(&id);
fs::create_dir_all(&dir)?;
fs::write(
@@ -425,7 +418,9 @@ fn validate_record_component(value: &str) -> Result<(), ObjectiveCliError> {
)));
}
match path.components().next() {
Some(Component::Normal(_)) => Ok(()),
Some(Component::Normal(_)) => validate_record_id(value).map_err(|error| {
ObjectiveCliError::new(format!("{value} is not a canonical record id: {error}"))
}),
_ => Err(ObjectiveCliError::new(format!(
"invalid path-derived id component: {value}"
))),
@@ -631,7 +626,7 @@ mod tests {
#[test]
fn objective_cli_creates_lists_and_shows_records() {
let temp = TempDir::new().unwrap();
create_ticket_dir(&temp, "20260608-125430-001");
create_ticket_dir(&temp, "00001KTKMS0VG");
let created = run(
&temp,
@@ -640,10 +635,12 @@ mod tests {
"--title",
"Medium-term goal",
"--ticket",
"20260608-125430-001",
"00001KTKMS0VG",
],
);
let objective_id = created_id(&created);
validate_record_id(&objective_id).unwrap();
assert_eq!(objective_id.len(), project_record::RECORD_ID_WIDTH);
assert!(
temp.path()
.join(".yoi/objectives")
@@ -654,7 +651,7 @@ mod tests {
let listed = run(&temp, &["list", "--state", "active"]);
assert!(listed.stdout.contains(&objective_id));
assert!(listed.stdout.contains("20260608-125430-001"));
assert!(listed.stdout.contains("00001KTKMS0VG"));
let shown = run(&temp, &["show", &objective_id]);
assert!(shown.stdout.contains("# Medium-term goal"));
@@ -663,7 +660,7 @@ mod tests {
.stdout
.contains("## Success criteria / exit conditions")
);
assert!(shown.stdout.contains("20260608-125430-001"));
assert!(shown.stdout.contains("00001KTKMS0VG"));
}
#[test]
@@ -674,24 +671,24 @@ mod tests {
"--title",
"Broken link",
"--ticket",
"missing-ticket",
"0000000000ABC",
]))
.unwrap();
let err = run_in_workspace(cli, temp.path()).unwrap_err();
assert!(
err.to_string()
.contains("linked ticket missing-ticket does not exist")
.contains("linked ticket 0000000000ABC does not exist")
);
}
#[test]
fn objective_doctor_reports_invalid_linked_ticket() {
let temp = TempDir::new().unwrap();
let dir = temp.path().join(".yoi/objectives/20260609-000000-001");
let dir = temp.path().join(".yoi/objectives/0000000000001");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("item.md"),
"---\ntitle: \"Goal\"\nstate: \"active\"\ncreated_at: \"2026-06-09T00:00:00Z\"\nupdated_at: \"2026-06-09T00:00:00Z\"\nlinked_tickets: [\"missing\"]\n---\n\n## Goal\n\nText\n\n## Motivation / background\n\nText\n\n## Strategy / design direction\n\nText\n\n## Success criteria / exit conditions\n\n- Text\n\n## Decision context\n\n- Text\n",
"---\ntitle: \"Goal\"\nstate: \"active\"\ncreated_at: \"2026-06-09T00:00:00Z\"\nupdated_at: \"2026-06-09T00:00:00Z\"\nlinked_tickets: [\"0000000000ABD\"]\n---\n\n## Goal\n\nText\n\n## Motivation / background\n\nText\n\n## Strategy / design direction\n\nText\n\n## Success criteria / exit conditions\n\n- Text\n\n## Decision context\n\n- Text\n",
)
.unwrap();
@@ -700,14 +697,14 @@ mod tests {
assert!(
output
.stdout
.contains("linked ticket missing does not exist")
.contains("linked ticket 0000000000ABD does not exist")
);
}
#[test]
fn objective_doctor_accepts_well_formed_records() {
let temp = TempDir::new().unwrap();
create_ticket_dir(&temp, "20260608-125430-001");
create_ticket_dir(&temp, "00001KTKMS0VG");
run(
&temp,
&[
@@ -715,7 +712,7 @@ mod tests {
"--title",
"Good objective",
"--ticket",
"20260608-125430-001",
"00001KTKMS0VG",
],
);