1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
//! **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`](LifecycleState::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.
use fmt;
pub use ;
pub use LifecycleState;
pub use ;
pub use ;
/// What kind of entity a ppnum is.
///
/// Members are exactly the storable values of
/// `scaccounts.ppnums.entity_type` (`ck_ppnums_entity_type_enum`) — bound to
/// that constraint by the enrollment directly below, so the constraint name
/// lives *on the fact*.
///
/// There is **no `non_stored` list**, and that is the point: every member of
/// this vocabulary is a real, storable entity kind. The render-only
/// `Delegated` that the retired PAS enum had to declare as an exception is not
/// an exception here — it is simply not an entity type. See the module docs.
///
/// The `serde` impls are **feature-gated and off by default** (PCS caches
/// `PpnumAccount` as JSON in KVRocks and needs them; the only other consumer is
/// PAS's [`AdminLayer1`] cache value, which carries a [`LifecycleState`]). The
/// wire form is `rename_all = "snake_case"` — byte-identical to
/// [`as_str`](Self::as_str), and not by coincidence: `snake_case` of each
/// variant *is* the canonical string, so the derive cannot drift from
/// [`TABLE`]. If a variant ever needs a form `snake_case` does not produce,
/// delete the derive and write a manual impl that delegates to `as_str` — a
/// second spelling of the wire strings is precisely the mirror this crate
/// exists to remove.
// SSOT binding: the vocabulary must equal `ck_ppnums_entity_type_enum`.
//
// The enrollment lives here, next to the enum, rather than in PAS — which is
// what `RFC_202607252223` T-03's tier move bought. While it sat in
// `accounts-core` the DB binding could not follow the type up the lattice, so
// a second PAS-local enum had to stay alive purely to hold it, and every
// textual analysis of this vocabulary was ambiguous between the two.
//
// PAS still owns the *column* (and `number_class`, which is a `GENERATED`
// column PCS never reads); this crate owns the *value-set*. Verification is
// unchanged — `accounts-api/tests/schema_check_drift.rs` reads the
// materialized `CHECK` via `pg_get_constraintdef` and asserts set-equality.
impl_schema_constrained!;
/// One row of [`TABLE`] — every attribute of one entity type.
/// **The lookup table — SSOT of every per-variant attribute.**
///
/// Ordered identically to [`EntityType::ALL`]; the const gate below proves
/// it, so the two can never be read out of step.
pub const TABLE: = ;
// ── Compile-time gates ──────────────────────────────────────────────────
//
// `TABLE` and `ALL` are two orderings of one fact; nothing at runtime would
// notice them diverging, so it is settled at build time.
const _: = ;
// Outer, not the usual inner `#![allow(..)]`: ARCH-BOUNDARY pins the exact
// inner-attribute block of a published-tier `lib.rs` and scans every line,
// so an indented inner attribute inside this module trips it too.