axess-identity 0.2.0

Identity primitives for the axess workspace: typed identifiers (TenantId, UserId, DeviceId, SessionId, EventId; all `FooId(Uuid)` newtypes via the `define_id!` macro) plus the principal abstraction (unified `Principal` enum with `Human` + `Workload` variants, SPIFFE-shaped `WorkloadId` / `TrustDomain` / `Issuer`, and the async `PrincipalResolver` trait + `CliResolver` impl). Foundation crate, deliberately small: depends only on `axess-rng` (for the DST-injectable `SecureRng` trait), `uuid`, and `thiserror`. No tokio, no axum, no Cedar; axess-core layers session integration plus Cedar entity emission on top of these primitives. See `docs/workload-identity/README.md` for the broader design.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
//! Typed identifier primitives for the axess workspace.
//!
//! Every entity identifier in axess-core (auth principals, sessions,
//! devices) and axess-events (envelope identity) uses the same shape:
//! `pub struct FooId(uuid::Uuid)`, 16 bytes, `Copy`, with random UUID v4
//! bytes minted from the workspace [`SecureRng`](axess_rng::SecureRng)
//! at creation. Construction splits into two camps: adopter-supplied
//! ids (`TenantId`, `UserId`) accept whatever the adopter brings via
//! `from_uuid` / `from_namespaced_str` / `from_bytes` and surface
//! `::SYSTEM` sentinels; axess-minted ids (`DeviceId`, `SessionId`,
//! `EventId`) call `FooId::new(rng)` because opacity is the security
//! contract.
//!
//! The [`define_id!`](crate::define_id!) macro declares custom domain
//! ids with the same machinery.
//!
//! # Example
//!
//! ```
//! use axess_identity::define_id;
//!
//! define_id! {
//!     /// Account identifier, globally unique across the platform.
//!     pub AccountId
//! }
//!
//! let mut rng = axess_rng::testing::MockRng::new(42);
//! let id = AccountId::new(&mut rng);
//! assert!(!id.is_nil());
//! ```
//!
//! Test fixtures live in [`testing`], which derives ids deterministically
//! from string labels over a fixed workspace test namespace.

#![deny(unsafe_code)]
#![deny(missing_docs)]
#![cfg_attr(docsrs, feature(doc_cfg))]

pub use uuid::Uuid;

/// Validation failure for [`define_id!`](crate::define_id!)'s `try_new` /
/// [`ensure_user_id_not_reserved`].
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdError {
    /// The identifier was empty.
    Empty(&'static str),
    /// The identifier was not a valid hyphenated Uuid.
    NotAUuid(&'static str),
    /// The identifier matched a reserved system sentinel and the
    /// calling guard refused to accept it.
    Reserved(&'static str),
}

impl core::fmt::Display for IdError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            IdError::Empty(kind) => write!(f, "{kind} must be non-empty"),
            IdError::NotAUuid(kind) => write!(f, "{kind} must be a valid Uuid"),
            IdError::Reserved(kind) => write!(f, "{kind} matches reserved system identifier"),
        }
    }
}

impl std::error::Error for IdError {}

/// Mint 16 cryptographically random bytes into a UUID v4 (sets the
/// version + RFC 4122 variant bits per spec).
///
/// **Prefer the typed `FooId::new(&mut rng)` constructor generated by
/// [`define_id!`](crate::define_id!).** This bare-`Uuid` mint is an
/// escape hatch for niche cases (a foreign-key column with no typed
/// wrapper yet, or interop with an external API that demands a raw
/// `Uuid`). Direct use in domain crates erodes the type-discrimination
/// property that the typed-id newtypes provide.
///
/// **DST discipline.** This function is the only sanctioned random mint
/// path in the workspace; it routes through the injected
/// [`SecureRng`](axess_rng::SecureRng), so production threads
/// `SystemRng` and tests inject `MockRng` for reproducible runs.
/// Calling `Uuid::new_v4()` directly bypasses the injection; a CI guard
/// (PG-072 in the platform ROADMAP) flags any new `Uuid::new_v4()`
/// introductions outside this crate. Direct use of `mint_v4` outside
/// its typed-id callers is similarly suspect; consider declaring a new
/// typed id via [`define_id!`](crate::define_id!) instead.
pub fn mint_v4<R: axess_rng::SecureRng>(rng: &R) -> Uuid {
    let mut bytes = [0u8; 16];
    rng.fill_bytes(&mut bytes);
    uuid::Builder::from_random_bytes(bytes).into_uuid()
}

