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