Skip to main content

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}