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