// ── Thread-local RNG override (PG-072) ──────────────────────────────────────

type RngFiller = Box<dyn FnMut(&mut [u8])>;

std::thread_local! {
    static THREAD_LOCAL_RNG: std::cell::RefCell<Option<RngFiller>>
        = const { std::cell::RefCell::new(None) };
}

/// Mint a fresh UUID v4 from the **default** RNG source, no caller-side
/// `R: SecureRng` plumbing required.
///
/// Production behaviour: pulls bytes from [`axess_rng::SystemRng`] (the
/// OS-provided CSPRNG via `rand::rng()`).
///
/// DST behaviour: when [`with_thread_local_rng`] is active on the current
/// thread, the supplied [`SecureRng`](axess_rng::SecureRng) is used instead.
/// Tests wrap the section of work that mints identity in
/// `with_thread_local_rng(MockRng::new(seed), || { ... })` to make minting
/// reproducible across runs.
///
/// **Why this exists** (PG-072 in the platform ROADMAP): the typed
/// `FooId::new(&rng)` constructor is the DST-correct mint path, but
/// adopting it requires every minting call site to thread an
/// `R: SecureRng` parameter through every function signature in its
/// call stack. The platform has hundreds of direct `Uuid::new_v4()`
/// callers spread across services, handlers, and repos; threading RNG
/// through all of them at once is a prohibitively invasive sweep.
/// `mint_v4_default()` is the pragmatic alternative: a no-arg helper
/// that honours the DST contract via a thread-local override, and
/// otherwise falls back to the OS RNG. Tests opt in per-thread;
/// production code is unaffected.
///
/// # Panics
///
/// On `wasm32-unknown-unknown` without a thread-local override installed,
/// since `SystemRng` is not available there. Either install an override or
/// switch to the typed `FooId::new(&rng)` API on that target.
pub fn mint_v4_default() -> Uuid {
    let mut bytes = [0u8; 16];
    THREAD_LOCAL_RNG.with(|cell| {
        let mut opt = cell.borrow_mut();
        if let Some(filler) = opt.as_mut() {
            filler(&mut bytes);
        } else {
            #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
            {
                use axess_rng::SecureRng;
                axess_rng::SystemRng.fill_bytes(&mut bytes);
            }
            #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
            {
                panic!(
                    "mint_v4_default() on wasm32-unknown-unknown requires a thread-local RNG \
                     override (see with_thread_local_rng); the OS RNG is not available on that target."
                );
            }
        }
    });
    uuid::Builder::from_random_bytes(bytes).into_uuid()
}

/// Install a thread-local RNG override for the duration of `f`. Restores
/// the previous override (if any) on return, including on panic, via a
/// Drop guard.
///
/// Used by tests to make [`mint_v4_default`] (and any typed id minted via
/// the no-arg `FooId::new_default()` helper, when added) deterministic.
/// Production code must not call this; the override leaks RNG state
/// across otherwise-unrelated work on the same thread, and the only
/// sanctioned production RNG is [`axess_rng::SystemRng`].
pub fn with_thread_local_rng<R, F, T>(rng: R, f: F) -> T
where
    R: axess_rng::SecureRng,
    F: FnOnce() -> T,
{
    struct Guard(Option<RngFiller>);
    impl Drop for Guard {
        fn drop(&mut self) {
            THREAD_LOCAL_RNG.with(|cell| {
                *cell.borrow_mut() = self.0.take();
            });
        }
    }

    let prev = THREAD_LOCAL_RNG.with(|cell| {
        cell.borrow_mut().replace(Box::new(move |dest: &mut [u8]| {
            rng.fill_bytes(dest);
        }))
    });
    let restore_guard = Guard(prev);
    let result = f();
    drop(restore_guard);
    result
}

