ppoppo-identity 0.31.0

Principal-identity vocabulary for the ppoppo ecosystem — the Ppnum/PpnumId pair, the EntityType, LifecycleState and OAuth Scope value-sets, and the admin predicate, shared by the token engine, both services and every SDK
Documentation

NOT a stable public API. Engine-tier identity vocabulary — published to crates.io only because the SDK closure requires it on the registry; 3rd parties never name this crate. They meet these types through an SDK product facade or a wire contract, never here.

Principal identity — the scaccounts.ppnums vocabularies

The identifier pair, three value-sets, and one predicate over them — all owned by the scaccounts.ppnums table (or, for [Scope], by the OAuth catalog PAS mints from it) and all read by more than one organ, so none of them can live in either organ. The rows first:

Type Column / fact Binding
[Ppnum] ppnum ck_ppnums_format — named on [Ppnum::CONSTRAINT]
[PpnumId] id the 26-char ULID PK / FK / sub
[EntityType] entity_type ck_ppnums_entity_type_enum
[LifecycleState] lifecycle_state (+ 2 audit columns) ck_ppnums_lifecycle_state_enum, ck_ppnum_lifecycle_events_{from,to}_state_enum
[Scope] the scopes claim / scopes_supported [SCOPE_TABLE] + const gates in each organ

