Skip to main content

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}