ppoppo-identity 0.23.0

Principal-identity vocabulary for the ppoppo ecosystem — the single EntityType value-set shared by the token engine and both services
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

Two value-sets and one predicate over them, all owned by the scaccounts.ppnums table and all read by more than one organ, so none of them can live in either organ. The value-sets first:

Type Column CHECK
[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

They are here for one reason, applied twice: a value-set 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).

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 zero dependencies

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). Pure std 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.