Skip to main content

ppoppo_identity/
lib.rs

1//! **NOT a stable public API.** Engine-tier identity vocabulary — published
2//! to crates.io only because the SDK closure requires it on the registry;
3//! 3rd parties never name this crate. They meet these types through an SDK
4//! product facade or a wire contract, never here.
5//!
6//! # Principal identity — the `scaccounts.ppnums` vocabularies
7//!
8//! The identifier pair, three value-sets, and one predicate over them — all
9//! owned by the `scaccounts.ppnums` table (or, for [`Scope`], by the OAuth
10//! catalog PAS mints from it) and all read by *more than one* organ, so none
11//! of them can live in either organ. The rows first:
12//!
13//! | Type | Column / fact | Binding |
14//! |---|---|---|
15//! | [`Ppnum`] | `ppnum` | `ck_ppnums_format` — named on [`Ppnum::CONSTRAINT`] |
16//! | [`PpnumId`] | `id` | the 26-char ULID PK / FK / `sub` |
17//! | [`EntityType`] | `entity_type` | `ck_ppnums_entity_type_enum` |
18//! | [`LifecycleState`] | `lifecycle_state` (+ 2 audit columns) | `ck_ppnums_lifecycle_state_enum`, `ck_ppnum_lifecycle_events_{from,to}_state_enum` |
19//! | [`Scope`] | the `scopes` claim / `scopes_supported` | [`SCOPE_TABLE`] + const gates in each organ |
20//!
21//! They are here for one reason, applied five times: **a value consumed
22//! across the organ boundary collapses into one vocabulary** rather than
23//! being mirrored and then bound. `EntityType` arrived by `RFC_202607252223`,
24//! `LifecycleState` by `RFC_202607251658` P1 — and the second move is also
25//! what finally makes the *cross-fact* between them stateable exactly once
26//! ([`LifecycleState::can_transition_for_entity`], the
27//! `ck_ppnums_expired_only_mask` rule). `Ppnum`, `PpnumId` and `Scope`
28//! followed on 2026-08-29 (the ADR named in this crate's CHANGELOG), each
29//! for the same reason the first two moved: the rule already had several
30//! homes and at least two of them disagreed.
31//!
32//! ## `Ppnum` / `PpnumId` — the identifier pair
33//!
34//! **The number, and the id that names its row.** Consumers store the two
35//! *paired*; both are immutable for a human. Before this crate the format rule
36//! behind [`Ppnum`] was restated six times across the SDKs, PCS and PAS — and
37//! PCS's two copies said `== 11` while the column, PAS and the SDKs said
38//! `≥ 11`, so every 15-digit dependent-agent ppnum PAS minted was un-addable
39//! as a PCS contact. The rule is stated once here ([`Ppnum::MIN_LEN`], the
40//! `ck_ppnums_format` regex), the two renderings that every organ needs
41//! (wire digits, hyphen-grouped display) are stated once, and the ladder PAS
42//! mints on (`11 + 4·depth`) is deliberately **not** here — it is issuance
43//! policy PCS never reads.
44//!
45//! [`PpnumId`] was the same ULID under three names (`PpnumAccountId` in PAS,
46//! `PpnumId` in PCS and the SDK). A value that crosses the organ boundary
47//! under one name in the database has one name in Rust.
48//!
49//! ## `Scope` — the one OAuth scope vocabulary
50//!
51//! **Every scope PAS mints**, with the two attributes both organs decide on:
52//! its [`ScopeTier`] (reach) and its [`ScopeEnforcer`] (which organ's
53//! perimeter matches it). Before this crate the catalog was a PAS string list,
54//! a PAS `plims:*` const module, a 15-member PCS enum with three parallel
55//! lists, and bare string atoms in the SDK's tiers — held together by three
56//! dev-dependency tests that compiled the other organ to compare strings. Now
57//! a scope the SDK requests *is* a scope PAS mints, by type, and the K8
58//! inclusions (`DIRECTION_COUPLING_PASPCS` §6 I10) are const gates in
59//! `chat-core`: every `Pcs`-enforced scope backs a gate, every gate is keyed
60//! on a `Pcs`-enforced scope, and a mislabel fails to compile.
61//!
62//! What stays in PAS is what only PAS reads: `registerable` (the `/oauth/apps`
63//! form's allow-list) and the consent-screen glyph, in a PAS policy table
64//! keyed by [`Scope`] and const-gated to [`Scope::ALL`]'s order.
65//!
66//! [`ScopeSet`] (with its two halves, [`ConsentScopes`] and [`FixedScopes`])
67//! is the contract every phantom scope marker implements — the OIDC markers
68//! and each SDK's tiers alike — so a wire scope line is always derived from
69//! these atoms. It lives here because this is the one crate every marker
70//! family can reach.
71//!
72//! ## `EntityType` — the one entity vocabulary
73//!
74//! **What kind of entity a ppnum is.** One type, one name, one value-set,
75//! shared by the token engine and both services. Before this crate the same
76//! fact was reified four times — `accounts_core::EntityType` (6 variants),
77//! `chat_core::port::EntityClass` (5, a hand-maintained mirror),
78//! `ppoppo_token::EntityType` (3), and the `ppnum.EntityType` proto enum —
79//! and two of those disagreed about whether `delegated` was a member.
80//!
81//! ### The axis this crate is NOT
82//!
83//! `EntityType` answers *what the principal is*. It does **not** answer *who
84//! is currently acting for it* — that is the RFC 8693 §4.1 `act` claim, and
85//! keeping the two apart is the entire point.
86//!
87//! **There is deliberately no `Delegated` variant.** A human identity driven
88//! by an agent is `Human` **plus** `act` — two facts, two fields. Compressing
89//! them into one string field is the mistake
90//! `STS_AUTH_PPOPPO` §4.2.1 already rejected for the retired `role`
91//! claim; that rule was never applied to its two siblings
92//! (`EntityType::Delegated`, `SenderBadge::Delegated`), and this crate is
93//! where it finally is. Recovering "is this delegated?" from a *single* value
94//! is impossible by construction here.
95//!
96//! ### Why an engine-tier crate with no IO or transport dependency
97//!
98//! The type must be reachable from three places at once, and the crate
99//! lattice leaves exactly one option:
100//!
101//! - `ppoppo-token` needs it for the `entity_type` claim — and `engine →
102//!   shared` is **forbidden** (`xtask::policy::rules::taxonomy`), so a
103//!   `crates/shared/*` home is illegal. Engine tier it is.
104//! - `chat-core` needs it, and bans IO/transport crates (Constitution
105//!   Principle I). `std` plus the ULID codec both cores already carry is the
106//!   only shape it can accept — the same stance that already lets it depend
107//!   on `ppoppo-clock`.
108//!
109//! Sharing one type also **removes a value-set mirror**: `EntityClass`
110//! existed only to restate `scaccounts.ppnums.entity_type` inside PCS, and
111//! `DIRECTION_COUPLING_PASPCS` §4 **K5** records that mirror as a gap
112//! (PCS's drift test scopes `nspname='scchat'` and structurally cannot see
113//! `scaccounts`). With one type there is nothing left to drift.
114//!
115//! ### Table, not scattered predicates
116//!
117//! [`TABLE`] is the SSOT of per-variant *attributes*. Each fact previously
118//! lived somewhere else — the wire string in an `as_str` match,
119//! credential-eligibility in a PAS use-case, the AI-disclosure obligation in
120//! a doc comment. The enum is retained because exhaustive `match` is
121//! load-bearing: a sixth variant must fail to compile rather than default
122//! into a claim.
123//!
124//! Attributes that belong to **one** owner stay with that owner and are
125//! deliberately absent here — notably `number_class` (people/infra/
126//! ephemeral), a `GENERATED` column in `scaccounts` that PCS never reads.
127//! A shared table is not a dumping ground.
128//!
129//! ## `LifecycleState` — the one lifecycle vocabulary
130//!
131//! **What state a ppnum is in**, plus the legal transitions between states.
132//! Arrived by `RFC_202607251658` P1 for two reasons, and it carries the
133//! *transition lattice* as well as the value-set because of the second:
134//!
135//! - **PCS reads the value-set.** `chat-core` classifies
136//!   `scaccounts.ppnums.lifecycle_state` on its liveness path, and did so
137//!   through a hand-maintained 8-variant copy. Same unguardable shape as
138//!   `EntityClass` above — PCS's drift test is schema-scoped to `scchat` and
139//!   structurally cannot see a `scaccounts` `CHECK`
140//!   (`DIRECTION_COUPLING_PASPCS` §4 **K5** / §6 **I7**).
141//! - **[`can_transition_for_entity`](LifecycleState::can_transition_for_entity)
142//!   is a cross-fact.** It layers `ck_ppnums_expired_only_mask` — only
143//!   [`EntityType::Mask`] may reach [`LifecycleState::Expired`] — which is a
144//!   statement about *both* value-sets. Rust's orphan rule means whichever crate
145//!   does not own the type cannot say it as an inherent method, so leaving the
146//!   state machine in `accounts-core` would have required an extension trait:
147//!   the invariant expressible in two places again.
148//!
149//! This is not the dumping ground the paragraph above rules out. PAS keeps what
150//! only PAS reads — the column, the business triggers that drive transitions,
151//! and the audit trail. What moved is the fact neither organ could own alone.
152//!
153//! ## `effective_admin` — the one admin predicate
154//!
155//! **Not a value-set: a decision.** [`effective_admin`] answers "is this
156//! principal an effective admin" from three facts — the `is_admin` grant, the
157//! [`LifecycleState`] it is (or is not) in effect under, and how many active
158//! passkeys the principal holds. It arrived by `RFC_202608241353` T-01, and it
159//! arrived because the two organs had **stopped agreeing**:
160//!
161//! | Premise | PAS `/admin` | PCS admin RPCs (before) |
162//! |---|---|---|
163//! | `is_admin = TRUE` | checked | checked |
164//! | `lifecycle_state = 'active'` | checked | **not checked** |
165//! | at least one active passkey | checked, bounded | **not checked** |
166//!
167//! A deactivated account whose grant was never revoked was refused by one organ
168//! and admitted by the other. Roles persisting across lifecycle state is
169//! deliberate (`GUIDE_ADMIN_PAS` §4.8), which is exactly what made the missing
170//! term reachable rather than theoretical.
171//!
172//! The collapse is the same one the two value-sets above took, applied to a
173//! predicate: **one declaration, two callers.** Each organ's adapter gathers the
174//! premises from its own tier — PAS from `accounts-database`, PCS from its
175//! cross-schema read into `scaccounts` — and both hand them to the same
176//! function. Duplicating the conjunction in `chat-core` was the fast path and is
177//! rejected in the RFC's §8: a second declaration of a security predicate is the
178//! shape **K5** exists to forbid.
179//!
180//! Two things travel with the predicate rather than with its callers, because a
181//! caller-local copy of either would let the organs diverge again while both
182//! "used the shared function": the Layer-2 budget
183//! ([`ADMIN_PASSKEY_CHECK_TIMEOUT`]) and the *unknown* premise
184//! ([`ActivePasskeys::Unknown`]) that a blown budget produces. Protocol mapping
185//! — which status code, which page, which gRPC metadata — stays at each
186//! caller's edge, exactly as with everything else here.
187
188#![deny(rust_2018_idioms)]
189#![warn(missing_debug_implementations)]
190
191use core::fmt;
192
193mod admin;
194mod lifecycle;
195mod ppnum;
196mod scope;
197mod scope_set;
198
199pub use admin::{
200    ACCESS_RECORD_RETENTION_DAYS, ACCESS_RECORD_RETENTION_FLOOR_DAYS, ADMIN_PASSKEY_CHECK_TIMEOUT,
201    ADMIN_PREMISE_COLUMNS, ActivePasskeys, AdminFacts, AdminLayer1, AdminPremiseColumn,
202    AdminVerdict, effective_admin,
203};
204pub use lifecycle::LifecycleState;
205pub use ppnum::{Ppnum, PpnumError, PpnumId};
206pub use scope::{SCOPE_TABLE, Scope, ScopeEnforcer, ScopeFacts, ScopeTier};
207pub use scope_set::{ConsentScopes, FixedScopes, ScopeSet};
208
209/// What kind of entity a ppnum is.
210///
211/// Members are exactly the storable values of
212/// `scaccounts.ppnums.entity_type` (`ck_ppnums_entity_type_enum`) — bound to
213/// that constraint by the enrollment directly below, so the constraint name
214/// lives *on the fact*.
215///
216/// There is **no `non_stored` list**, and that is the point: every member of
217/// this vocabulary is a real, storable entity kind. The render-only
218/// `Delegated` that the retired PAS enum had to declare as an exception is not
219/// an exception here — it is simply not an entity type. See the module docs.
220///
221/// The `serde` impls are **feature-gated and off by default** (PCS caches
222/// `PpnumAccount` as JSON in KVRocks and needs them; the only other consumer is
223/// PAS's [`AdminLayer1`] cache value, which carries a [`LifecycleState`]). The
224/// wire form is `rename_all = "snake_case"` — byte-identical to
225/// [`as_str`](Self::as_str), and not by coincidence: `snake_case` of each
226/// variant *is* the canonical string, so the derive cannot drift from
227/// [`TABLE`]. If a variant ever needs a form `snake_case` does not produce,
228/// delete the derive and write a manual impl that delegates to `as_str` — a
229/// second spelling of the wire strings is precisely the mirror this crate
230/// exists to remove.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
232#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
233#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
234pub enum EntityType {
235    /// Natural person.
236    Human,
237    /// Business or organization.
238    Enterprise,
239    /// LLM-backed agent holding its own ppnum.
240    AiAgent,
241    /// Developer-programmable number (template / auto-reply). Infra class.
242    Programmable,
243    /// Ephemeral privacy-mediation proxy number with a TTL. Industry
244    /// analogue: Twilio Masking Numbers / 안심번호.
245    Mask,
246}
247
248// SSOT binding: the vocabulary must equal `ck_ppnums_entity_type_enum`.
249//
250// The enrollment lives here, next to the enum, rather than in PAS — which is
251// what `RFC_202607252223` T-03's tier move bought. While it sat in
252// `accounts-core` the DB binding could not follow the type up the lattice, so
253// a second PAS-local enum had to stay alive purely to hold it, and every
254// textual analysis of this vocabulary was ambiguous between the two.
255//
256// PAS still owns the *column* (and `number_class`, which is a `GENERATED`
257// column PCS never reads); this crate owns the *value-set*. Verification is
258// unchanged — `accounts-api/tests/schema_check_drift.rs` reads the
259// materialized `CHECK` via `pg_get_constraintdef` and asserts set-equality.
260ppoppo_schema_constrained::impl_schema_constrained!(EntityType via as_str {
261    all: [Human, Enterprise, AiAgent, Programmable, Mask],
262    constraints: ["ck_ppnums_entity_type_enum"],
263});
264
265/// One row of [`TABLE`] — every attribute of one entity type.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub struct EntityFacts {
268    /// The variant this row describes. Present so the row is self-describing
269    /// and the table↔enum wiring is checkable at compile time.
270    pub entity: EntityType,
271    /// Canonical wire/DB string. The value in `ppnums.entity_type`, in the
272    /// `entity_type` JWT claim, and on every wire that names an entity type.
273    pub wire: &'static str,
274    /// May this entity hold a minted credential (OAuth `client_credentials`)?
275    ///
276    /// **This is a policy, not an identity fact** — which is why it lives in
277    /// a table column rather than in the type. `false` means *no
278    /// credential-issuing flow exists today*, not *never will*: the External
279    /// Developer / enterprise lane is designed and unbuilt
280    /// (`STS_AUTH_PPOPPO` §6.4).
281    ///
282    /// Load-bearing in two places that must agree — the mint refuses a
283    /// principal with `false`, and the engine's M40 gate refuses such a
284    /// value on the wire. One declaration, two enforcement points, which is
285    /// what lets the K8 reachability guard assert an *equality* rather than
286    /// an inclusion.
287    pub can_hold_credential: bool,
288    /// Does a message from this entity carry a mandatory AI-disclosure
289    /// obligation? (EU AI Act Art.50 / KR AI기본법.)
290    ///
291    /// Keyed on the *entity*, and that is the whole obligation: an agent
292    /// speaks as itself, under its own `AiAgent` ppnum. There is no human
293    /// ppnum driven by an agent to disclose separately — ppoppo refuses
294    /// identity lending (`ADR_202609271736`).
295    pub requires_ai_disclosure: bool,
296}
297
298/// **The lookup table — SSOT of every per-variant attribute.**
299///
300/// Ordered identically to [`EntityType::ALL`]; the const gate below proves
301/// it, so the two can never be read out of step.
302pub const TABLE: [EntityFacts; 5] = [
303    EntityFacts {
304        entity: EntityType::Human,
305        wire: "human",
306        can_hold_credential: true,
307        requires_ai_disclosure: false,
308    },
309    EntityFacts {
310        entity: EntityType::Enterprise,
311        wire: "enterprise",
312        // No credential-issuing flow. AUTH §6.4 records the lane as
313        // designed-but-unbuilt; opening it flips this cell, and the K8
314        // guard fails until the mint path moves with it.
315        can_hold_credential: false,
316        requires_ai_disclosure: false,
317    },
318    EntityFacts {
319        entity: EntityType::AiAgent,
320        wire: "ai_agent",
321        can_hold_credential: true,
322        requires_ai_disclosure: true,
323    },
324    EntityFacts {
325        entity: EntityType::Programmable,
326        wire: "programmable",
327        can_hold_credential: true,
328        // Infra class — a template/auto-reply number is not an AI system.
329        requires_ai_disclosure: false,
330    },
331    EntityFacts {
332        entity: EntityType::Mask,
333        wire: "mask",
334        // A mask is a presentation alias for someone else; it does not
335        // authenticate as itself.
336        can_hold_credential: false,
337        requires_ai_disclosure: false,
338    },
339];
340
341impl EntityType {
342    /// Every variant. Ordered as [`TABLE`].
343    pub const ALL: [EntityType; 5] = [
344        Self::Human,
345        Self::Enterprise,
346        Self::AiAgent,
347        Self::Programmable,
348        Self::Mask,
349    ];
350
351    /// Stable position in [`ALL`](Self::ALL) / [`TABLE`].
352    ///
353    /// The exhaustive `match` is the compile-time gate: a sixth variant
354    /// fails to build here, so it cannot reach a claim by defaulting.
355    #[must_use]
356    pub const fn index(self) -> usize {
357        match self {
358            Self::Human => 0,
359            Self::Enterprise => 1,
360            Self::AiAgent => 2,
361            Self::Programmable => 3,
362            Self::Mask => 4,
363        }
364    }
365
366    /// This variant's row. Every attribute accessor below reads through it,
367    /// so [`TABLE`] is the only place an attribute is stated.
368    #[must_use]
369    pub const fn facts(self) -> &'static EntityFacts {
370        &TABLE[self.index()]
371    }
372
373    /// Canonical wire/DB string.
374    #[must_use]
375    pub const fn as_str(self) -> &'static str {
376        self.facts().wire
377    }
378
379    /// May this entity hold a minted credential? See
380    /// [`EntityFacts::can_hold_credential`].
381    #[must_use]
382    pub const fn can_hold_credential(self) -> bool {
383        self.facts().can_hold_credential
384    }
385
386    /// Does this entity carry an AI-disclosure obligation? See
387    /// [`EntityFacts::requires_ai_disclosure`].
388    #[must_use]
389    pub const fn requires_ai_disclosure(self) -> bool {
390        self.facts().requires_ai_disclosure
391    }
392
393    /// Parse a canonical wire string. Exact inverse of
394    /// [`as_str`](Self::as_str).
395    ///
396    /// `None` for anything outside the vocabulary — notably `"delegated"`,
397    /// which is a *session mode* and never an entity type. Callers on a
398    /// verify path MUST treat `None` as a forgery signal, not as an
399    /// unknown-but-tolerable value.
400    #[must_use]
401    pub fn parse(s: &str) -> Option<Self> {
402        Self::ALL.into_iter().find(|e| e.as_str() == s)
403    }
404}
405
406impl fmt::Display for EntityType {
407    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408        f.write_str(self.as_str())
409    }
410}
411
412// ── Compile-time gates ──────────────────────────────────────────────────
413//
414// `TABLE` and `ALL` are two orderings of one fact; nothing at runtime would
415// notice them diverging, so it is settled at build time.
416const _: () = {
417    assert!(TABLE.len() == EntityType::ALL.len());
418
419    // Row i describes variant i — a mis-ordered table cannot compile.
420    let mut i = 0;
421    while i < EntityType::ALL.len() {
422        assert!(TABLE[i].entity.index() == EntityType::ALL[i].index());
423        i += 1;
424    }
425
426    // `facts()` resolves each variant to its OWN row (a copy-paste slip in
427    // the `index()` match would otherwise hand back a neighbour's
428    // attributes — including `can_hold_credential`, a security gate).
429    let mut i = 0;
430    while i < EntityType::ALL.len() {
431        assert!(EntityType::ALL[i].facts().entity.index() == EntityType::ALL[i].index());
432        i += 1;
433    }
434};
435
436#[cfg(test)]
437// Outer, not the usual inner `#![allow(..)]`: ARCH-BOUNDARY pins the exact
438// inner-attribute block of a published-tier `lib.rs` and scans every line,
439// so an indented inner attribute inside this module trips it too.
440#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn parse_is_the_inverse_of_as_str() {
446        for e in EntityType::ALL {
447            assert_eq!(EntityType::parse(e.as_str()), Some(e));
448        }
449    }
450
451    /// The value this crate exists to make unspellable. `"delegated"` is a
452    /// session mode carried by `act`; it was never an entity type and must
453    /// not become one again.
454    #[test]
455    fn delegated_is_not_an_entity_type() {
456        assert_eq!(EntityType::parse("delegated"), None);
457    }
458
459    #[test]
460    fn unknown_strings_do_not_parse() {
461        for s in ["", "HUMAN", "human ", "user", "bot", "service", "virtual"] {
462            assert_eq!(EntityType::parse(s), None, "{s:?} must not parse");
463        }
464    }
465
466    /// Pins the credential-eligible set. This is the M40 admitted set and a
467    /// security boundary — widening it is a deliberate act, so it should
468    /// require editing an assertion that says so.
469    #[test]
470    fn credential_eligible_set_is_pinned() {
471        let eligible: Vec<_> = EntityType::ALL
472            .into_iter()
473            .filter(|e| e.can_hold_credential())
474            .collect();
475        assert_eq!(
476            eligible,
477            vec![
478                EntityType::Human,
479                EntityType::AiAgent,
480                EntityType::Programmable
481            ],
482            "the credential-eligible set changed — this is the M40 admitted \
483             set (a forgery gate). Widening it must move the mint path in the \
484             same change, or the K8 reachability guard will fail."
485        );
486    }
487
488    /// **The `serde` derive must encode exactly `as_str`.**
489    ///
490    /// Load-bearing beyond tidiness: PCS caches `PpnumAccount` as JSON in
491    /// KVRocks and `get_typed` propagates a deserialize failure rather than
492    /// treating it as a miss, so an encoding change would turn every cached
493    /// account into a hard error until the TTL expired. The retired
494    /// `chat_core::port::EntityClass` encoded via `rename_all = "snake_case"`
495    /// over the same five variants; this pins that the replacement is
496    /// byte-identical, so live cache entries written before the collapse still
497    /// read back.
498    ///
499    /// It also pins the claim in the type's doc comment that `snake_case`
500    /// *is* the canonical string — asserted over `ALL`, so a variant whose
501    /// `snake_case` diverges from its `TABLE` row fails here instead of
502    /// silently minting a second wire spelling.
503    #[cfg(feature = "serde")]
504    #[test]
505    fn serde_encoding_is_exactly_as_str() {
506        for e in EntityType::ALL {
507            let json = serde_json::to_string(&e).expect("serialize");
508            assert_eq!(
509                json,
510                format!("\"{}\"", e.as_str()),
511                "the serde derive drifted from `as_str` for {e:?} — this breaks \
512                 every JSON-cached PpnumAccount and mints a second spelling of \
513                 the wire strings"
514            );
515            assert_eq!(
516                serde_json::from_str::<EntityType>(&json).expect("deserialize"),
517                e,
518            );
519        }
520    }
521
522    /// AI disclosure is an entity property.
523    #[test]
524    fn ai_disclosure_is_agent_only() {
525        for e in EntityType::ALL {
526            assert_eq!(e.requires_ai_disclosure(), e == EntityType::AiAgent);
527        }
528    }
529}