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//! Two value-sets, both owned by the `scaccounts.ppnums` table and both read by
9//! *more than one* organ, so neither can live in either organ:
10//!
11//! | Type | Column | `CHECK` |
12//! |---|---|---|
13//! | [`EntityType`] | `entity_type` | `ck_ppnums_entity_type_enum` |
14//! | [`LifecycleState`] | `lifecycle_state` (+ 2 audit columns) | `ck_ppnums_lifecycle_state_enum`, `ck_ppnum_lifecycle_events_{from,to}_state_enum` |
15//!
16//! They are here for one reason, applied twice: **a value-set consumed across
17//! the organ boundary collapses into one vocabulary** rather than being mirrored
18//! and then bound. `EntityType` arrived by `RFC_202607252223`, `LifecycleState`
19//! by `RFC_202607251658` P1 — and the second move is also what finally makes the
20//! *cross-fact* between them stateable exactly once
21//! ([`LifecycleState::can_transition_for_entity`], the
22//! `ck_ppnums_expired_only_mask` rule).
23//!
24//! ## `EntityType` — the one entity vocabulary
25//!
26//! **What kind of entity a ppnum is.** One type, one name, one value-set,
27//! shared by the token engine and both services. Before this crate the same
28//! fact was reified four times — `accounts_core::EntityType` (6 variants),
29//! `chat_core::port::EntityClass` (5, a hand-maintained mirror),
30//! `ppoppo_token::EntityType` (3), and the `ppnum.EntityType` proto enum —
31//! and two of those disagreed about whether `delegated` was a member.
32//!
33//! ### The axis this crate is NOT
34//!
35//! `EntityType` answers *what the principal is*. It does **not** answer *who
36//! is currently acting for it* — that is the RFC 8693 §4.1 `act` claim, and
37//! keeping the two apart is the entire point.
38//!
39//! **There is deliberately no `Delegated` variant.** A human identity driven
40//! by an agent is `Human` **plus** `act` — two facts, two fields. Compressing
41//! them into one string field is the mistake
42//! [`STANDARDS_AUTH_PPOPPO`] §4.2.1 already rejected for the retired `role`
43//! claim; that rule was never applied to its two siblings
44//! (`EntityType::Delegated`, `SenderBadge::Delegated`), and this crate is
45//! where it finally is. Recovering "is this delegated?" from a *single* value
46//! is impossible by construction here.
47//!
48//! ### Why an engine-tier crate with zero dependencies
49//!
50//! The type must be reachable from three places at once, and the crate
51//! lattice leaves exactly one option:
52//!
53//! - `ppoppo-token` needs it for the `entity_type` claim — and `engine →
54//!   shared` is **forbidden** (`xtask::policy::rules::taxonomy`), so a
55//!   `crates/shared/*` home is illegal. Engine tier it is.
56//! - `chat-core` needs it, and bans IO/transport crates (Constitution
57//!   Principle I). Pure `std` is the only shape it can accept — the same
58//!   stance that already lets it depend on `ppoppo-clock`.
59//!
60//! Sharing one type also **removes a value-set mirror**: `EntityClass`
61//! existed only to restate `scaccounts.ppnums.entity_type` inside PCS, and
62//! [`DIRECTION_COUPLING_PASPCS`] §4 **K5** records that mirror as a gap
63//! (PCS's drift test scopes `nspname='scchat'` and structurally cannot see
64//! `scaccounts`). With one type there is nothing left to drift.
65//!
66//! ### Table, not scattered predicates
67//!
68//! [`TABLE`] is the SSOT of per-variant *attributes*. Each fact previously
69//! lived somewhere else — the wire string in an `as_str` match,
70//! credential-eligibility in a PAS use-case, the AI-disclosure obligation in
71//! a doc comment. The enum is retained because exhaustive `match` is
72//! load-bearing: a sixth variant must fail to compile rather than default
73//! into a claim.
74//!
75//! Attributes that belong to **one** owner stay with that owner and are
76//! deliberately absent here — notably `number_class` (people/infra/
77//! ephemeral), a `GENERATED` column in `scaccounts` that PCS never reads.
78//! A shared table is not a dumping ground.
79//!
80//! ## `LifecycleState` — the one lifecycle vocabulary
81//!
82//! **What state a ppnum is in**, plus the legal transitions between states.
83//! Arrived by `RFC_202607251658` P1 for two reasons, and it carries the
84//! *transition lattice* as well as the value-set because of the second:
85//!
86//! - **PCS reads the value-set.** `chat-core` classifies
87//!   `scaccounts.ppnums.lifecycle_state` on its liveness path, and did so
88//!   through a hand-maintained 8-variant copy. Same unguardable shape as
89//!   `EntityClass` above — PCS's drift test is schema-scoped to `scchat` and
90//!   structurally cannot see a `scaccounts` `CHECK`
91//!   ([`DIRECTION_COUPLING_PASPCS`] §4 **K5** / §6 **I7**).
92//! - **[`can_transition_for_entity`](LifecycleState::can_transition_for_entity)
93//!   is a cross-fact.** It layers `ck_ppnums_expired_only_mask` — only
94//!   [`EntityType::Mask`] may reach [`LifecycleState::Expired`] — which is a
95//!   statement about *both* value-sets. Rust's orphan rule means whichever crate
96//!   does not own the type cannot say it as an inherent method, so leaving the
97//!   state machine in `accounts-core` would have required an extension trait:
98//!   the invariant expressible in two places again.
99//!
100//! This is not the dumping ground the paragraph above rules out. PAS keeps what
101//! only PAS reads — the column, the business triggers that drive transitions,
102//! and the audit trail. What moved is the fact neither organ could own alone.
103
104#![deny(rust_2018_idioms)]
105#![warn(missing_debug_implementations)]
106
107use core::fmt;
108
109mod lifecycle;
110
111pub use lifecycle::LifecycleState;
112
113/// What kind of entity a ppnum is.
114///
115/// Members are exactly the storable values of
116/// `scaccounts.ppnums.entity_type` (`ck_ppnums_entity_type_enum`) — bound to
117/// that constraint by the enrollment directly below, so the constraint name
118/// lives *on the fact*.
119///
120/// There is **no `non_stored` list**, and that is the point: every member of
121/// this vocabulary is a real, storable entity kind. The render-only
122/// `Delegated` that the retired PAS enum had to declare as an exception is not
123/// an exception here — it is simply not an entity type. See the module docs.
124///
125/// The `serde` impls are **feature-gated and off by default** (PCS caches
126/// `PpnumAccount` as JSON in KVRocks and needs them; nothing else does). The
127/// wire form is `rename_all = "snake_case"` — byte-identical to
128/// [`as_str`](Self::as_str), and not by coincidence: `snake_case` of each
129/// variant *is* the canonical string, so the derive cannot drift from
130/// [`TABLE`]. If a variant ever needs a form `snake_case` does not produce,
131/// delete the derive and write a manual impl that delegates to `as_str` — a
132/// second spelling of the wire strings is precisely the mirror this crate
133/// exists to remove.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
135#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
136#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
137pub enum EntityType {
138    /// Natural person. Also the class of a token whose `sub` is a human
139    /// being driven by an agent (ppnum lending) — the agent is `act`, not a
140    /// different entity type.
141    Human,
142    /// Business or organization.
143    Enterprise,
144    /// LLM-backed agent holding its own ppnum.
145    AiAgent,
146    /// Developer-programmable number (template / auto-reply). Infra class.
147    Programmable,
148    /// Ephemeral privacy-mediation proxy number with a TTL. Industry
149    /// analogue: Twilio Masking Numbers / 안심번호.
150    Mask,
151}
152
153// SSOT binding: the vocabulary must equal `ck_ppnums_entity_type_enum`.
154//
155// The enrollment lives here, next to the enum, rather than in PAS — which is
156// what `RFC_202607252223` T-03's tier move bought. While it sat in
157// `accounts-core` the DB binding could not follow the type up the lattice, so
158// a second PAS-local enum had to stay alive purely to hold it, and every
159// textual analysis of this vocabulary was ambiguous between the two.
160//
161// PAS still owns the *column* (and `number_class`, which is a `GENERATED`
162// column PCS never reads); this crate owns the *value-set*. Verification is
163// unchanged — `accounts-api/tests/schema_check_drift.rs` reads the
164// materialized `CHECK` via `pg_get_constraintdef` and asserts set-equality.
165ppoppo_schema_constrained::impl_schema_constrained!(EntityType via as_str {
166    all: [Human, Enterprise, AiAgent, Programmable, Mask],
167    constraints: ["ck_ppnums_entity_type_enum"],
168});
169
170/// One row of [`TABLE`] — every attribute of one entity type.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub struct EntityFacts {
173    /// The variant this row describes. Present so the row is self-describing
174    /// and the table↔enum wiring is checkable at compile time.
175    pub entity: EntityType,
176    /// Canonical wire/DB string. The value in `ppnums.entity_type`, in the
177    /// `entity_type` JWT claim, and on every wire that names an entity type.
178    pub wire: &'static str,
179    /// May this entity hold a minted credential (OAuth `client_credentials`)?
180    ///
181    /// **This is a policy, not an identity fact** — which is why it lives in
182    /// a table column rather than in the type. `false` means *no
183    /// credential-issuing flow exists today*, not *never will*: the External
184    /// Developer / enterprise lane is designed and unbuilt
185    /// ([`STANDARDS_AUTH_PPOPPO`] §6.4).
186    ///
187    /// Load-bearing in two places that must agree — the mint refuses a
188    /// principal with `false`, and the engine's M40 gate refuses such a
189    /// value on the wire. One declaration, two enforcement points, which is
190    /// what lets the K8 reachability guard assert an *equality* rather than
191    /// an inclusion.
192    pub can_hold_credential: bool,
193    /// Does a message from this entity carry a mandatory AI-disclosure
194    /// obligation? (EU AI Act Art.50 / KR AI기본법.)
195    ///
196    /// Note this is keyed on the *entity*, so it does **not** cover a human
197    /// ppnum driven by an agent — that case is `Human` + `act`, and the
198    /// obligation there follows from the delegation, not from this column.
199    pub requires_ai_disclosure: bool,
200}
201
202/// **The lookup table — SSOT of every per-variant attribute.**
203///
204/// Ordered identically to [`EntityType::ALL`]; the const gate below proves
205/// it, so the two can never be read out of step.
206pub const TABLE: [EntityFacts; 5] = [
207    EntityFacts {
208        entity: EntityType::Human,
209        wire: "human",
210        can_hold_credential: true,
211        requires_ai_disclosure: false,
212    },
213    EntityFacts {
214        entity: EntityType::Enterprise,
215        wire: "enterprise",
216        // No credential-issuing flow. AUTH §6.4 records the lane as
217        // designed-but-unbuilt; opening it flips this cell, and the K8
218        // guard fails until the mint path moves with it.
219        can_hold_credential: false,
220        requires_ai_disclosure: false,
221    },
222    EntityFacts {
223        entity: EntityType::AiAgent,
224        wire: "ai_agent",
225        can_hold_credential: true,
226        requires_ai_disclosure: true,
227    },
228    EntityFacts {
229        entity: EntityType::Programmable,
230        wire: "programmable",
231        can_hold_credential: true,
232        // Infra class — a template/auto-reply number is not an AI system.
233        requires_ai_disclosure: false,
234    },
235    EntityFacts {
236        entity: EntityType::Mask,
237        wire: "mask",
238        // A mask is a presentation alias for someone else; it does not
239        // authenticate as itself.
240        can_hold_credential: false,
241        requires_ai_disclosure: false,
242    },
243];
244
245impl EntityType {
246    /// Every variant. Ordered as [`TABLE`].
247    pub const ALL: [EntityType; 5] = [
248        Self::Human,
249        Self::Enterprise,
250        Self::AiAgent,
251        Self::Programmable,
252        Self::Mask,
253    ];
254
255    /// Stable position in [`ALL`](Self::ALL) / [`TABLE`].
256    ///
257    /// The exhaustive `match` is the compile-time gate: a sixth variant
258    /// fails to build here, so it cannot reach a claim by defaulting.
259    #[must_use]
260    pub const fn index(self) -> usize {
261        match self {
262            Self::Human => 0,
263            Self::Enterprise => 1,
264            Self::AiAgent => 2,
265            Self::Programmable => 3,
266            Self::Mask => 4,
267        }
268    }
269
270    /// This variant's row. Every attribute accessor below reads through it,
271    /// so [`TABLE`] is the only place an attribute is stated.
272    #[must_use]
273    pub const fn facts(self) -> &'static EntityFacts {
274        &TABLE[self.index()]
275    }
276
277    /// Canonical wire/DB string.
278    #[must_use]
279    pub const fn as_str(self) -> &'static str {
280        self.facts().wire
281    }
282
283    /// May this entity hold a minted credential? See
284    /// [`EntityFacts::can_hold_credential`].
285    #[must_use]
286    pub const fn can_hold_credential(self) -> bool {
287        self.facts().can_hold_credential
288    }
289
290    /// Does this entity carry an AI-disclosure obligation? See
291    /// [`EntityFacts::requires_ai_disclosure`].
292    #[must_use]
293    pub const fn requires_ai_disclosure(self) -> bool {
294        self.facts().requires_ai_disclosure
295    }
296
297    /// Parse a canonical wire string. Exact inverse of
298    /// [`as_str`](Self::as_str).
299    ///
300    /// `None` for anything outside the vocabulary — notably `"delegated"`,
301    /// which is a *session mode* and never an entity type. Callers on a
302    /// verify path MUST treat `None` as a forgery signal, not as an
303    /// unknown-but-tolerable value.
304    #[must_use]
305    pub fn parse(s: &str) -> Option<Self> {
306        Self::ALL.into_iter().find(|e| e.as_str() == s)
307    }
308}
309
310impl fmt::Display for EntityType {
311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312        f.write_str(self.as_str())
313    }
314}
315
316// ── Compile-time gates ──────────────────────────────────────────────────
317//
318// `TABLE` and `ALL` are two orderings of one fact; nothing at runtime would
319// notice them diverging, so it is settled at build time.
320const _: () = {
321    assert!(TABLE.len() == EntityType::ALL.len());
322
323    // Row i describes variant i — a mis-ordered table cannot compile.
324    let mut i = 0;
325    while i < EntityType::ALL.len() {
326        assert!(TABLE[i].entity.index() == EntityType::ALL[i].index());
327        i += 1;
328    }
329
330    // `facts()` resolves each variant to its OWN row (a copy-paste slip in
331    // the `index()` match would otherwise hand back a neighbour's
332    // attributes — including `can_hold_credential`, a security gate).
333    let mut i = 0;
334    while i < EntityType::ALL.len() {
335        assert!(EntityType::ALL[i].facts().entity.index() == EntityType::ALL[i].index());
336        i += 1;
337    }
338};
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn parse_is_the_inverse_of_as_str() {
346        for e in EntityType::ALL {
347            assert_eq!(EntityType::parse(e.as_str()), Some(e));
348        }
349    }
350
351    /// The value this crate exists to make unspellable. `"delegated"` is a
352    /// session mode carried by `act`; it was never an entity type and must
353    /// not become one again.
354    #[test]
355    fn delegated_is_not_an_entity_type() {
356        assert_eq!(EntityType::parse("delegated"), None);
357    }
358
359    #[test]
360    fn unknown_strings_do_not_parse() {
361        for s in ["", "HUMAN", "human ", "user", "bot", "service", "virtual"] {
362            assert_eq!(EntityType::parse(s), None, "{s:?} must not parse");
363        }
364    }
365
366    /// Pins the credential-eligible set. This is the M40 admitted set and a
367    /// security boundary — widening it is a deliberate act, so it should
368    /// require editing an assertion that says so.
369    #[test]
370    fn credential_eligible_set_is_pinned() {
371        let eligible: Vec<_> = EntityType::ALL
372            .into_iter()
373            .filter(|e| e.can_hold_credential())
374            .collect();
375        assert_eq!(
376            eligible,
377            vec![
378                EntityType::Human,
379                EntityType::AiAgent,
380                EntityType::Programmable
381            ],
382            "the credential-eligible set changed — this is the M40 admitted \
383             set (a forgery gate). Widening it must move the mint path in the \
384             same change, or the K8 reachability guard will fail."
385        );
386    }
387
388    /// **The `serde` derive must encode exactly `as_str`.**
389    ///
390    /// Load-bearing beyond tidiness: PCS caches `PpnumAccount` as JSON in
391    /// KVRocks and `get_typed` propagates a deserialize failure rather than
392    /// treating it as a miss, so an encoding change would turn every cached
393    /// account into a hard error until the TTL expired. The retired
394    /// `chat_core::port::EntityClass` encoded via `rename_all = "snake_case"`
395    /// over the same five variants; this pins that the replacement is
396    /// byte-identical, so live cache entries written before the collapse still
397    /// read back.
398    ///
399    /// It also pins the claim in the type's doc comment that `snake_case`
400    /// *is* the canonical string — asserted over `ALL`, so a variant whose
401    /// `snake_case` diverges from its `TABLE` row fails here instead of
402    /// silently minting a second wire spelling.
403    #[cfg(feature = "serde")]
404    #[test]
405    fn serde_encoding_is_exactly_as_str() {
406        for e in EntityType::ALL {
407            let json = serde_json::to_string(&e).expect("serialize");
408            assert_eq!(
409                json,
410                format!("\"{}\"", e.as_str()),
411                "the serde derive drifted from `as_str` for {e:?} — this breaks \
412                 every JSON-cached PpnumAccount and mints a second spelling of \
413                 the wire strings"
414            );
415            assert_eq!(
416                serde_json::from_str::<EntityType>(&json).expect("deserialize"),
417                e,
418            );
419        }
420    }
421
422    /// AI disclosure is an entity property; delegation is not covered here
423    /// (a human driven by an agent is `Human` + `act`).
424    #[test]
425    fn ai_disclosure_is_agent_only() {
426        for e in EntityType::ALL {
427            assert_eq!(e.requires_ai_disclosure(), e == EntityType::AiAgent);
428        }
429    }
430}