/// Declare a typed id newtype.
///
/// Expands to a `pub struct $name(uuid::Uuid)` newtype with the
/// standard impls (Display, FromStr, `From<Uuid>`, optional serde
/// transparent, optional rkyv) plus a DST-friendly
/// `new<R: SecureRng>(rng) -> Self` constructor. See the crate-level
/// docs for the full API surface.
///
/// # Visibility constraint
///
/// The macro accepts only `pub` newtypes; the matcher requires
/// `pub $name`. This is deliberate: typed identity primitives are
/// public API (they cross crate boundaries), and `pub(crate)` /
/// `pub(super)` ids would couple the type to its local module without
/// buying anything. If you need a crate-private id-shaped helper,
/// prefer a tiny inline struct or a `pub` newtype with a `pub(crate)`
/// constructor.
///
/// # Transitive deps required by the expansion
///
/// `define_id!` expands to code referring to [`axess_rng::SecureRng`]
/// in the `Self::new` constructor's bound. Adopters need
/// `axess-rng` as a direct dependency (and `uuid` for the
/// underlying type). Adopters using the optional `serde` /
/// `rkyv` features additionally need `serde` / `rkyv` as direct
/// dependencies. axess-identity ships the smoke for these to work; the
/// macro itself emits no version-specific algorithm calls beyond
/// what `mint_v4` invokes.
///
/// ```
/// use axess_identity::define_id;
///
/// define_id! {
///     /// An account identifier.
///     pub AccountId
/// }
///
/// let rng = axess_rng::testing::MockRng::new(42);
/// let id = AccountId::new(&rng);
/// assert!(!id.is_nil());
/// assert_eq!(id.as_uuid().get_version_num(), 4);
/// ```
#[macro_export]
macro_rules! define_id {
    ($(#[$meta:meta])* pub $name:ident) => {
        $(#[$meta])*
        ///
        /// Backed by [`uuid::Uuid`] (16 bytes, `Copy`). UUID v4
        /// random when minted via [`Self::new`]; v5 namespaced when
        /// adopted from a non-UUID source via
        /// [`Self::from_namespaced_str`]; bytes stored verbatim
        /// when restored from persistence via [`Self::from_bytes`].
        /// Wire format under serde is the hyphenated UUID string;
        /// under rkyv it's the 16-byte archive layout.
        #[cfg_attr(
            feature = "serde",
            derive(serde::Serialize, serde::Deserialize)
        )]
        #[cfg_attr(feature = "serde", serde(transparent))]
        #[cfg_attr(
            feature = "rkyv",
            derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
        )]
        // `Default` is intentionally **not** derived. `Default::default()`
        // would yield `Self(Uuid::nil())` which collides with
        // `TenantId::SYSTEM` / `UserId::SYSTEM` semantics: a developer
        // writing `let tid: TenantId = Default::default();` to mean
        // "placeholder" would silently produce the system-tenant
        // sentinel. Use [`Self::NIL`] explicitly when an all-zero id is
        // intended; use [`Self::new`] with an injected RNG when a fresh
        // id is wanted; use [`Self::from_uuid`] when wrapping an
        // externally-supplied Uuid.
        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
        pub struct $name($crate::Uuid);

        impl $name {
            /// All-zero sentinel. Equal to [`Uuid::nil`].
            pub const NIL: Self = Self($crate::Uuid::nil());

            /// Mint a fresh UUID v4 from the supplied
            /// [`SecureRng`](axess_rng::SecureRng). Sets version 4 +
            /// RFC 4122 variant bits per spec; the remaining 122
            /// bits come from `rng`.
            #[inline]
            pub fn new<R: axess_rng::SecureRng>(rng: &R) -> Self {
                Self($crate::mint_v4(rng))
            }

            #[doc = concat!(
                "Construct from a string (which must be a valid hyphenated Uuid). ",
                "Returns [`IdError::Empty`] for empty input or ",
                "[`IdError::NotAUuid`] if parsing fails.",
            )]
            pub fn try_new(value: impl AsRef<str>) -> ::std::result::Result<Self, $crate::IdError> {
                let s = value.as_ref();
                if s.is_empty() {
                    return Err($crate::IdError::Empty(stringify!($name)));
                }
                $crate::Uuid::parse_str(s)
                    .map(Self)
                    .map_err(|_| $crate::IdError::NotAUuid(stringify!($name)))
            }

            /// Wrap an existing [`Uuid`].
            #[inline]
            pub const fn from_uuid(uuid: $crate::Uuid) -> Self {
                Self(uuid)
            }

            /// Construct from raw bytes verbatim (version and variant
            /// bits are not adjusted). For round-tripping persisted ids
            /// whose bytes already encode a valid Uuid.
            #[inline]
            pub const fn from_bytes(bytes: [u8; 16]) -> Self {
                Self($crate::Uuid::from_bytes(bytes))
            }

            /// Construct a UUID v4-shaped id from 16 random bytes
            /// (sets version + variant bits per RFC 4122).
            ///
            /// **Prefer [`Self::new`].** This constructor is for the
            /// niche case where 16 random bytes have already been
            /// drawn (e.g. from a fixed test seed buffer or an
            /// external CSPRNG that doesn't expose
            /// [`SecureRng`](axess_rng::SecureRng)). When you have a
            /// `SecureRng` in scope (production code always does,
            /// tests should), `Self::new(&mut rng)` is the DST-correct
            /// path: the only random source is the injected RNG, so
            /// tests that mint identity remain reproducible across
            /// runs.
            #[inline]
            pub fn from_random_bytes(bytes: [u8; 16]) -> Self {
                Self(uuid::Builder::from_random_bytes(bytes).into_uuid())
            }

            /// Map a non-UUID adopter identifier (slug, OAuth subject,
            /// integer-stringified, ...) to a stable id via UUID v5.
            /// Same `(namespace, name)` always produces the same id, so
            /// services agree without coordination.
            #[inline]
            pub fn from_namespaced_str(namespace: $crate::Uuid, name: &str) -> Self {
                Self($crate::Uuid::new_v5(&namespace, name.as_bytes()))
            }

            /// Borrow the raw 16-byte body. Zero-cost handoff to
            /// byte-shaped APIs.
            #[inline]
            pub const fn as_bytes(&self) -> &[u8; 16] {
                self.0.as_bytes()
            }

            /// Get the underlying [`Uuid`]. Zero-cost conversion to any
            /// sibling newtype (`other_crate::FooId::from_uuid(id.as_uuid())`).
            #[inline]
            pub const fn as_uuid(&self) -> $crate::Uuid {
                self.0
            }

            /// `true` when this is the all-zero ([`Uuid::nil`])
            /// sentinel.
            #[inline]
            pub fn is_nil(&self) -> bool {
                self.0.is_nil()
            }
        }

        impl ::std::fmt::Display for $name {
            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
                ::std::fmt::Display::fmt(&self.0, f)
            }
        }

        impl ::std::convert::From<$crate::Uuid> for $name {
            #[inline]
            fn from(uuid: $crate::Uuid) -> Self {
                Self(uuid)
            }
        }

        impl ::std::convert::From<$name> for $crate::Uuid {
            #[inline]
            fn from(id: $name) -> Self {
                id.0
            }
        }

        impl ::std::convert::From<[u8; 16]> for $name {
            #[inline]
            fn from(bytes: [u8; 16]) -> Self {
                Self($crate::Uuid::from_bytes(bytes))
            }
        }

        impl ::std::str::FromStr for $name {
            type Err = $crate::IdError;
            fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
                Self::try_new(s)
            }
        }

        // sqlx integration. Delegates to `String` so the wire format
        // matches the platform schema's TEXT-typed UUID columns. The cost
        // is one heap allocation per bind, equivalent to writing
        // `.bind(typed_id.to_string())` by hand; the gain is type-safe
        // binds (`.bind(&typed_id)`) and a forward-compat lever; flipping
        // the feature body to `uuid/sqlx` would route through the zero-copy
        // native UUID path (BLOB(16) on sqlite, native UUID on postgres)
        // once the schema migrates.
        #[cfg(feature = "sqlx")]
        impl<DB> ::sqlx::Type<DB> for $name
        where
            DB: ::sqlx::Database,
            ::std::string::String: ::sqlx::Type<DB>,
        {
            fn type_info() -> <DB as ::sqlx::Database>::TypeInfo {
                <::std::string::String as ::sqlx::Type<DB>>::type_info()
            }

            fn compatible(ty: &<DB as ::sqlx::Database>::TypeInfo) -> bool {
                <::std::string::String as ::sqlx::Type<DB>>::compatible(ty)
            }
        }

        #[cfg(feature = "sqlx")]
        impl<'q, DB> ::sqlx::Encode<'q, DB> for $name
        where
            DB: ::sqlx::Database,
            ::std::string::String: ::sqlx::Encode<'q, DB>,
        {
            fn encode_by_ref(
                &self,
                buf: &mut <DB as ::sqlx::Database>::ArgumentBuffer,
            ) -> ::std::result::Result<
                ::sqlx::encode::IsNull,
                ::sqlx::error::BoxDynError,
            > {
                <::std::string::String as ::sqlx::Encode<'q, DB>>::encode_by_ref(
                    &self.0.to_string(),
                    buf,
                )
            }
        }

        #[cfg(feature = "sqlx")]
        impl<'r, DB> ::sqlx::Decode<'r, DB> for $name
        where
            DB: ::sqlx::Database,
            ::std::string::String: ::sqlx::Decode<'r, DB>,
        {
            fn decode(
                value: <DB as ::sqlx::Database>::ValueRef<'r>,
            ) -> ::std::result::Result<Self, ::sqlx::error::BoxDynError> {
                let s = <::std::string::String as ::sqlx::Decode<'r, DB>>::decode(value)?;
                Self::try_new(s).map_err(::std::convert::Into::into)
            }
        }
    };
}

