//! Identifier newtypes for people, agents, organisations, and registry events.
//!
//! Every identifier is a `repr(transparent)` wrapper over [`NonZeroU64`]. The
//! niche means `Option<HumanId>` occupies eight bytes with no discriminant.
//!
//! Identifiers are typed rather than interchangeable `u64`s: passing an
//! [`AgentId`] where a [`HumanId`] is expected is a compile error, which is
//! not a convenience here — the attestation invariant (only a human can
//! attest) leans on exactly this distinction.
//!
//! Objects in the version DAG are *not* identified by these sequential ids;
//! they are identified by content digests. See [`crate::oid`].
use core::fmt;
use core::num::NonZeroU64;
use core::str::FromStr;
use serde::de::Visitor;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::CoreError;
/// Maximum rendered length of an identifier: prefix, hyphen, 16 hex digits.
const ID_BUF: usize = 24;
macro_rules! define_id {
($(#[$meta:meta])* $name:ident, $prefix:literal) => {
$(#[$meta])*
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(transparent)]
pub struct $name(NonZeroU64);
impl $name {
/// Textual prefix used when rendering this identifier.
pub const PREFIX: &'static str = $prefix;
/// Smallest representable identifier.
pub const MIN: Self = Self(NonZeroU64::MIN);
/// Constructs an identifier, rejecting zero.
///
/// # Errors
///
/// Returns [`CoreError::ZeroIdentifier`] if `raw` is zero.
pub const fn new(raw: u64) -> Result<Self, CoreError> {
match NonZeroU64::new(raw) {
Some(v) => Ok(Self(v)),
None => Err(CoreError::ZeroIdentifier),
}
}
/// Constructs an identifier from a value known to be non-zero.
#[must_use]
pub const fn from_non_zero(raw: NonZeroU64) -> Self {
Self(raw)
}
/// Returns the underlying value.
#[must_use]
pub const fn get(self) -> u64 {
self.0.get()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}-{:016x}", $prefix, self.0.get())
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
impl FromStr for $name {
type Err = CoreError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let body = s
.strip_prefix(concat!($prefix, "-"))
.ok_or(CoreError::IdentifierPrefix { expected: $prefix })?;
let raw =
u64::from_str_radix(body, 16).map_err(|_| CoreError::IdentifierBody)?;
Self::new(raw)
}
}
impl Serialize for $name {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
if s.is_human_readable() {
// Rendered into a stack buffer: registry records serialise
// in bulk and this path must not allocate.
let mut buf = [0u8; ID_BUF];
let n = render(&mut buf, $prefix, self.0.get());
s.serialize_str(core::str::from_utf8(&buf[..n]).unwrap_or($prefix))
} else {
s.serialize_u64(self.0.get())
}
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl Visitor<'_> for V {
type Value = $name;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "a `{}-` prefixed identifier or a non-zero u64", $prefix)
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<$name, E> {
<$name>::from_str(v).map_err(E::custom)
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<$name, E> {
<$name>::new(v).map_err(E::custom)
}
}
if d.is_human_readable() { d.deserialize_str(V) } else { d.deserialize_u64(V) }
}
}
};
}
/// Writes `prefix-<16 hex digits>` into `buf`, returning the number of bytes
/// written.
fn render(buf: &mut [u8; ID_BUF], prefix: &str, value: u64) -> usize {
const HEX: &[u8; 16] = b"0123456789abcdef";
let p = prefix.as_bytes();
let mut n = 0;
for &b in p {
buf[n] = b;
n += 1;
}
buf[n] = b'-';
n += 1;
for shift in (0..16).rev() {
let nibble = (value >> (shift * 4)) & 0x0f;
buf[n] = HEX[nibble as usize];
n += 1;
}
n
}
define_id!(
/// Identifies a person.
///
/// The only identity an attestation can be minted from (see the
/// `jac-decision` crate). Deliberately a different type from [`AgentId`]:
/// the distinction carries the attestation invariant.
HumanId,
"hum"
);
define_id!(
/// Identifies a coding agent or model.
///
/// Agents may author snapshots and propose decisions. No code path turns
/// an `AgentId` into an attestation.
AgentId,
"agt"
);
define_id!(
/// Identifies an organisation participating in the rendezvous registry.
OrgId,
"org"
);
define_id!(
/// Identifies one published sketch in the rendezvous registry.
PublicationId,
"pub"
);
define_id!(
/// Identifies a brokered introduction between two organisations.
///
/// The token both parties receive. The registry never carries content
/// between them; introduction happens out of band.
IntroductionId,
"intro"
);
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "test assertions read better with unwrap"
)]
mod tests {
use super::*;
#[test]
fn option_is_niche_optimised() {
assert_eq!(size_of::<HumanId>(), 8);
assert_eq!(size_of::<Option<HumanId>>(), 8);
assert_eq!(size_of::<Option<AgentId>>(), 8);
}
#[test]
fn zero_is_rejected() {
assert_eq!(HumanId::new(0), Err(CoreError::ZeroIdentifier));
}
#[test]
fn display_roundtrips() {
let id = HumanId::new(42).unwrap();
assert_eq!(id.to_string(), "hum-000000000000002a");
assert_eq!(HumanId::from_str("hum-000000000000002a").unwrap(), id);
}
#[test]
fn prefixes_are_not_interchangeable() {
assert!(AgentId::from_str("hum-000000000000002a").is_err());
}
#[test]
fn json_uses_the_prefixed_form() {
let id = OrgId::new(255).unwrap();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"org-00000000000000ff\"");
assert_eq!(serde_json::from_str::<OrgId>(&json).unwrap(), id);
}
}