prov_identity/lib.rs
1//! Identity policy — when a document earns an id, and how one is minted.
2//!
3//! Everything here is optional. The graph and mutation layers operate on paths
4//! and never require an ID. This module decides **when** a document earns a
5//! stable ID (the trigger set) and **what** that ID looks like (the mint).
6//! *Where* IDs are stored is [`prov_graph::index`]; the [`Id`] type itself and the
7//! well-formedness check over it are [`prov_graph::identity`], because
8//! resolving a link needs to *recognize* an id without being able to issue one.
9//!
10//! The default is [`NoIdentity`] — identity off, no ID ever written. The
11//! recommended lazy policy registers an ID only when something durably refers
12//! to a document (a link-by-id or a publish), keeping the authoritative set as
13//! small as possible.
14//!
15//! Minting is random (opaque for free), with uniqueness enforced by rejection
16//! against the index — including its tombstones, so a deleted document's ID is
17//! never reissued. An ID may contain, and begin with, a digit; anything
18//! stamping one into metadata must keep it a *string* (see
19//! `prov-store`'s `edit::infer_scalar`). The alphabet, check-character arithmetic,
20//! and seeded PRNG all live in [`moid`].
21
22use std::path::Path;
23
24use moid::Alphabet;
25use moid::SeededRng;
26
27pub use prov_graph::identity::{BLADE_LEN, BLADE_RANDOM_LEN, Id, verify};
28
29fn canonical_minter() -> moid::Minter {
30 moid::Minter::new(Alphabet::noid_xdigit(), BLADE_RANDOM_LEN)
31}
32
33/// Random characters in a *minted* workspace name — twice a document blade's
34/// [`BLADE_RANDOM_LEN`], for a different uniqueness problem.
35///
36/// A document ID is unique by *rejection*: the minter can see the registry, so a
37/// collision is caught and re-rolled, and six characters (29⁶ ≈ 595M) is ample.
38/// A workspace name has no such arbiter — nothing can see the other workspaces
39/// in the world, which is exactly why `prov_config::is_valid_workspace_id`
40/// refuses to promise uniqueness. So the only defense a minted name has is its
41/// width: at 29¹² ≈ 3.5 × 10¹⁷, a million independently minted names collide
42/// with probability ~10⁻⁶. That is what makes an unaudited mint honest to call
43/// globally unique.
44pub const WORKSPACE_NAME_RANDOM_LEN: usize = 12;
45
46/// Total length of a minted workspace name: [`WORKSPACE_NAME_RANDOM_LEN`] plus
47/// the check character every [`moid`] blade ends with.
48pub const WORKSPACE_NAME_LEN: usize = WORKSPACE_NAME_RANDOM_LEN + 1;
49
50/// Mint an opaque global name for a *workspace*, randomizing from `seed`.
51///
52/// The name a workspace calls itself is normally the user's to choose — it is
53/// read by humans, in `id:<workspace>/<id>` references. This is the escape hatch
54/// for when there is no good choice to make: a workspace that must be nameable
55/// from anywhere, whose owner has no naming authority to lean on and would
56/// rather not gamble that `notes` is theirs alone. So this is offered, never
57/// applied: nothing in prov mints a workspace name on its own, because a name is
58/// a *commitment* (every reference written elsewhere is spelled with it), and
59/// prov does not make commitments on a user's behalf.
60///
61/// The result is a [`moid`] blade over the same NOID extended-digit alphabet as
62/// a document ID, and so is always well-formed by
63/// `prov_config::is_valid_workspace_id`: no vowels (nothing accidentally spells
64/// a word), and no `/`, `:` or whitespace to break the qualifier position it
65/// gets written in. It is deliberately *not* prefixed or otherwise marked as
66/// minted — a reader of a reference has no business caring whether the name was
67/// chosen or rolled.
68pub fn mint_workspace_id(seed: u64) -> String {
69 moid::Minter::new(Alphabet::noid_xdigit(), WORKSPACE_NAME_RANDOM_LEN)
70 .mint_seeded(&mut SeededRng::new(seed))
71}
72
73/// Which events cause a document to be assigned (registered) an ID.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct Registration {
76 /// Register every document at creation time (eager).
77 pub on_create: bool,
78 /// Register when a document is first referenced by ID (e.g. a wikilink).
79 pub on_link: bool,
80 /// Register when a document is published.
81 pub on_publish: bool,
82}
83
84impl Registration {
85 /// Never register — identity is effectively off.
86 pub const OFF: Self = Self {
87 on_create: false,
88 on_link: false,
89 on_publish: false,
90 };
91 /// Register only on a durable reference (link-by-id or publish). Recommended.
92 pub const LAZY: Self = Self {
93 on_create: false,
94 on_link: true,
95 on_publish: true,
96 };
97 /// Register every document the moment it is created.
98 pub const EAGER: Self = Self {
99 on_create: true,
100 on_link: true,
101 on_publish: true,
102 };
103
104 /// Whether any trigger is active.
105 pub fn is_active(&self) -> bool {
106 self.on_create || self.on_link || self.on_publish
107 }
108}
109
110/// The registration event a caller is asking about (for example, a
111/// workspace's `register` operation).
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum Trigger {
114 /// A document was created.
115 Create,
116 /// Something is about to link to the document by ID.
117 Link,
118 /// The document is being published.
119 Publish,
120}
121
122impl Registration {
123 /// Whether this trigger set fires for `event`.
124 pub fn fires_on(&self, event: Trigger) -> bool {
125 match event {
126 Trigger::Create => self.on_create,
127 Trigger::Link => self.on_link,
128 Trigger::Publish => self.on_publish,
129 }
130 }
131}
132
133/// A policy deciding when to register documents and how their IDs are minted.
134pub trait IdentityPolicy {
135 /// The registration trigger set for this policy.
136 fn registration(&self) -> Registration;
137
138 /// Mint a fresh ID for the document at `path`. Only called when a trigger
139 /// fires, so a disabled policy need never produce a meaningful value.
140 /// Uniqueness is the *caller's* job (mint-with-rejection against the
141 /// index); a mint may repeat.
142 fn mint(&mut self, path: &Path) -> Id;
143}
144
145/// Identity disabled — the default. Paths only; no ID is ever minted or written.
146#[derive(Debug, Clone, Copy, Default)]
147pub struct NoIdentity;
148
149impl IdentityPolicy for NoIdentity {
150 fn registration(&self) -> Registration {
151 Registration::OFF
152 }
153
154 fn mint(&mut self, _path: &Path) -> Id {
155 // Unreachable in practice: `OFF` fires no triggers.
156 Id(String::new())
157 }
158}
159
160/// The bundled minting policy: NOID xdigit + check IDs from a seeded PRNG.
161///
162/// Minting is delegated to [`moid`]: a [`moid::Minter`] over the canonical
163/// alphabet ([`canonical_minter`]) driven by a [`moid::SeededRng`]. The RNG is
164/// xorshift64 — *not* cryptographic, and not claimed to be: these are opaque
165/// internal handles whose uniqueness is enforced by rejection, not by entropy.
166/// Both parts are `Clone`/`Debug`, which keeps this policy (and any workspace
167/// carrying it) `Clone`/`Debug`, and a fixed seed makes tests deterministic. A
168/// deployment wanting stronger opacity (or ARK permalinks, like diaryx)
169/// implements [`IdentityPolicy`] itself.
170#[derive(Debug, Clone)]
171pub struct Minter {
172 registration: Registration,
173 minter: moid::Minter,
174 rng: SeededRng,
175}
176
177impl Minter {
178 /// Register only on a durable reference (the recommended default),
179 /// randomizing from `seed`.
180 pub fn lazy(seed: u64) -> Self {
181 Self::with(Registration::LAZY, seed)
182 }
183
184 /// Register every document at creation, randomizing from `seed`.
185 pub fn eager(seed: u64) -> Self {
186 Self::with(Registration::EAGER, seed)
187 }
188
189 /// Register on a custom trigger set, randomizing from `seed`. A zero seed is
190 /// nudged off xorshift64's fixed point by [`moid::SeededRng`].
191 pub fn with(registration: Registration, seed: u64) -> Self {
192 Self {
193 registration,
194 minter: canonical_minter(),
195 rng: SeededRng::new(seed),
196 }
197 }
198}
199
200impl IdentityPolicy for Minter {
201 fn registration(&self) -> Registration {
202 self.registration
203 }
204
205 fn mint(&mut self, _path: &Path) -> Id {
206 Id(self.minter.mint_seeded(&mut self.rng))
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use moid::Alphabet;
214
215 #[test]
216 fn no_identity_is_off() {
217 assert!(!NoIdentity.registration().is_active());
218 }
219
220 #[test]
221 fn lazy_registers_on_link_and_publish_only() {
222 let r = Minter::lazy(1).registration();
223 assert!(!r.fires_on(Trigger::Create));
224 assert!(r.fires_on(Trigger::Link));
225 assert!(r.fires_on(Trigger::Publish));
226 }
227
228 #[test]
229 fn eager_registers_on_create() {
230 assert!(Minter::eager(1).registration().fires_on(Trigger::Create));
231 }
232
233 #[test]
234 fn mints_verified_distinct_opaque_ids() {
235 let mut p = Minter::eager(42);
236 let a = p.mint(Path::new("a.md"));
237 let b = p.mint(Path::new("b.md"));
238 assert_ne!(a, b);
239 for id in [&a, &b] {
240 assert_eq!(id.as_str().len(), BLADE_LEN);
241 assert!(verify(id.as_str()), "{id}");
242 }
243 }
244
245 #[test]
246 fn same_seed_is_deterministic() {
247 let a = Minter::lazy(7).mint(Path::new("x"));
248 let b = Minter::lazy(7).mint(Path::new("y"));
249 assert_eq!(a, b, "path does not participate in the mint");
250 }
251
252 #[test]
253 fn mints_wide_opaque_workspace_names() {
254 let a = mint_workspace_id(42);
255 let b = mint_workspace_id(43);
256 assert_ne!(a, b);
257 for name in [&a, &b] {
258 assert_eq!(name.chars().count(), WORKSPACE_NAME_LEN);
259 // Every constraint the qualifier position imposes, checked here
260 // rather than through `prov-config` (which this crate cannot see):
261 // non-empty, and none of the three characters that would break
262 // `id:<workspace>/<id>` apart.
263 assert!(!name.is_empty());
264 assert!(
265 !name
266 .chars()
267 .any(|c| c == '/' || c == ':' || c.is_whitespace()),
268 "{name} cannot be written as a reference qualifier"
269 );
270 }
271 }
272
273 /// A minted workspace name is *wider* than a document ID, and that width is
274 /// the entire uniqueness argument — nothing rejects a colliding one, because
275 /// nothing can see the other workspaces it might collide with. Asserted at
276 /// compile time, since narrowing the constant is the way this would be lost.
277 const _: () = assert!(WORKSPACE_NAME_LEN > BLADE_LEN);
278
279 #[test]
280 fn a_workspace_name_is_wider_than_a_document_id() {
281 assert!(
282 mint_workspace_id(1).chars().count() > Minter::lazy(1).mint(Path::new("x")).0.len()
283 );
284 }
285
286 #[test]
287 fn verify_rejects_typos() {
288 let id = Minter::lazy(3).mint(Path::new("x")).0;
289 assert!(verify(&id));
290 // Flip one body character to another alphabet character.
291 let mut chars: Vec<char> = id.chars().collect();
292 chars[0] = if chars[0] == 'b' { 'c' } else { 'b' };
293 let typo: String = chars.iter().collect();
294 assert!(!verify(&typo), "{typo}");
295 // Wrong length, wrong alphabet (vowels and `y` are both out).
296 assert!(!verify("bcd"));
297 assert!(!verify("aeiouAy"));
298 assert!(!verify("bcdfghy"));
299 }
300
301 #[test]
302 fn check_char_matches_the_noid_lineage() {
303 // Independently computed: the xdigit alphabet leads with the digits, so
304 // ordinals b=10,c=11,d=12,f=13,g=14,h=15 weighted by position 1..=6 →
305 // 10+22+36+52+70+90 = 280; 280 % 29 = 19 → the 19th xdigit symbol is
306 // 'n'. moid computes the same check character, so a full ID with that
307 // body validates.
308 assert_eq!(Alphabet::noid_xdigit().check_char("bcdfgh"), 'n');
309 assert!(verify("bcdfghn"));
310 }
311
312 #[test]
313 fn an_id_may_be_all_digits() {
314 // The point of the xdigit alphabet: digits are in it, so an ID can look
315 // like a number — which is why every stamp writes a string scalar.
316 let check = Alphabet::noid_xdigit().check_char("012345");
317 assert!(verify(&format!("012345{check}")));
318 }
319
320 /// The check character's whole reason to exist, stated as a law.
321 ///
322 /// `verify_rejects_typos` above flips one character of one ID and confirms
323 /// the result is refused. That is a witness, and the claim a check character
324 /// actually makes is universal: **no single-character substitution of a
325 /// valid ID is ever itself valid.** A check digit that caught most typos and
326 /// missed some would still pass every example anyone thought to write, and
327 /// would silently let a mistyped `id:` reference resolve to nothing while
328 /// looking well-formed — the failure `MalformedId` exists to prevent.
329 mod properties {
330 use super::*;
331 use proptest::prelude::*;
332
333 /// The NOID extended-digit alphabet: the ten digits plus the nineteen
334 /// consonants that cannot combine into a word (no vowels, no `y`, no
335 /// `l`). Twenty-nine symbols, which is where the crate's own "29^6 ≈
336 /// 595M" comes from. Written out here so a substitution can be drawn
337 /// from it; `every_minted_character_is_in_the_alphabet` keeps the
338 /// transcription honest.
339 const XDIGIT: &str = "0123456789bcdfghjkmnpqrstvwxz";
340
341 fn minted() -> impl Strategy<Value = String> {
342 any::<u64>().prop_map(|seed| Minter::lazy(seed).mint(Path::new("x")).0)
343 }
344
345 proptest! {
346 #[test]
347 fn every_minted_id_verifies_and_is_the_declared_length(id in minted()) {
348 prop_assert_eq!(id.chars().count(), BLADE_LEN);
349 prop_assert!(verify(&id), "{id}");
350 }
351
352 #[test]
353 fn every_minted_character_is_in_the_alphabet(id in minted()) {
354 for c in id.chars() {
355 prop_assert!(XDIGIT.contains(c), "`{c}` of `{id}` is not an xdigit");
356 }
357 }
358
359 /// The law. Substitute any one character of a valid ID — body or
360 /// check character — for any *other* alphabet character, and the
361 /// result must be refused. Every position, every replacement.
362 #[test]
363 fn no_single_character_slip_survives_verification(
364 id in minted(),
365 position in 0..BLADE_LEN,
366 replacement in 0..XDIGIT.chars().count(),
367 ) {
368 let alphabet: Vec<char> = XDIGIT.chars().collect();
369 let mut chars: Vec<char> = id.chars().collect();
370 let replacement = alphabet[replacement];
371 prop_assume!(chars[position] != replacement);
372 chars[position] = replacement;
373 let typo: String = chars.into_iter().collect();
374 prop_assert!(
375 !verify(&typo),
376 "`{typo}` is one character from `{id}` and still verified"
377 );
378 }
379
380 /// A transposition of two *adjacent, different* characters is the
381 /// other slip a check character is chosen to catch — the one a
382 /// simple sum cannot see, since addition does not care about order.
383 #[test]
384 fn no_adjacent_transposition_survives_verification(
385 id in minted(),
386 position in 0..BLADE_LEN - 1,
387 ) {
388 let mut chars: Vec<char> = id.chars().collect();
389 prop_assume!(chars[position] != chars[position + 1]);
390 chars.swap(position, position + 1);
391 let swapped: String = chars.into_iter().collect();
392 prop_assert!(
393 !verify(&swapped),
394 "`{swapped}` transposes two characters of `{id}` and still verified"
395 );
396 }
397 }
398 }
399}