// ── Standard axess identity types ───────────────────────────────────────────

define_id! {
    /// Tenant identifier. Scopes principals and events to a
    /// multi-tenant boundary. Adopter-supplied: see
    /// [`Self::from_uuid`] for direct UUID adoption,
    /// [`Self::from_namespaced_str`] for v5 mapping of non-UUID
    /// identifiers, [`Self::SYSTEM`] for the reserved platform-operator
    /// sentinel.
    pub TenantId
}

impl TenantId {
    /// String form of the reserved system-tenant identifier
    /// (`"00000000-0000-0000-0000-000000000000"`). Application
    /// storage should install a real `tenants` row with this id so
    /// foreign-key references from tenant-scoped tables remain
    /// intact.
    pub const SYSTEM_STR: &'static str = "00000000-0000-0000-0000-000000000000";

    /// The reserved system-tenant sentinel, equal to [`Uuid::nil`].
    pub const SYSTEM: Self = Self(Uuid::nil());

    /// The reserved system-tenant identifier.
    #[inline]
    pub const fn system() -> Self {
        Self::SYSTEM
    }

    /// `true` when this identifier names the system tenant.
    #[inline]
    pub fn is_system(&self) -> bool {
        self.is_nil()
    }
}

define_id! {
    /// User (subject / principal) identifier. Adopter-supplied, same
    /// constructor surface as [`TenantId`]. Distinct [`Self::SYSTEM`]
    /// sentinel from the tenant so applications installing real rows
    /// for both don't collapse them.
    pub UserId
}

