macrame/util/ids.rs
1//! Identifiers: what the crate requires of them, and what it merely offers.
2//!
3//! # The decision (D-061, defects AD and J)
4//!
5//! **Concept ids are opaque, with two reserved characters.** They are caller
6//! data, they are not required to be ULIDs, and the crate stores them as it
7//! receives them. What it does require is that an id contain neither `|` nor
8//! `/`, because two of the crate's own encodings use those bytes as delimiters
9//! and are ambiguous without them reserved.
10//!
11//! Both of the options the plan offered failed on inspection, and the way they
12//! failed is what produced this one.
13//!
14//! **"Wire `validate_id` into the write path" would have meant requiring
15//! ULIDs.** That is not a tightening of the current contract, it is a different
16//! contract: every test in the suite uses ids like `a`, `SRC` and `n000`, and so,
17//! presumably, does every caller. Three modules *assumed* ULIDs; nothing ever
18//! *required* them, and the assumption was wrong rather than merely unenforced.
19//!
20//! **"Declare ids fully opaque" would have left two encodings ambiguous.** It is
21//! stated as needing only a width-independent cycle check, and that is the
22//! smaller half. The other half is `transaction_log.entity_id`, which a link
23//! writes as `source|target|type|valid_from`. With `|` legal inside a component,
24//! two genuinely different links can produce one key — so the log cannot say
25//! which relationship a row belongs to, and the fold partition that closed defect
26//! W does not help, because both rows are links.
27//!
28//! So the constraint is the delimiters and nothing else. It is narrow enough to
29//! leave every existing id valid, and it turns three assumptions into one
30//! enforced property:
31//!
32//! - `edge_key` / `trg_links_log_insert` — the `|` join is now unambiguous.
33//! - The traversal CTE's cycle check — `/`-delimited and no longer dependent on
34//! every id being the same width. See `TraversalBuilder::build_sql`.
35//! - Defect W's collision — a concept id can no longer *be* a link key, so the
36//! conflation is unreachable rather than merely harmless. The partition fix
37//! stays as defence in depth.
38//!
39//! Rejected: percent-encoding the components instead (moves the cost to every
40//! read and makes the log unreadable by eye, to buy two characters back);
41//! choosing delimiters no id would plausibly contain (the same bet, made
42//! silently — "plausibly" is what an assumption is).
43
44use ulid::Ulid;
45
46use crate::error::{DbError, Result};
47
48/// Bytes an identifier may not contain, and where each is load-bearing.
49///
50/// `|` joins a link's entity key in `transaction_log`. Structural, not
51/// cosmetic — a collision produces a wrong answer with no error.
52///
53/// **`/` was reserved here through 0.5.6 and is free again as of 0.6.0
54/// (T0.1, D-076).** It existed for exactly one reason: the traversal CTE
55/// carried a `path` column of visited ids, delimited by `/`, and D-061 reserved
56/// the character so the cycle check could match a whole element rather than a
57/// substring once ids became variable-length. T0.1 deleted the path column —
58/// the walk dedupes on entry instead — so nothing in the crate delimits with
59/// `/` any more, and continuing to refuse it would be a constraint kept for a
60/// mechanism that no longer exists.
61///
62/// Relaxing is safe in the direction that matters: ids previously refused are
63/// now accepted, so no stored id becomes invalid and no migration is implied.
64/// Adding a character back later would not be safe, which is why this list is
65/// worth keeping short.
66pub const RESERVED_ID_CHARS: [char; 1] = ['|'];
67
68/// Generate a new Crockford base32 26-character ULID string.
69///
70/// **Offered, not required.** Callers with no identifier scheme of their own
71/// should use this: ULIDs are sortable, collision-resistant, and contain neither
72/// reserved character by construction. Callers who have their own ids keep them,
73/// subject only to [`validate_id`].
74pub fn generate_id() -> String {
75 Ulid::new().to_string()
76}
77
78/// Whether `id` is a ULID. Not a requirement — see [`generate_id`].
79pub fn is_ulid(id: &str) -> bool {
80 Ulid::from_string(id).is_ok()
81}
82
83/// Refuse an identifier the crate's own encodings cannot represent (D-061).
84///
85/// Called from `ConceptUpsert::normalized` and `EdgeAssertion::normalized`, so
86/// a bad id is refused at the boundary with a typed error rather than becoming
87/// an ambiguous log key three layers down.
88///
89/// **The error type is the fix for defect J.** This used to return
90/// `DbError::NotFound(id)` for a malformed identifier, which says the opposite
91/// of what happened — the id was not looked up and not missing; it was refused.
92/// A caller matching on `NotFound` to decide whether to create the thing would
93/// have been sent to create it again, with the same id, forever.
94pub fn validate_id(id: &str) -> Result<()> {
95 if id.is_empty() {
96 return Err(DbError::InvalidId {
97 id: id.to_string(),
98 reason: "identifier is empty".to_string(),
99 });
100 }
101 if let Some(c) = id.chars().find(|c| RESERVED_ID_CHARS.contains(c)) {
102 return Err(DbError::InvalidId {
103 id: id.to_string(),
104 reason: format!(
105 "identifier contains the reserved character {c:?}; \
106 {RESERVED_ID_CHARS:?} delimits the transaction-log entity key, \
107 so an id carrying one is ambiguous"
108 ),
109 });
110 }
111 Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn ordinary_identifiers_are_accepted() {
120 // Every shape the suite and, by implication, callers actually use.
121 for id in ["a", "SRC", "n000", "node_100", "c1", &generate_id()] {
122 validate_id(id).unwrap_or_else(|e| panic!("{id:?} must stay valid: {e}"));
123 }
124 }
125
126 /// `/` is accepted again (T0.1, D-076).
127 ///
128 /// It was reserved solely for the traversal CTE's path delimiter, and the
129 /// path column is gone. Asserted rather than assumed, because a constraint
130 /// that outlives its mechanism is invisible until someone hits it — and
131 /// because the direction matters: this test failing means the crate has
132 /// started refusing ids it accepted, which is a breaking change to callers.
133 #[test]
134 fn a_slash_is_no_longer_reserved() {
135 for id in ["a/b", "urn:x/y/z", "2026/01/01"] {
136 validate_id(id).unwrap_or_else(|e| panic!("{id:?} must be accepted now: {e}"));
137 }
138 }
139
140 #[test]
141 fn a_reserved_character_is_refused_by_name() {
142 for id in ["a|b", "a|b|KNOWS|2026-01-01T00:00:00.000000Z"] {
143 let err = validate_id(id).unwrap_err();
144 assert!(
145 matches!(err, DbError::InvalidId { .. }),
146 "{id:?} gave {err:?}"
147 );
148 }
149 }
150
151 /// Defect J: a refused identifier is not a missing one.
152 #[test]
153 fn a_refusal_is_not_a_not_found() {
154 assert!(!matches!(
155 validate_id("a|b").unwrap_err(),
156 DbError::NotFound(_)
157 ));
158 }
159
160 /// A ULID must satisfy the constraint, or `generate_id` would hand callers
161 /// ids the write path rejects.
162 #[test]
163 fn generated_ids_are_always_valid() {
164 for _ in 0..64 {
165 let id = generate_id();
166 assert!(is_ulid(&id));
167 validate_id(&id).unwrap();
168 }
169 }
170}