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-tokenneeds it for theentity_typeclaim — andengine → sharedis forbidden (xtask::policy::rules::taxonomy), so acrates/shared/*home is illegal. Engine tier it is.chat-coreneeds it, and bans IO/transport crates (Constitution Principle I). Purestdis the only shape it can accept — the same stance that already lets it depend onppoppo-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-coreclassifiesscaccounts.ppnums.lifecycle_stateon its liveness path, and did so through a hand-maintained 8-variant copy. Same unguardable shape asEntityClassabove — PCS's drift test is schema-scoped toscchatand structurally cannot see ascaccountsCHECK(DIRECTION_COUPLING_PASPCS§4 K5 / §6 I7). can_transition_for_entityis a cross-fact. It layersck_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 inaccounts-corewould 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.