impl UserId {
    /// String form of the reserved system-user identifier
    /// (`"00000000-0000-0000-0000-000000000001"`). Distinct from
    /// [`TenantId::SYSTEM_STR`] so that an application installing
    /// real rows for both doesn't collapse the system tenant and
    /// the system user onto the same identifier.
    pub const SYSTEM_STR: &'static str = "00000000-0000-0000-0000-000000000001";

    /// The reserved system-user sentinel: non-nil, distinct from
    /// [`TenantId::SYSTEM`].
    pub const SYSTEM: Self = Self(Uuid::from_bytes([
        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    ]));

    /// The reserved system-user identifier.
    #[inline]
    pub const fn system() -> Self {
        Self::SYSTEM
    }

    /// `true` when this identifier names the system user.
    #[inline]
    pub fn is_system(&self) -> bool {
        *self == Self::SYSTEM
    }
}

define_id! {
    /// Device identifier, sibling to [`UserId`] / [`TenantId`].
    /// axess-minted: cryptographic opacity is the security contract, so
    /// prefer [`Self::new`] over [`Self::from_namespaced_str`] in
    /// production paths.
    pub DeviceId
}

define_id! {
    /// Session identifier. axess-minted; cryptographic opacity is the
    /// security contract. A session-id leak that revealed login time
    /// would be a vulnerability for forensic correlation against
    /// externally-observed events, so prefer [`Self::new`] (UUID v4
    /// random) over time-prefixed variants.
    ///
    /// # Logging discipline
    ///
    /// `SessionId` derives [`std::fmt::Debug`] and [`std::fmt::Display`]
    /// via [`define_id!`](crate::define_id!); both formats produce the
    /// full hyphenated UUID string. **Treat session ids as credentials
    /// in logs**: a `SessionId` that lands in a structured-log line, an
    /// observability pipeline, or a crash dump leaks a still-valid
    /// authenticator. Redact at the emission boundary (a project-local
    /// `RedactedSessionId(SessionId)` newtype, or manual masking of
    /// middle bytes). `axess-identity` does not redact at the type
    /// level because the full id form is needed at the storage and
    /// authn-validation boundaries.
    pub SessionId
}