They are here for one reason, applied five times: a value consumed across the organ boundary collapses into one vocabulary rather than being mirrored and then bound. EntityType arrived by RFC_202607252223, LifecycleState by RFC_202607251658 P1 — and the second move is also what finally makes the cross-fact between them stateable exactly once ([LifecycleState::can_transition_for_entity], the ck_ppnums_expired_only_mask rule). Ppnum, PpnumId and Scope followed on 2026-08-29 (the ADR named in this crate's CHANGELOG), each for the same reason the first two moved: the rule already had several homes and at least two of them disagreed.

Ppnum / PpnumId — the identifier pair

The number, and the id that names its row. Consumers store the two paired; both are immutable for a human. Before this crate the format rule behind [Ppnum] was restated six times across the SDKs, PCS and PAS — and PCS's two copies said == 11 while the column, PAS and the SDKs said ≥ 11, so every 15-digit dependent-agent ppnum PAS minted was un-addable as a PCS contact. The rule is stated once here ([Ppnum::MIN_LEN], the ck_ppnums_format regex), the two renderings that every organ needs (wire digits, hyphen-grouped display) are stated once, and the ladder PAS mints on (11 + 4·depth) is deliberately not here — it is issuance policy PCS never reads.

[PpnumId] was the same ULID under three names (PpnumAccountId in PAS, PpnumId in PCS and the SDK). A value that crosses the organ boundary under one name in the database has one name in Rust.

Scope — the one OAuth scope vocabulary

Every scope PAS mints, with the two attributes both organs decide on: its [ScopeTier] (reach) and its [ScopeEnforcer] (which organ's perimeter matches it). Before this crate the catalog was a PAS string list, a PAS plims:* const module, a 15-member PCS enum with three parallel lists, and bare string atoms in the SDK's tiers — held together by three dev-dependency tests that compiled the other organ to compare strings. Now a scope the SDK requests is a scope PAS mints, by type, and the K8 inclusions (DIRECTION_COUPLING_PASPCS §6 I10) are const gates in chat-core: every Pcs-enforced scope backs a gate, every gate is keyed on a Pcs-enforced scope, and a mislabel fails to compile.

What stays in PAS is what only PAS reads: registerable (the /oauth/apps form's allow-list) and the consent-screen glyph, in a PAS policy table keyed by [Scope] and const-gated to [Scope::ALL]'s order.

EntityType — the one entity vocabulary

What kind of entity a ppnum is. One type, one name, one value-set, shared by the token engine and both services. Before this crate the same fact was reified four times — accounts_core::EntityType (6 variants), chat_core::port::EntityClass (5, a hand-maintained mirror), ppoppo_token::EntityType (3), and the ppnum.EntityType proto enum — and two of those disagreed about whether delegated was a member.

The axis this crate is NOT

EntityType answers what the principal is. It does not answer who is currently acting for it — that is the RFC 8693 §4.1 act claim, and keeping the two apart is the entire point.

There is deliberately no Delegated variant. A human identity driven by an agent is Human plus act — two facts, two fields. Compressing them into one string field is the mistake STS_AUTH_PPOPPO §4.2.1 already rejected for the retired role claim; that rule was never applied to its two siblings (EntityType::Delegated, SenderBadge::Delegated), and this crate is where it finally is. Recovering "is this delegated?" from a single value is impossible by construction here.

Why an engine-tier crate with no IO or transport dependency

The type must be reachable from three places at once, and the crate lattice leaves exactly one option:

  • ppoppo-token needs it for the entity_type claim — and engine → shared is forbidden (xtask::policy::rules::taxonomy), so a crates/shared/* home is illegal. Engine tier it is.
  • chat-core needs it, and bans IO/transport crates (Constitution Principle I). std plus the ULID codec both cores already carry is the only shape it can accept — the same stance that already lets it depend on ppoppo-clock.

Sharing one type also removes a value-set mirror: EntityClass existed only to restate scaccounts.ppnums.entity_type inside PCS, and DIRECTION_COUPLING_PASPCS §4 K5 records that mirror as a gap (PCS's drift test scopes nspname='scchat' and structurally cannot see scaccounts). With one type there is nothing left to drift.

Table, not scattered predicates

[TABLE] is the SSOT of per-variant attributes. Each fact previously lived somewhere else — the wire string in an as_str match, credential-eligibility in a PAS use-case, the AI-disclosure obligation in a doc comment. The enum is retained because exhaustive match is load-bearing: a sixth variant must fail to compile rather than default into a claim.

Attributes that belong to one owner stay with that owner and are deliberately absent here — notably number_class (people/infra/ ephemeral), a GENERATED column in scaccounts that PCS never reads. A shared table is not a dumping ground.

LifecycleState — the one lifecycle vocabulary

What state a ppnum is in, plus the legal transitions between states. Arrived by RFC_202607251658 P1 for two reasons, and it carries the transition lattice as well as the value-set because of the second:

  • PCS reads the value-set. chat-core classifies scaccounts.ppnums.lifecycle_state on its liveness path, and did so through a hand-maintained 8-variant copy. Same unguardable shape as EntityClass above — PCS's drift test is schema-scoped to scchat and structurally cannot see a scaccounts CHECK (DIRECTION_COUPLING_PASPCS §4 K5 / §6 I7).
  • can_transition_for_entity is a cross-fact. It layers ck_ppnums_expired_only_mask — only [EntityType::Mask] may reach [LifecycleState::Expired] — which is a statement about both value-sets. Rust's orphan rule means whichever crate does not own the type cannot say it as an inherent method, so leaving the state machine in accounts-core would have required an extension trait: the invariant expressible in two places again.

This is not the dumping ground the paragraph above rules out. PAS keeps what only PAS reads — the column, the business triggers that drive transitions, and the audit trail. What moved is the fact neither organ could own alone.

effective_admin — the one admin predicate

Not a value-set: a decision. [effective_admin] answers "is this principal an effective admin" from three facts — the is_admin grant, the [LifecycleState] it is (or is not) in effect under, and how many active passkeys the principal holds. It arrived by RFC_202608241353 T-01, and it arrived because the two organs had stopped agreeing:

Premise PAS /admin PCS admin RPCs (before)
is_admin = TRUE checked checked
lifecycle_state = 'active' checked not checked
at least one active passkey checked, bounded not checked

A deactivated account whose grant was never revoked was refused by one organ and admitted by the other. Roles persisting across lifecycle state is deliberate (GUIDE_ADMIN_PAS §4.8), which is exactly what made the missing term reachable rather than theoretical.

The collapse is the same one the two value-sets above took, applied to a predicate: one declaration, two callers. Each organ's adapter gathers the premises from its own tier — PAS from accounts-database, PCS from its cross-schema read into scaccounts — and both hand them to the same function. Duplicating the conjunction in chat-core was the fast path and is rejected in the RFC's §8: a second declaration of a security predicate is the shape K5 exists to forbid.

Two things travel with the predicate rather than with its callers, because a caller-local copy of either would let the organs diverge again while both "used the shared function": the Layer-2 budget ([ADMIN_PASSKEY_CHECK_TIMEOUT]) and the unknown premise ([ActivePasskeys::Unknown]) that a blown budget produces. Protocol mapping — which status code, which page, which gRPC metadata — stays at each caller's edge, exactly as with everything else here.