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-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).stdplus the ULID codec both cores already carry is 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.