define_id! {
    /// Event identifier for [`Event<P>`](https://docs.rs/axess-events)
    /// envelopes. axess-minted: UUID v4 random from a DST-injected
    /// [`SecureRng`](axess_rng::SecureRng). Sortability of events comes
    /// from `Event::time_micros` and from domain time-stamped fields,
    /// not from the id.
    pub EventId
}

/// Returns `Err(IdError::Reserved)` when `id` matches
/// [`UserId::SYSTEM`] or `tenant_id` matches [`TenantId::SYSTEM`].
/// Useful as a guard at the top of `IdentityStore::create_user`
/// implementations to refuse self-service signup with reserved
/// platform-operator identifiers.
pub fn ensure_user_id_not_reserved(user_id: &UserId, tenant_id: &TenantId) -> Result<(), IdError> {
    if user_id.is_system() {
        return Err(IdError::Reserved("UserId"));
    }
    if tenant_id.is_system() {
        return Err(IdError::Reserved("TenantId"));
    }
    Ok(())
}

// Test-fixture helpers (`tenant(label)`, `user(label)`, ...) for the typed
// ids live in [`crate::testing`] alongside [`crate::testing::MockResolver`];
// see that module for usage.

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testing;
    use axess_rng::testing::MockRng;

    #[test]
    fn system_tenant_is_nil() {
        assert!(TenantId::SYSTEM.is_system());
        assert!(TenantId::system().is_nil());
        assert_eq!(TenantId::SYSTEM.to_string(), TenantId::SYSTEM_STR);
    }

    #[test]
    fn system_user_distinct_from_system_tenant() {
        assert_ne!(UserId::SYSTEM.as_uuid(), TenantId::SYSTEM.as_uuid());
        assert!(UserId::SYSTEM.is_system());
        assert_eq!(UserId::SYSTEM.to_string(), UserId::SYSTEM_STR);
    }

    #[test]
    fn try_new_rejects_empty() {
        assert_eq!(TenantId::try_new(""), Err(IdError::Empty("TenantId")));
        assert_eq!(UserId::try_new(""), Err(IdError::Empty("UserId")));
    }

    #[test]
    fn try_new_rejects_non_uuid() {
        assert_eq!(
            TenantId::try_new("not-a-uuid"),
            Err(IdError::NotAUuid("TenantId"))
        );
    }

    #[test]
    fn try_new_accepts_uuid_string() {
        let t = TenantId::try_new("1f0a7b2e-4c91-4e3f-9b2a-8d0123456789").unwrap();
        assert!(!t.is_system());
    }

    #[test]
    fn new_is_dst_reproducible() {
        let a = MockRng::new(42);
        let b = MockRng::new(42);
        assert_eq!(TenantId::new(&a), TenantId::new(&b));
        assert_eq!(
            SessionId::new(&MockRng::new(7)).as_uuid().get_version_num(),
            4
        );
    }

    #[test]
    fn from_namespaced_str_is_deterministic() {
        let ns = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
        let a = TenantId::from_namespaced_str(ns, "ekekrantz");
        let b = TenantId::from_namespaced_str(ns, "ekekrantz");
        let c = TenantId::from_namespaced_str(ns, "wctest");
        assert_eq!(a, b);
        assert_ne!(a, c);
        assert_eq!(a.as_uuid().get_version_num(), 5);
    }

    #[test]
    fn ensure_user_id_not_reserved_blocks_system_user() {
        let res = ensure_user_id_not_reserved(&UserId::SYSTEM, &testing::tenant("t1"));
        assert_eq!(res, Err(IdError::Reserved("UserId")));
    }

    #[test]
    fn ensure_user_id_not_reserved_blocks_system_tenant() {
        let res = ensure_user_id_not_reserved(&testing::user("u1"), &TenantId::SYSTEM);
        assert_eq!(res, Err(IdError::Reserved("TenantId")));
    }

    #[test]
    fn ensure_user_id_not_reserved_accepts_normal_pair() {
        let res = ensure_user_id_not_reserved(&testing::user("u1"), &testing::tenant("t1"));
        assert!(res.is_ok());
    }

    #[test]
    fn testing_helpers_are_deterministic() {
        assert_eq!(testing::tenant("alice"), testing::tenant("alice"));
        assert_eq!(testing::user("alice"), testing::user("alice"));
        assert_eq!(testing::device("alice"), testing::device("alice"));
        assert_eq!(testing::session("alice"), testing::session("alice"));
        assert_eq!(testing::event("alice"), testing::event("alice"));
    }

    #[cfg(feature = "serde")]
    #[test]
    fn serde_wire_is_hyphenated_string() {
        let id =
            TenantId::from_uuid(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap());
        let json = serde_json::to_string(&id).unwrap();
        assert_eq!(json, "\"550e8400-e29b-41d4-a716-446655440000\"");
        let back: TenantId = serde_json::from_str(&json).unwrap();
        assert_eq!(id, back);
    }

    #[cfg(feature = "serde")]
    #[test]
    fn id_error_serialise_shape_is_stable() {
        // Pin the IdError JSON shape so error responses crossing the
        // wire (HTTP boundary, SIEM pipelines) stay stable. The
        // discriminator is the variant name; the body is the
        // `&'static str` kind label.
        //
        // Round-trip is intentionally one-way (serialise only): the
        // `&'static str` payload can't be reconstituted from
        // non-static input, so `Deserialize` is supported only for
        // structural symmetry (e.g. a higher-level error type that
        // happens to embed `IdError` and decodes from a known
        // source). External-input deserialisation should land in a
        // typed-error wrapper that owns its strings, not in
        // `IdError` directly.
        for (err, expected_json) in [
            (IdError::Empty("TenantId"), r#"{"Empty":"TenantId"}"#),
            (IdError::NotAUuid("UserId"), r#"{"NotAUuid":"UserId"}"#),
            (IdError::Reserved("DeviceId"), r#"{"Reserved":"DeviceId"}"#),
        ] {
            let json = serde_json::to_string(&err).unwrap();
            assert_eq!(json, expected_json, "IdError JSON shape drifted");
        }
    }

    #[test]
    fn no_default_impl() {
        // Pin the absence of `Default` on typed ids; `Default::default()`
        // would silently produce `Self(Uuid::nil())`, which collides
        // with `TenantId::SYSTEM` / `UserId::SYSTEM` semantics. Forcing
        // explicit construction (via `NIL`, `new(rng)`, `from_uuid`,
        // or `from_namespaced_str`) makes intent explicit at every
        // call site.
        //
        // This compile-time test asserts the absence by checking that
        // `TenantId` does NOT implement `Default`. If `Default` is
        // ever re-added, this test won't compile.
        fn assert_not_default<T>()
        where
            T: Sized,
        {
        }
        assert_not_default::<TenantId>();
        assert_not_default::<UserId>();
        assert_not_default::<SessionId>();
        assert_not_default::<DeviceId>();
        assert_not_default::<EventId>();
        // To be a useful pin: this would ideally check
        // `!impls Default`, but Rust's negative-bound expressivity
        // is limited. The signal is in the line above; call sites
        // that do `TenantId::default()` will fail at use, not here.
    }

    define_id! {
        /// Adopter-defined id used in macro tests.
        pub TestId
    }

    #[test]
    fn define_id_macro_yields_v4_via_new() {
        let rng = MockRng::new(7);
        let id = TestId::new(&rng);
        assert_eq!(id.as_uuid().get_version_num(), 4);
        assert!(!id.is_nil());
    }

    /// PG-072: `mint_v4_default()` honours the thread-local RNG override.
    /// Same seed via `with_thread_local_rng` produces the same Uuid both
    /// times, confirming DST reproducibility through the no-arg helper.
    #[test]
    fn mint_v4_default_is_dst_reproducible_under_override() {
        let a = with_thread_local_rng(MockRng::new(99), mint_v4_default);
        let b = with_thread_local_rng(MockRng::new(99), mint_v4_default);
        assert_eq!(a, b);
        // And: outside the override, a third draw uses SystemRng and is
        // (with overwhelming probability) distinct.
        let c = mint_v4_default();
        assert_ne!(a, c);
        assert_eq!(a.get_version_num(), 4);
        assert_eq!(b.get_version_num(), 4);
        assert_eq!(c.get_version_num(), 4);
    }

    /// sqlx round-trip: bind a typed id into a TEXT column, query it
    /// back, decode into the typed shape, verify the value matches.
    /// Catches both Encode (TEXT-shaped output) and Decode (TEXT input
    /// to `try_new` parsing) regressions in one go.
    #[cfg(feature = "sqlx")]
    #[tokio::test]
    async fn sqlx_text_column_roundtrip() {
        use sqlx::sqlite::SqlitePoolOptions;

        let pool = SqlitePoolOptions::new()
            .max_connections(1)
            .connect("sqlite::memory:")
            .await
            .unwrap();

        sqlx::query("CREATE TABLE ids (id TEXT NOT NULL PRIMARY KEY)")
            .execute(&pool)
            .await
            .unwrap();

        let rng = MockRng::new(13);
        let original = TenantId::new(&rng);

        sqlx::query("INSERT INTO ids (id) VALUES (?1)")
            .bind(original)
            .execute(&pool)
            .await
            .unwrap();

        let row: (TenantId,) = sqlx::query_as("SELECT id FROM ids LIMIT 1")
            .fetch_one(&pool)
            .await
            .unwrap();

        assert_eq!(row.0, original);
    }

    #[test]
    fn id_error_display_pins_each_variant_string() {
        assert_eq!(IdError::Empty("Foo").to_string(), "Foo must be non-empty");
        assert_eq!(
            IdError::NotAUuid("Bar").to_string(),
            "Bar must be a valid Uuid"
        );
        assert_eq!(
            IdError::Reserved("Baz").to_string(),
            "Baz matches reserved system identifier"
        );
    }

    /// The Drop impl on `with_thread_local_rng`'s `Guard` restores
    /// the previous thread-local override on scope exit. With the
    /// Drop body deleted (mutation), the inner override would leak
    /// into the outer scope, so a re-mint after the inner closure
    /// returns would observe the inner seed's draws instead of the
    /// outer seed's. Pin the restoration by comparing draws on both
    /// sides of the inner scope.
    #[test]
    fn with_thread_local_rng_drop_restores_previous_override() {
        // What the outer seed would draw at step 1 with NO inner
        // disturbance.
        let outer_step1 = with_thread_local_rng(MockRng::new(1), mint_v4_default);

        // Same outer seed, but with an inner override that drains
        // five draws (different seed). If the Drop on Guard restores
        // the outer override, the next draw after the inner scope
        // must equal `outer_step1` (the FIRST draw on a fresh seed=1
        // override). If Drop is a no-op (mutation), the next draw
        // comes from seed=99 instead.
        let post_inner = with_thread_local_rng(MockRng::new(1), || {
            with_thread_local_rng(MockRng::new(99), || {
                for _ in 0..5 {
                    let _ = mint_v4_default();
                }
            });
            mint_v4_default()
        });

        assert_eq!(
            outer_step1, post_inner,
            "Drop on Guard MUST restore the outer override; \
             otherwise the inner override leaks across the scope boundary"
        );
    }
}