pas-external 0.12.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
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
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
# Changelog

All notable changes to `pas-external` are documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

> **Pre-1.0 version reset (2026-04-30):** All previously-published
> versions (`1.0.1`, `2.0.0`, `3.0.0`, `3.1.0`, `4.0.0`, `4.0.1`,
> `4.0.2`, and the development series targeted at `5.0.0`) were
> yanked from crates.io on 2026-04-30 to align the public version
> cadence with the project's pre-production status. While in `0.x.y`,
> breaking changes are landed as **minor bumps** per SemVer §11
> (e.g., `0.2.0` may break compat with `0.1.0`). Historical changelog
> entries for the yanked versions are preserved below for archaeology
> — adopters of `0.1.0+` do not need to read them.

## [0.12.0] — 2026-05-10

Clock-port Slice 7 — injectable `ArcClock` across all SDK structs.
Consumers can now call `.with_clock(clock)` on any struct that reads
time, enabling deterministic test control without real-time waits.

Trigger: clock-port RFC Slice 7 (closed 2026-05-10).

### Added

- `RelyingParty<S>::with_clock(clock: ArcClock) -> Self` — injects clock
  into the RP's `start()` timestamp and the underlying `PasIdTokenVerifier`.
- `PasIdTokenVerifier<S>::with_clock(clock: ArcClock) -> Self` — propagates
  to the inner `JwtVerifier` / `JwksCache`.
- `InMemoryStateStore::with_clock(clock: ArcClock) -> Self` — controls TTL
  expiry in boundary tests (`cfg(feature = "oauth")`).
- `InProcessTtlCache::with_clock(clock: ArcClock) -> Self` — controls TTL
  expiry in boundary tests.
- `pas_external::clock::*` re-export — consumers reach `ArcClock`,
  `FrozenClock`, etc. without a separate `ppoppo-clock` dep.
- New direct dependency: `ppoppo-clock = "0.1"` (published this release).

### Non-breaking

- All existing construction paths (`RelyingParty::new`, `PasIdTokenVerifier::from_jwks_url`, etc.)
  default to `WallClock` — no migration required.

## [0.11.0] — 2026-05-08

App-credential collapse Phase A — extracts the SDK's
verifier/audit/session-liveness/discovery/bearer primitives into a
new 1st-party-shared crate `ppoppo-sdk-core` (`publish=false`) and
relocates the perimeter Bearer-auth Layer kit out of
`oidc::axum::*`. pas-external 0.11.0 ships the consumer-facing
re-exports at the new shapes; 0.10.0 paths are removed (no
transitional aliases — invariants live in
`STANDARDS_AUTH_PPOPPO §13.5` + `STANDARDS_API_TOOLING_PPOPPO §12`).

Trigger: app-credential-collapse Phase A (closed 2026-05-09;
Slices 1a + 1b + 2 + 4 + 5a). Co-design with `pas-plims` Phase A
(SDK family inheritance) and `sv-readout` Slice 1 (`AdminSvFetcher`
deferred — only the `FetchError` enum widening lands in 0.11.0).

### BREAKING — import-path changes

> **No transitional aliases** (audit decision A). Adopters update
> import paths in one cutover. Pre-launch posture (`project_pre_launch_state`):
> no live consumer migration friction to absorb.

- **Verifier cohesive group** moved from `pas_external::token::*` to
  `pas_external::*` (top-level). Renames at the same time:
  `PasJwtVerifier``JwtVerifier`; `Expectations``VerifyConfig`;
  `AuthSession``VerifiedClaims` (audit decision G — distinct
  identity from the chat-auth perimeter `AuthSession`).
  - Old: `pas_external::token::{BearerVerifier, PasJwtVerifier, Expectations, AuthSession, JwksCache, MemoryBearerVerifier, VerifyError}`.
  - New: `pas_external::{BearerVerifier, JwtVerifier, VerifyConfig, VerifiedClaims, JwksCache, MemoryBearerVerifier, TokenVerifyError}`.
  - The crypto-side `VerifyError` is re-exported as **`TokenVerifyError`**
    to free the bare `VerifyError` name for the perimeter Layer-side
    `VerifyError` from `pas_external::bearer::*`.
- **Bearer Layer kit** moved from `pas_external::oidc::axum::*` to
  `pas_external::bearer::*` (audit decision D — 1-level role-named
  module; no nested `oidc::axum::*` namespace). The actual primitives
  live in `ppoppo_sdk_core::bearer::*` (audit decision F — flat path,
  no `perimeter::bearer::*`). pas-external re-exports for 3rd-party
  consumers (RCW, CTW); 1st-party services (chat-auth, chat-api)
  import direct from sdk-core (audit decision B — no chat-auth
  re-export passthrough).
  - Old: `pas_external::oidc::axum::{AuthProvider, BearerAuthLayer, BearerAuthConfig, VerifyError, MemoryAuthProvider}`.
  - New: `pas_external::bearer::{AuthProvider, BearerAuthLayer, BearerAuthConfig, VerifyError, MemoryAuthProvider}`.
- **OIDC discovery primitive** lifted to sdk-core; pas-external's
  `pas_external::oidc::discovery::*` is now a thin re-export
  (`pub use ::ppoppo_sdk_core::discovery::*`). Public surface
  unchanged on the consumer side (`Discovery`, `DiscoveryError`,
  `fetch_discovery` reach the same symbols), but the *source* of
  truth is now sdk-core.
- **Audit primitives** lifted to sdk-core; pas-external's
  `pas_external::audit::*` re-exports (`AuditSink`, `AuditEvent`,
  `RateLimitedAuditSink`, `MemoryAuditSink`, `MemoryRateLimiter`,
  `NoopAuditSink`, `RateLimiter`, `RateLimitKey`, `VerifyErrorKind`,
  `IdTokenFailureKind`, `compose_source_id`, `compose_id_token_source_id`).
  Source: `ppoppo_sdk_core::audit::*`. Same shapes; new owner.
- **`SessionLiveness` port** lifted to sdk-core; pas-external's
  `pas_external::session_liveness::{SessionLiveness, SessionLivenessError}`
  is a re-export. AES wrapper (`TokenCipher`,
  `EncryptedRefreshToken`, `LivenessFailure`, …) stays
  pas-external-local behind `feature = "session-liveness"`.
- **Identity types** (`Ppnum`, `PpnumId`, `SessionId`, `UserId`,
  `KeyId`) lifted to sdk-core; pas-external re-exports.
- **`Error::InvalidPpnum``Error::SdkCore(#[from] SdkCoreError)`**.
  The narrow `InvalidPpnum` variant moves to
  `ppoppo_sdk_core::SdkCoreError::InvalidPpnum`; pas-external's
  top-level `Error` collapses through the seam variant.

### BREAKING — `FetchError` widened from struct → enum

`pas_external::epoch::FetchError` (was `pub struct
FetchError(pub String)`) widens to a `#[non_exhaustive]` enum so
`Fetcher` impls can hand the composer a substrate-grained reason
that survives into dashboards:

- `FetchError::AuthorityDenied(String)` — caller credential rejected
  by the authoritative substrate.
- `FetchError::SubjectNotFound(String)` — substrate confirms the
  queried `sub` is absent.
- `FetchError::Throttled(String)` — substrate rate-limit / 429.
- `FetchError::Other(String)` — catch-all for substrate transients
  (network, deserialization, unspecified 5xx). Replaces 0.10.x's
  `FetchError(String)`.

The composer (`CompositeEpochRevocation`) still collapses every
variant onto `EpochRevocationError::Transient` via Display —
fail-closed contract preserved. Existing chat-api `PgFetcher`
migrated to construct `FetchError::Other(...)` (semantic-preserving
1-arm migration); future `AdminSvFetcher` (sv-readout Slice 1) will
emit the substrate-grained variants natively.

### Deferred (Phase A out-of-scope, NOT in 0.11.0)

- **Concrete `AdminSvFetcher` impl** wrapping
  `pas.session.v1.SessionService` — co-creation with sv-readout
  Slice 1; will land as a follow-up patch on 0.11.x without a
  Cargo bump (additive).
- **`TokenCache` + `AuthInterceptor`** — deferred to first client
  SDK consumer (pas-plims B/C or pcs-external Phase E).
- **`BearerVerifyLayer` + `ScopePolicy` + `AuthorityCheck`**  deferred to pas-plims Phase A.

## [0.10.0] — 2026-05-08

Phase 11.Z continuation — closes the per-user RCW/CTW sv enforcement
gap, adds the L2 (session-row liveness) verifier slot, and removes
the 0.9.0 `UserinfoFetcher` that ignored its `_sub` parameter. Adds
the canonical KVRocks-backed `SharedCacheCache` (lifted from chat-api)
and ships the SessionLiveness port for consumer L2 substrate
adapters.

Trigger: `RFC_2026-05-08_pas-external-0.10.0-rcw-ctw-sv-axis-completion.md`
§4 (Slice 0 audit closure) + §3 Slice 1 (SDK shape change).

### Added

- **`pas_external::epoch::SharedCacheCache`** (gated
  `feature = "shared-cache"`) — adapter implementing the SDK's
  sv-specific `Cache` (`Option<i64>`) over any
  `Arc<dyn ppoppo_infra::Cache>`. Substrate-agnostic by construction:
  KVRocks via `ppoppo-kvrocks::KvCache` for production, in-memory
  mocks for tests. Promoted from chat-api's `session_version::KvCache`
  (replace, don't layer — lift mirrors RFC 11.Z chat-auth promotion).
  RFC_2026-05-08 §4.1 lock.
- **`pas_external::session_liveness::SessionLiveness`** — new L2 port
  for per-request session-row revocation enforcement. Distinct from
  the existing `attempt_liveness_refresh` (PAS-callback periodic
  check). 3-state contract: `Ok(())` = live, `Err(Revoked)` = absent
  or revoked row, `Err(Transient(detail))` = substrate down
  (fail-closed). Trait + error enum are NOT gated on
  `feature = "session-liveness"` — the AES wrapper and the lookup
  port have orthogonal dep needs. RFC_2026-05-08 §4.2 lock.
- **`pas_external::session_liveness::SessionLivenessError`** — typed
  error (`Revoked` / `Transient(String)`).
- **`PasJwtVerifier::with_session_liveness(Arc<dyn SessionLiveness>)`**  opt-in verifier slot symmetric to `with_epoch_revocation`. With no
  port wired, the verifier short-circuits the L2 check (matches
  pre-0.10.0 behavior). With a port wired, every verify (after engine
  success) consults the port for the bearer's `sid` claim. **Lenient
  on no-`sid`**: tokens without a `sid` claim admit without consulting
  the port — non-session-bound tokens (machine credentials, AI-agent
  flows, R6 legacy admit) have no row to look up. RFC_2026-05-08 §4.2
  lock.
- **`VerifyError::SessionRevoked`** — typed variant for L2 row-revoked
  rejection. Distinct from `SessionVersionStale` (L1) so audit logs
  pivot per-session logout from cluster-wide break-glass.
- **`VerifyError::SessionLivenessLookupUnavailable`** — typed variant
  for L2 substrate-down rejection (HTTP 503, fail-closed). Distinct
  from `SessionVersionLookupUnavailable` (L1).
- **`audit::VerifyErrorKind::SessionRevoked`** +
  **`SessionLivenessLookupUnavailable`** — companion audit-pivot
  variants.
- New optional dep: **`ppoppo-infra` workspace crate** (trait-only,
  no Redis client deps). Pulled by `feature = "shared-cache"` only;
  default builds unchanged.

### Removed

- **`pas_external::epoch::UserinfoFetcher`** — deleted. The 0.9.0
  framing was misleading: PAS's userinfo authenticates the *caller*,
  not an arbitrary queried subject, so `Fetcher::fetch(_sub)` ignored
  `_sub` and returned the caller's own sv. Zero consumers across the
  3-monorepo workspace verified 2026-05-08. Replacement:
  `SharedCacheCache` over the canonical KVRocks `sv:{sub}` key
  (proper per-user shape). `feedback_no_backcompat_healthy_arch` +
  pre-launch state authorize delete-not-shim.

### Migration

For consumers upgrading from 0.9.0:

```diff
- use pas_external::epoch::{UserinfoFetcher, InProcessTtlCache, CompositeEpochRevocation};
+ use pas_external::epoch::{SharedCacheCache, CompositeEpochRevocation};

- let cache = Arc::new(InProcessTtlCache::new(Duration::from_secs(60)));
- let fetcher = Arc::new(UserinfoFetcher::new(pas_url).with_access_token(svc_token));
+ let cache: Arc<dyn pas_external::epoch::Cache> = Arc::new(SharedCacheCache::new(kv));
+ let fetcher: Arc<dyn pas_external::epoch::Fetcher> = /* consumer-specific — see below */;
  let port = Arc::new(CompositeEpochRevocation::new(cache, fetcher));
  verifier.with_epoch_revocation(port)
```

#### RCW/CTW L1 sv-axis Fetcher gap (deferred to Slice 3)

After 0.10.0, `pas_external::epoch::Fetcher` has **no opinionated
default implementation**. Consumers wiring `with_epoch_revocation`
for L1 sv-axis enforcement choose between:

- **Cache-only**: wire only `SharedCacheCache`, accept fail-closed on
  cache miss. Cache miss → `VerifyError::SessionVersionLookupUnavailable`
  (HTTP 503). PAS-side aggressive pre-warming required.
- **PAS admin-readout endpoint**: blocked on a separate PAS RFC
  adding `/admin/sv/{sub}` (or equivalent). Once available, ship
  `HttpAdminSvFetcher` in pas-external (or consumer-side adapter)
  and wire as the cache-miss authoritative source. Symmetric to
  chat-api's `PgFetcher` shape but transport-routed.
- **Engine relax**: ppoppo-token modifies `EpochRevocation` to make
  Fetcher optional. NOT recommended — changes fail-closed semantics
  that STANDARDS_AUTH_INVALIDATION §3 treats as load-bearing.

`pas-external 0.10.0` ships only the cache adapter; the Fetcher
choice is a Slice 3 deployment decision per consumer.
RFC_2026-05-08 §4.4.

#### L2 SessionLiveness wire shape (RCW/CTW Slice 3/4)

Consumer-side adapter (~10 LOC):

```rust,ignore
use async_trait::async_trait;
use pas_external::{SessionLiveness, SessionLivenessError};
use pas_external::types::SessionId;
use sqlx::PgPool;

#[derive(Debug)]
pub struct PgSessionLiveness { pool: PgPool }

#[async_trait]
impl SessionLiveness for PgSessionLiveness {
    async fn check(&self, sid: &SessionId) -> Result<(), SessionLivenessError> {
        let row: Option<(Option<time::OffsetDateTime>,)> =
            sqlx::query_as("SELECT revoked_at FROM scrcall.user_sessions WHERE id = $1")
                .bind(&sid.0).fetch_optional(&self.pool).await
                .map_err(|e| SessionLivenessError::Transient(format!("session lookup: {e}")))?;
        match row {
            None | Some((Some(_),)) => Err(SessionLivenessError::Revoked),
            Some((None,)) => Ok(()),
        }
    }
}
```

Wire at verifier construction:
`PasJwtVerifier::with_session_liveness(Arc::new(PgSessionLiveness{pool}))`.
RCW uses `scrcall.user_sessions`; CTW mirror uses
`scctime.user_sessions`.

### Workspace-internal consumer migrations (same commit as SDK shape change)

- **chat-api**`services/chat/crates/chat-api/src/session_version.rs`
  shrinks: local `KvCache` deleted (lifted to SDK as
  `SharedCacheCache`); `PgFetcher` retained (chat-api-specific
  cross-schema reader, kept consumer-side). `chat-api/src/main.rs`
  rewires to `SharedCacheCache::new(cache)` directly. Replace, don't
  layer.
- **PAS userinfo handler doc-comment**  `services/accounts/crates/accounts-api/src/rest/userinfo.rs`
  updated to reflect `SharedCacheCache` as the canonical reader.

### Publish prerequisites (Slice 2 split)

Slice 2a (separate user-confirmed session) publishes
`ppoppo-infra v0.1.0` to crates.io before pas-external 0.10.0
publish (Slice 2b) because `feature = "shared-cache"` pulls the
trait crate. Workspace path-deps make local builds + tests work
without the registry; `cargo publish -p pas-external` requires
ppoppo-infra available on crates.io first. RFC_2026-05-08 §3
(Slice frame) + §4.1 closure note.

## [0.9.0] — 2026-05-09

Phase 11.Z — sv-axis enforcement port surfaced through the SDK
boundary. Previously the engine's `EpochRevocation` port was
unreachable from `PasJwtVerifier`-based consumers (RCW/CTW), so every
PAS-issued token admitted past the sv axis regardless of break-glass
state. 0.9.0 exposes the port and ships the canonical adapter set.

Trigger: `RFC_2026-05-09_pas-external-0.9.0-sv-axis-surfacing.md`
(§3 + §3.5 — Slice 1).

### Added

- **`pas_external::epoch`** — new module gated on
  `feature = "well-known-fetch"`. Re-exports the engine's
  [`EpochRevocation`] port + [`EpochRevocationError`] from
  `ppoppo-token`, plus the canonical adapter set:
  - **`Cache`** — best-effort `sv:{sub}` cache port
    (`get` / `set`). Promoted from chat-auth's private
    `SessionVersionCache` trait per RFC §3 Row 6 ("replace, don't
    layer").
  - **`Fetcher`** — authoritative substrate readout port
    (`fetch(sub) -> Result<i64, FetchError>`). Promoted from
    chat-auth's private `SessionVersionFetcher` trait.
  - **`InProcessTtlCache`** — opinionated in-process TTL `Cache`
    impl. Default for RCW/CTW (Slice 4/5); per-pod, lazy-evicting,
    `RwLock<HashMap>`-backed.
  - **`UserinfoFetcher`** — HTTP `Fetcher` impl reading
    `session_version` from PAS `/userinfo`. Requires the consumer's
    OAuth scope set to include `"session_version"` (re-enabled on
    PAS-side, scope-gated, in this same release).
  - **`CompositeEpochRevocation`** — combines a `Cache` + a
    `Fetcher` into the engine port. Cache hit short-circuits;
    cache miss fetches authoritative + writes back; fetcher
    transient surfaces as `EpochRevocationError::Transient`
    (fail-closed). Promoted from chat-auth's
    `ChatAuthEpochRevocation`.
  - Re-exports of `SV_CACHE_TTL` (60 s) and `sv_cache_key(sub)`
    so consumers writing custom `Cache` impls share the canonical
    namespace with PAS's writer
    (`STANDARDS_SHARED_CACHE.md` §3.1).
- **`PasJwtVerifier::with_epoch_revocation(Arc<dyn EpochRevocation>)`**
  — opt-in builder method. With no port wired the engine's
  `check_epoch` gate short-circuits (matching pre-11.Z behavior).
  With a port wired, every verify call queries
  `port.current(sub)` and the engine compares against the token's
  `sv` claim. Stale tokens reject as
  `VerifyError::SessionVersionStale`; substrate-down failures reject
  as `VerifyError::SessionVersionLookupUnavailable` (fail-closed).
- **`VerifyError::SessionVersionStale`** — typed variant for engine
  `AuthError::SessionVersionStale`. Pre-11.Z this collapsed to
  `Other(String)` along with every uncategorized engine error.
- **`VerifyError::SessionVersionLookupUnavailable`** — typed variant
  for engine `AuthError::SessionVersionLookupUnavailable`.
- **`audit::VerifyErrorKind::SessionVersionStale`** +
  **`SessionVersionLookupUnavailable`** — companion audit-pivot
  variants. Audit dashboards filter on these kinds to surface
  break-glass propagation lag (Stale) and substrate health problems
  (LookupUnavailable) distinct from cryptographic failure.

### Removed

- **`AuthSession::session_version() -> Option<i64>`** accessor. The
  doc-comment hedge — *"forward compatibility with a future engine
  that surfaces `sv` directly"* — was hedging in the wrong
  direction. Path A reshaped (RFC §1) confirms the forward direction
  is to NOT surface; Phase 4 Decision 1 keeps `sv` HIDDEN on engine
  `Claims`. Consumers wire `EpochRevocation` instead.

### PAS-side companion change

- **`UserInfoResponse.session_version: Option<i64>`** re-added,
  scope-gated on the new `"session_version"` scope. Reverses Phase
  10.13.B F6 cleanup partially — default-scope (`profile`/`email`)
  tokens still see no `session_version` on userinfo, preserving
  F6 spirit. Only consumers wiring `EpochRevocation` request the
  new scope. The `session_version` scope is added to PAS's
  `SUPPORTED_SCOPES`; consumers add it to their `RequestedScope`
  set at OAuth time.

### Migration

For consumers that want sv-axis enforcement (RCW/CTW canonical
shape):

```rust
use std::sync::Arc;
use std::time::Duration;
use pas_external::epoch::{
    CompositeEpochRevocation, InProcessTtlCache, UserinfoFetcher,
};

let cache: Arc<dyn pas_external::epoch::Cache> =
    Arc::new(InProcessTtlCache::new(Duration::from_secs(60)));
let fetcher: Arc<dyn pas_external::epoch::Fetcher> = Arc::new(
    UserinfoFetcher::new("https://accounts.ppoppo.com")
        .with_access_token(my_service_account_access_token),
);
let port: Arc<dyn pas_external::epoch::EpochRevocation> =
    Arc::new(CompositeEpochRevocation::new(cache, fetcher));

let verifier = pas_external::PasJwtVerifier::from_jwks_url(
    "https://accounts.ppoppo.com/.well-known/jwks.json",
    pas_external::Expectations::new(/*…*/),
)
.await?
.with_epoch_revocation(port);
```

Plus `RCW_SCOPES` / `CTW_SCOPES` (or equivalent) MUST contain
`"session_version"` so the consumer's access tokens grant the
substrate-readout scope.

For consumers that don't enforce sv-axis (no break-glass propagation
required), no migration is needed — `with_epoch_revocation` is opt-in
and the gate short-circuits without it.

### Deferred (out of 0.9.0)

- **`SharedCacheEpochRevocation`** (Cache-only adapter, no
  fetcher fall-through) — ships in 11.AB+ when `KVROCKS_URL` ACL
  extends to RCW/CTW workloads. The current 0.9.0 surface covers
  every consumer's needs; adding it later is non-breaking.
- **Bounded-capacity `InProcessTtlCache`** — current impl is
  unbounded. Pre-launch workloads with bounded user counts are
  unaffected. Future LRU-backed adapter ships when needed.

## [0.8.0] — 2026-05-08

Phase 11 SDK shape consolidation. Stabilization release with API
tightening; no runtime behavior change vs `0.8.0-beta.1`.

### Added

- `pas_external::test_support::FakePasServer` — wiremock-wrapped fake
  PAS Authorization Server. Replaces the
  `RelyingParty::for_test_with_parts` escape hatch from 0.7.x —
  consumer boundary tests now construct a real
  `RelyingParty::new(config, store)` against `FakePasServer.issuer_url()`
  and exercise the production HTTP discovery + JWKS bootstrap path.
  Gated on `feature = "test-support"`. Pulls in `wiremock` (optional dep).
- `pas_external::oidc::RefreshOutcome` — typed return for
  `RelyingParty::refresh`. Replaces direct exposure of the OAuth
  `TokenResponse` wire DTO at the SDK boundary; mirrors `Completion<S>`
  (the deep return of `complete`). `expires_in: Option<Duration>` (was
  `Option<u64>` on `TokenResponse`).
- `pas_external::test_support::TokenResponse` /
  `pas_external::test_support::PasIdTokenVerifier` — compatibility
  re-exports under the `test-support` feature. SDK boundary tests +
  downstream consumer integration tests use these to fabricate the
  OAuth wire DTO and instantiate the verifier directly. **11.Z migration
  target**: migrate those tests onto `FakePasServer` +
  `RelyingParty::complete`, then drop these re-exports.

### Changed

- `RelyingParty::refresh` return type: `oauth::TokenResponse`  `oidc::RefreshOutcome`. Migration: same field names; `expires_in:
  Option<u64>``Option<Duration>` (consumers drop a
  `.map(Duration::from_secs)` call from each callsite).

### Removed

- **`RelyingParty::for_test_with_parts`** — extracted-for-testability
  escape hatch. Use `test_support::FakePasServer` +
  `RelyingParty::new(...)` for the same coverage on the production HTTP
  boot path.
- **`pas_external::middleware::*`** — Phase 6.1 cookie-as-session-id
  flow (~1908 LOC across 13 files). Superseded by `pas_external::oidc::*`
  (composition root + post-verify shapes) + `pas_external::oidc::axum::*`
  (perimeter `BearerAuthLayer` + `AuthProvider` from Phase 11.X.C). All
  consumers (chat-auth, RCW, CTW) migrated in 11.X.A/B/C.
- **`pas_external::oauth::AuthorizationRequest`** type — sole consumer
  was the legacy middleware's `login` handler. `AuthClient::authorization_url`
  deleted with it.
- **`pas_external::oauth::UserInfo`** type — superseded by
  `oidc::IdAssertion<S>` for scope-bounded PII; sole consumer was the
  legacy middleware's userinfo flow.
- **`pas_external::pas_port::PasAuthPort::userinfo()`** method — no
  surviving consumer. The `refresh()` method is retained (consumed by
  `session_liveness::attempt_liveness_refresh` +
  `oidc::RelyingParty::refresh`).
- **`pas_external::token::jwt::peek_session_version`** — internal helper
  used only by the legacy middleware.
- **`OAuthConfig::with_userinfo_url` / `userinfo_url()`** — dead with
  the userinfo method removal.
- **`AuthClient::with_http_client`** — caller-supplied HTTP-client
  constructor; no consumer used it.
- SDK-internal tests `tests/session_validator_refresh.rs` +
  `tests/sv_aware_trait.rs` — covered the legacy middleware's
  internal `SessionValidator` + `SvAware`; deleted with them.
- `urlencoding` optional dep — was used only by the legacy middleware.

### Visibility tightened (`pub``pub(crate)` at module level)

- `oauth` module → `pub(crate)`. Consumers reach OAuth through
  `oidc::RelyingParty<S>` and `oidc::RefreshOutcome`. The `AuthClient`,
  `OAuthConfig`, `TokenResponse` types still exist but are unreachable
  externally.
- `pkce` module → `pub(crate)`. `RelyingParty::start` consumes PKCE
  primitives internally; the `generate_state` / `generate_code_verifier`
  / `generate_code_challenge` re-exports at the crate root are gone.
- `oidc::verifier` module → `pub(crate)`. `PasIdTokenVerifier` is no
  longer reachable via `pas_external::oidc::PasIdTokenVerifier` — use
  `RelyingParty::<S>::complete` for production verification.

### Surface after 0.8.0

The pas-external public surface is now:

- `oidc::RelyingParty<S>` — composition root for the OIDC RP flow.
- `oidc::axum::{BearerAuthLayer, AuthProvider, BearerAuthConfig, BearerAuthSession, ...}` — perimeter (Phase 11.X.C).
- `oidc::{IdAssertion<S>, IdTokenVerifier<S>, MemoryIdTokenVerifier<S>, RefreshOutcome, Completion<S>, Discovery, fetch_discovery, ...}` — post-verify shapes + ports.
- `oidc::{Config, State, RelativePath, AuthorizationRedirect, CallbackParams, StateStore, InMemoryStateStore, PendingAuthRequest, ...}` — state-store port + value types.
- `oidc::{Openid, Email, EmailProfile, ..., Nonce, ScopePiiReader, HasEmail, HasProfile, ...}` — scope markers.
- `token::{BearerVerifier, PasJwtVerifier, MemoryBearerVerifier, AuthSession, Expectations, VerifyError}` — γ port + adapters.
- `session_liveness::{TokenCipher, attempt_liveness_refresh, EncryptedRefreshToken, LivenessOutcome, LivenessFailure, RevokeCause, TransientCause, CipherError}`.
- `pas_port::{PasAuthPort, PasFailure, MemoryPasAuth, PasRefreshOutcome, pas_refresh, CipherFailure}`.
- `audit::{AuditEvent, AuditSink, NoopAuditSink, MemoryAuditSink, RateLimitedAuditSink, MemoryRateLimiter, RateLimiter, RateLimitKey, VerifyErrorKind, IdTokenFailureKind, compose_id_token_source_id, compose_source_id}`.
- `types::{KeyId, Ppnum, PpnumId, SessionId, UserId}`.
- `Url` — re-export of `url::Url` for consumer ergonomics.
- `test_support::{FakePasServer, TokenExchangeBody, TokenResponse, PasIdTokenVerifier}` — test substrate, gated `feature = "test-support"`.

No Phase 6.1 artifacts remain.

## [0.8.0-beta.1] — 2026-05-07

Phase 11.X.C **additive** pre-release. Surfaces the perimeter
`BearerAuthLayer` mechanism that chat-auth (1st integrator), RCW (2nd),
and CTW (3rd) all carry as near-identical ~600-LOC modules — N=3
evidence justifies the SDK promote per `feedback_deep_modules`. No
breaking changes vs `0.7.1`; existing imports continue to compile
unchanged. The pre-release tag (`-beta.1`) signals that the consumer
migration (Slices 2 / 4 / 5 of 11.X.C) is in flight; the stable `0.8.0`
will land when 11.Y closes the escape-hatch surface
(`oauth::AuthClient` + `PasIdTokenVerifier` `pub(crate)` lockdown).

Trigger: `RFC_2026-05-07_oidc-rp-phase-11x-rcw-ctw-migration.md` §11
(SDK contract lock).

### Added

- **`oidc::axum`** — new module gated by the existing `axum` feature.
  Four public types; consumers import via the explicit
  `pas_external::oidc::axum::*` path (no `oidc::*`-level re-export, so
  the framework dependency stays visible at the import site).
  - **`oidc::axum::AuthProvider<S>`** — generic perimeter port.
    `async fn verify_token(&self, bearer: &str) -> Result<S, VerifyError>`.
    Single async method spans the consumer's full security decision
    (cryptographic verify + substrate liveness lookup) and produces
    the consumer's perimeter session type `S` directly. The generic
    `S` lets each consumer keep its native session shape — chat-auth's
    5-field `AuthSession` (with `account_type`/`scopes`/`audience`
    for OAuth 3rd-party scope-gating) and RCW/CTW's 3-field
    `BearerAuthSession` coexist behind the same SDK Layer with no
    projection step.
  - **`oidc::axum::VerifyError`** — 2-variant enum
    (`Rejected(String)` / `SubstrateTransient(String)`) lifted
    verbatim from RCW/CTW. Layer maps `Rejected` to
    **401 + add-based cookie clearance** and `SubstrateTransient` to
    **503 with cookies preserved**. Richer per-substrate taxonomies
    (chat-auth's break-glass dashboard with `JtiReplayed`,
    `SessionVersionStale`, etc.) collapse here at the SDK boundary
    inside the consumer's `verify_token` impl, mirroring RCW's
    existing `map_verify_err` shape.
  - **`oidc::axum::BearerAuthConfig`** — value struct carrying
    `access_cookie_name: &'static str` and an
    `Arc<dyn Fn(CookieJar) -> CookieJar + Send + Sync>` clearance
    closure. Per-consumer cookie inventory stays consumer-side
    (`__Host-pcs_at` for chat-auth, `__Host-rcw_at` for RCW,
    `__Host-ctw_at` for CTW); the SDK never spells the literal.
    Future fields (e.g. `audit_sink: Option<Arc<dyn AuditSink>>` in
    11.Y) can be added with a `Default` impl — non-breaking.
  - **`oidc::axum::BearerAuthLayer<Sess, P>`** — tower
    [`Layer`]::tower::Layer implementation. Two generic parameters:
    `Sess` (consumer's perimeter session type) and `P: ?Sized`
    (`AuthProvider<Sess>` impl, supporting both concrete `Arc<MyProvider>`
    and trait-object `Arc<dyn AuthProvider<Sess>>`). Mounts at axum
    HTTP-edge granularity; produces a single, type-safe `Sess` request
    extension. Single perimeter / single `verify_token` call site
    invariant per `feedback_perimeter_auth_layer`.
- **`oidc::axum::MemoryAuthProvider<S>`** (gated `cfg(any(test, feature = "test-support"))`)
  — in-memory test-support adapter mirroring the existing
  `token::MemoryBearerVerifier` / `oidc::MemoryIdTokenVerifier` pattern.
  Lets consumer integration tests and SDK boundary tests share one mock
  shape rather than re-defining a `MockValidator` struct in every file.

### Sources & RFC references

- Authorization-vs-cookie precedence: RFC 6750 §2.1 (header-preferred);
  RFC 9700 §6.3 (browser-context BCP).
- Bearer scheme case-insensitivity: RFC 7235 §2.1 — `Bearer` and
  `bearer` both accepted.
- Cookie name domain-scoping rationale: RFC 6265bis (`__Host-` prefix).
- N=3 evidence threshold: `feedback_deep_modules` (workspace memory) +
  RFC §11.1 audit summary.
- Single-perimeter invariant: `feedback_perimeter_auth_layer` (workspace
  memory) — `verify_token` call site count must remain at 1 across the
  consumer codebase.

### Test coverage

- New SDK boundary suite at `tests/oidc_axum_boundary.rs` (10 cases):
  header_path / cookie_path / header_wins_over_cookie /
  missing_credentials_returns_401_no_clear /
  rejected_returns_401_with_clear /
  substrate_transient_returns_503_no_clear / verbatim_cookie_value /
  empty_token_treated_as_missing / lowercase_bearer_prefix /
  unrelated_cookies_in_jar.
- 30 redundant boundary-test assertions (10 cases × 3 consumer modules)
  collapse into this 1 SDK suite once Slices 2 / 4 / 5 land in chat-auth
  + RCW + CTW. Substrate-specific tests (KVRocks sv-axis; PgPool
  session-row revocation) remain consumer-side — they exercise the
  `AuthProvider` impl, not the Layer.

### Internal — no consumer impact

- `tower = { workspace = true, optional = true }` added to dependencies,
  gated by the existing `axum` feature flag. Workspace-pinned to `0.5`,
  matching `axum 0.8`'s transitive tower major.

## [0.7.1] — 2026-05-08

Additive release that opens the Phase 11 OIDC Relying Party surface to
external consumers. No breaking changes; existing 0.7.0 imports continue
to compile unchanged. Trigger: `RFC_2026-05-15_oidc-rp-integration-phase-11`
(closed) and `RFC_2026-05-07_oidc-rp-phase-11x-rcw-ctw-migration` (RCW
migration depends on these symbols being on crates.io).

### Added

- **`oidc::RelyingParty<S>`** composition root with three lifecycle
  methods: `new`, `start`, `complete`, `refresh`. The phantom-typed
  scope marker `S: ScopePiiReader` (re-exported from `ppoppo-token::id_token::scopes`)
  carries a compile-time bound on which ID-token claims a given consumer
  is permitted to read. A consumer parameterised by `scopes::Profile` cannot
  call `claims.email()` even if `email` is present on the wire.
  - `RelyingParty::new` discovers the OP via `.well-known/openid-configuration`,
    instantiates a JWKS-backed `IdTokenVerifier`, and stores the
    user-provided `OAuthConfig` + `StateStore`.
  - `RelyingParty::start` performs PKCE, mints a `state` nonce, persists
    state via `StateStore`, and returns a `RequestedScope` carrying the
    authorization URL the consumer redirects to.
  - `RelyingParty::complete` exchanges code → tokens, verifies the ID
    token + at_hash + c_hash + nonce, and returns a `Completion<S>`
    that exposes `IdAssertion<S>` (the verified claims) + the access
    token. Verifies all OIDC required claims (iss/aud/exp/nbf/iat/azp).
  - `RelyingParty::refresh` performs RTR token exchange and re-verifies
    the new ID token (when present in the response).
- **`oidc::StateStore` port** — single-method async trait
  (`put`/`take_if_match`) covering CSRF state binding. In-memory
  implementation `InMemoryStateStore` available behind
  `feature = "test-support"`. Production consumers implement against
  KVRocks/Redis/PostgreSQL.
- **`oidc::discovery::{fetch_discovery, Discovery, DiscoveryError}`**  `.well-known/openid-configuration` fetcher with structured error
  variants (transport, JSON, schema mismatch). Used internally by
  `RelyingParty::new` and `PasIdTokenVerifier::from_jwks_url`; exposed
  for consumers that need raw discovery for sibling flows.
- **Error types**: `CallbackError`, `RefreshError`, `RelyingPartyInitError`,
  `StartError`. Each is `#[non_exhaustive]` and carries enough variant
  granularity for consumers to distinguish operator-actionable errors
  (network, OP misconfiguration) from user-actionable errors (CSRF
  mismatch, expired state).
- **`oidc::RequestedScope`** — return value of `RelyingParty::start`
  carrying the authorization URL plus the `state` token the consumer
  must persist for `complete` to validate.

### Feature gating

All new symbols are gated behind `feature = "well-known-fetch"`. The
`axum` feature transitively enables this, so consumers using
`features = ["axum"]` get the new surface automatically. Pure `oauth`
or `token` consumers who do *not* need RP flow are not pulled into
the wiremock-tested HTTP discovery path.

### Internal

- Discovery primitive consolidated: `RelyingParty::new` and
  `PasIdTokenVerifier::from_jwks_url` now share `fetch_discovery` so
  the well-known fetch happens once per `RelyingParty` instance, with
  the JWKS URL extracted from the same `Discovery` snapshot.
- 12 boundary tests added against `wiremock` covering: PKCE/state
  round-trip, ID token verification across all 5 OIDC scopes, at_hash
  / c_hash binding, nonce binding, RTR refresh path, expired-state
  rejection, malformed-discovery rejection, JWKS rotation, and the
  cross-scope phantom-type compile-fail family (M45-style — not
  runtime-tested but type-system enforced).

### Not changed

- 1st-party `BearerVerifier` / `PasJwtVerifier` / `MemoryBearerVerifier`
  surface: unchanged from 0.7.0.
- 1st-party `middleware` (Bearer-flow Axum middleware): unchanged.
- Session liveness (`session_liveness::*`) and `TokenCipher`: unchanged.
- Audit (`audit::*`): unchanged.

### Out of scope (explicitly deferred)

- **`BearerAuthLayer` SDK promotion** — the perimeter Layer that consumes
  `RelyingParty`-issued access tokens currently lives chat-auth-internal
  (in the ppoppo monorepo's `services/chat/crates/chat-auth/src/middleware.rs`).
  Promotion to `pas-external::oidc::axum::BearerAuthLayer` is gated on
  N=3 integrator evidence (chat-auth + RCW + CTW); planned for the 0.8.0
  release line per `RFC_2026-05-07_oidc-rp-phase-11x-rcw-ctw-migration`.
  RCW (the first external consumer of `RelyingParty<S>`) self-implements
  its perimeter Layer in 0.7.x — that implementation provides the second
  evidence point.

## [0.7.0] — 2026-05-15

Closes the JWT-adoption RFC (`RFC_2026-05-04_jwt-full-adoption`, Phase 10
arc — OIDC ID Token profile + engine module split + RP middleware +
discovery + session liveness consolidation). The single user-visible
break in this release is the removal of `UserInfo::session_version` from
the public surface — session liveness now flows exclusively through the
access_token's `sv` claim (Phase 10.13.B F6 single-SSOT). All other
0.7 changes are additive and architectural.

### Breaking

- **`UserInfo::session_version` field removed**. The 0.6 SDK exposed
  `session_version: Option<u64>` on the `UserInfo` struct (populated
  from the PAS `/userinfo` response's `session_version` field). 0.7
  removes both the field AND the
  `UserInfoBuilder::with_session_version` builder. Consumers that read
  `info.session_version` no longer compile.
  - **Why**: pre-0.7, `sv` lived in two places — the access_token's
    `sv` claim AND the userinfo response. Two SSOTs invite drift; F6
    audit (Phase 10.13.B) routed *every* SDK consumer (including the
    internal `middleware::sv` validator) through the access_token
    claim and removed the userinfo channel.
  - **Engine surface**: a new `pub(crate) token::jwt::peek_session_version`
    extracts `sv` from a TLS-trusted access_token without going through
    the engine's full verify (the validator is downstream of
    `PasJwtVerifier` so the bytes are already trusted by transport).
    External callers do not need this — the public `BearerVerifier`
    surface already exposes `AuthSession::session_version`.

- **`SvStep::UserInfo` collapsed**. The session-version state machine
  (`session_version::SvStep` enum) lost the `AwaitingUserinfo` variant
  along with its dependencies: `UserinfoFeed` (4 variants),
  `feed_userinfo`, `classify_userinfo`, and 3 `ExpiryCause` userinfo
  branches. Consumers that pattern-matched on the userinfo path
  (none expected — internal API) are affected.

### Improved

- **F6 single-SSOT for session liveness**. Post-0.7, `sv` exists in
  exactly one place at runtime: the access_token claim verified by
  `engine::check_epoch`. This is the only liveness-verification path.
  See [`STANDARDS_SESSION_LIVENESS.md`]0context/STANDARDS_SESSION_LIVENESS.md
  §S-L6 for the architectural invariant + the reasoning behind dropping
  the userinfo channel.
- **Phase 10.11 RP middleware live since 0.6.5+**:
  `pas_external::oidc::*` exposes `IdTokenVerifier` trait,
  `PasIdTokenVerifier<S>` production adapter, and `IdAssertion<S>`
  with phantom-typed scope narrowing. Consumers integrating
  Login-with-ppoppo via OIDC id_token consume this surface; type system
  prevents reading PII outside the granted scope (M72 structural
  enforcement). Same `Arc<dyn AuditSink>` instance can be passed to
  both `PasJwtVerifier::with_audit` AND `PasIdTokenVerifier::with_audit`
  for unified audit-pipeline wiring.
- **Phase 10.12 discovery + UserInfo canonical**: PAS now serves
  `/.well-known/openid-configuration` (OIDC Discovery 1.0); the
  discovery `claims_supported` is sourced from the engine's
  `EmailProfilePhoneAddress::names()` compile-time SSOT. UserInfo
  endpoint canonicalized to `/userinfo` (was `/oauth/userinfo`  pre-launch β2 rename, no backcompat shim). Scope-aware response
  narrowing per OIDC Core §5.4 hardened: 1st-party tokens skip the
  filter; oauth tokens require explicit `claims.scopes` grants
  (no `is_empty()` bypass).
- **Phase 10.13.A positioning**: `STANDARDS_AUTH_PPOPPO §1` invariant
  #1 verbatim "OpenID Provider (OP)" — PAS' primary self-positioning
  per OIDC Core 1.0. Discovery `issuer` field is the
  machine-readable actor identifier (audit log gains no separate
  `actor` field — α3 design call).

### Migration

Most consumers need **no code change**. The breaking removal of
`UserInfo::session_version` only affects SDK callers that explicitly
read the field — and the SDK's own internal `middleware::sv`
validator is the only known reader, which migrated in Phase 10.13.B.

```rust
// before — pas-external 0.6
let info = client.userinfo(&access_token).await?;
let sv = info.session_version;          // ← Option<u64>
if let Some(sv) = sv { ... }

// after — pas-external 0.7
//
// The userinfo response no longer carries session_version. Read sv
// from the access_token's claim instead; the SDK's BearerVerifier
// exposes it on AuthSession after verify:
let session = verifier.verify(&access_token).await?;
let sv = session.session_version();      // ← u64
```

If you operate the access_token verify yourself (rare — most consumers
use `PasJwtVerifier::from_jwks_url`), the engine claim is `sv` (u64).

The `axum` middleware path (RCW/CTW) requires only a Cargo.toml bump
to `pas-external = "0.7"` — the trait surface
(`AccountResolver` / `SessionStore` / `PasAuthPort`) is unchanged.

### Tests

- 154 `pas-external` tests green under
  `--features "test-support well-known-fetch axum"` (unchanged from
  0.6.5+ totals; the F6 cleanup deleted dead test branches but the
  surviving tests synthesize JWS-shaped tokens via
  `token_response_with_sv` helper to feed the new
  `peek_session_version` path).
- 237 `ppoppo-token` engine tests green (unchanged — engine surface
  did not move in 0.7).
- 157 `accounts-api` lib tests green (Phase 10.12 discovery suite +
  UserInfo handler + scope-narrowing tests included).

### Links

- RFC `RFC_2026-05-04_jwt-full-adoption` §6.11.1 closure ([CLOSED 2026-05-15])
- New standard prose: `STANDARDS_JWT_DETAILS_ID_TOKEN_PPOPPO.md`
  (Phase 10.14.A authored end-to-end OIDC id_token profile spec)
- F6 invariant: `STANDARDS_SESSION_LIVENESS.md` §S-L6 (single-SSOT)
- Discovery: `STANDARDS_WELL_KNOWN.md` + Phase 10.12 commit `35037322`
- Phase 10 commit chain: `a2f211f1` (10.0 D1 split) →
  `c4148d9e` (10.10 D2 emission half) → `25a0e60c` (10.11 RP middleware) →
  `35037322` (10.12 discovery + /userinfo) → `b4baba84` (10.13.A
  positioning) → `adf77f3a` (10.13.B F6 sv single-SSOT) → this release.

## [0.6.0] — 2026-05-05

Implements Phase 6.1 of `RFC_2026-05-04_jwt-full-adoption` (D-04 = γ
port-and-adapter, locked 2026-05-05). The SDK retires its bundled
PASETO verification logic and consumes the published JWT engine
(`ppoppo-token` 0.1.0, RFC 9068, EdDSA) through a γ-shaped
[`BearerVerifier`] port. External Developers now verify against a
*format-blind, swap-able* port; the engine is the only place that
knows JWT. PASETO is gone from the dependency tree —
`cargo tree -p pas-external | grep pasetors` returns empty.

### Breaking

- **Token format: PASETO v4.public → JWT (RFC 9068, EdDSA).** Tokens
  issued by PAS post-Phase-6 are JWTs; this SDK verifies them via
  `ppoppo-token::verify` under a TTL-cached JWKS. Re-fetch keys at
  deployment from `/.well-known/jwks.json` (replaces
  `/.well-known/paseto`).
- **SDK surface restructured.** The function-style verifier API
  (`verify_v4_public_access_token`, `verify_v4_with_keyset`,
  `parse_public_key_hex`, `extract_unverified_kid`, `PublicKey`,
  `VerifiedClaims`) is replaced by a port-and-adapter shape:
  - `BearerVerifier` async trait with single `verify(bearer_token)` method
  - `PasJwtVerifier::from_jwks_url(url, expectations)` production adapter
  - `AuthSession` opaque result with typed accessors (`ppnum_id`,
    `ppnum`, `session_id`, `session_version`, `expires_at`)
  - `Expectations { issuer, audience }` held at verifier construction
  - `VerifyError` enum with engine M-codes mapped to boundary variants
  Consumer middleware injects `Arc<dyn BearerVerifier>` instead of
  calling free functions.
- **`KeySet` removed from public surface.** The TTL JWKS fetcher is
  now `pub(crate)` (Finding 2 of the deep-module audit) — consumers
  reach it only through `PasJwtVerifier::from_jwks_url`. Two-step
  construction (build KeySet → pass to verifier) was a leaky
  abstraction; the single-step constructor hides the cache.
- **`WellKnownPasetoDocument` / `WellKnownPasetoKey` /
  `WellKnownKeyStatus` removed.** JWKS shape now lives in
  `ppoppo_token::Jwks` (RFC 7517). Consumers that hand-built keysets
  use `Jwks::from_ed25519_keys(&[...]).into_key_set()` (engine SSOT).
- **`axum` feature now requires `token`.** The middleware path
  consumes `ppoppo_token::SV_CACHE_TTL` and `sv_cache_key()` directly
  (Finding G — type-enforced shared-cache contract). Adopters
  enabling `axum` already had `token` in default features, so no
  Cargo.toml change is required.

### New

- **`BearerVerifier` port** (`feature = "token"`) — the contract
  consumer middleware injects.
- **`PasJwtVerifier` production adapter** (`feature = "well-known-fetch"`)
  — verifies PAS-issued JWTs against a TTL-cached JWKS.
- **`MemoryBearerVerifier` test-support adapter**
  (`feature = "test-support"`) — in-memory port impl for consumer
  integration tests. Insert + verify round-trip without a live PAS
  process.
- **First external consumer of `ppoppo-token` 0.1.0** — the engine
  is now reachable via `cargo add ppoppo-token` for adopters that
  want a different SDK shape (e.g., a non-Axum framework).

### Migration

```rust
// before — pas-external 0.5
use pas_external::{verify_v4_public_access_token, KeySet, VerifiedClaims};

let keyset = KeySet::fetch("https://accounts.ppoppo.com/.well-known/paseto").await?;
let claims: VerifiedClaims = keyset
    .verify(token, "accounts.ppoppo.com", "my-client-id")
    .await?;
println!("ppnum_id={}", claims.sub().unwrap_or(""));

// after — pas-external 0.6
use pas_external::{BearerVerifier, Expectations, PasJwtVerifier};

let verifier = PasJwtVerifier::from_jwks_url(
    "https://accounts.ppoppo.com/.well-known/jwks.json",
    Expectations::new("accounts.ppoppo.com", "my-client-id"),
)
.await?;
let session = verifier.verify(token).await?;
println!("ppnum_id={}", session.ppnum_id());
```

For Axum middleware consumers, the wiring path through `PasAuth` /
`SessionValidator` / `AccountResolver` / `SessionStore` is **unchanged**
— the middleware internally upgraded from PASETO to JWT, but the public
trait surface is identical.

### Deep-module audit findings (applied)

- **Finding 1**: `Expectations` moved to verifier construction; `verify`
  takes only `bearer_token`.
- **Finding 2**: `KeySet` hidden as `pub(crate)`; `from_jwks_url` is
  the single public entry point.
- **Finding 3**: One constructor (`from_jwks_url`) replaces the
  `new` + `with_config` builder anti-pattern.
- **Finding 4**: `AuthSession::into_inner` escape hatch is NOT shipped
  — pre-flight grep audit confirmed RCW + CTW middleware never
  accesses raw claims. All needed fields surface as typed accessors.
- **Finding G**: `sv_cache_key` + `SV_CACHE_TTL` re-exported from
  `ppoppo-token` (engine SSOT) — drift between PAS, PCS, and SDK is
  now a compile-time ripple, not silent.

## [0.5.0] — 2026-05-01

Implements RFC_2026-05-01 (`deepen-session-validation-pipeline`) step 3.
Public-API rename: the SDK now speaks "session validator" instead of
"sv-aware session resolver" — the latter exposed an implementation
pattern in the public name.

### Breaking

- **Type rename: `SvAwareSessionResolver``SessionValidator`.** Same
  shape (`<S, P, B = MemorySvBackend>`), same semantics. Migration is a
  global find-and-replace.
- **Method rename: `SessionValidator::resolve(&jar)``SessionValidator::validate(&jar)`.** The base `SessionResolver` keeps
  its `.resolve(&jar)` method — the rename clarifies the distinction
  between "look up a session" (base resolver) and "validate a session
  including sv enforcement + refresh" (validator).
- **Accessor rename: `PasAuth::resolver()``PasAuth::session_validator()`.** Likewise `resolver_with_backend(b)`
  `session_validator_with_backend(b)`.
- **File rename: `middleware/sv_aware.rs``middleware/validator.rs`.**
  Internal but visible in `cargo doc` source links and stack traces.
- **Test rename: `tests/sv_aware_refresh.rs``tests/session_validator_refresh.rs`.** Same 11 tests, unchanged.

No other behavior changes — this release is a pure rename. Behavioral
changes were front-loaded in 0.3.0 (cache fold) and 0.4.0 (driver fold).

### Migration

```rust
// before
use pas_external::middleware::SvAwareSessionResolver;
let resolver = pas_auth.resolver();
match resolver.resolve(&jar).await? { ... }

// after
use pas_external::middleware::SessionValidator;
let validator = pas_auth.session_validator();
match validator.validate(&jar).await? { ... }
```

Custom-backend consumers:

```rust
- let resolver = pas_auth.resolver_with_backend(MyRedisCache::new(client));
+ let validator = pas_auth.session_validator_with_backend(MyRedisCache::new(client));
```

## [0.4.0] — 2026-05-01

Implements RFC_2026-05-01 (`deepen-session-validation-pipeline`) step 2.
Internal cache surface is reduced to a single port; the strategy
wrapper that owned the spec contract folds into the SDK's sync state
machine driver.

### Breaking

- **`SvCacheBackend` trait renamed to `SvCachePort`.** Vocabulary
  alignment with the ports & adapters pattern the SDK now follows
  internally. Method shape unchanged (`load`, `store`). Migration is
  mechanical: `impl SvCacheBackend for MyCache` → `impl SvCachePort for
  MyCache`.
- **`SvCachePolicy<B>` removed from the public API.** It was a thin
  wrapper that owned the `sv:` namespace prefix and the spec-fixed 60 s
  TTL. Both concerns now live in the SDK-internal driver
  (`middleware::sv::adapter`); the port stays namespace- and
  TTL-agnostic. Consumers who only used `PasAuth::resolver()` /
  `PasAuth::resolver_with_backend(backend)` need no changes — the
  argument type changed from `SvCachePolicy<B>` to `B: SvCachePort`,
  but the `with_backend` call accepts the same value.
- **`SvAwareSessionResolver::new` takes `Arc<B>` instead of
  `SvCachePolicy<B>`.** Direct callers of the constructor (rare —
  primarily SDK boundary tests) wrap the backend in `Arc::new`
  themselves.
- **`CheckResult` no longer public.** It was only a return type of
  `SvCachePolicy::check`, which is now internal. Operators see the
  same `sv_cache_outcome` field on the resolver's tracing span,
  unchanged.

### Internal

- New module `middleware::sv` (`pub(super)`) hosts the synchronous
  `SvCore` state machine and the async driver loop introduced in step 1.
  Step 2 inlines the cache-strategy logic into the driver, so the
  former `SvCachePolicy::check` and `SvCachePolicy::record` are now
  visible as `classify_cache` and the `RecordCache` arm of the driver.
- `SV_CACHE_KEY_PREFIX` and `SV_CACHE_TTL` move from `pub(crate)` to
  `pub(super)`. They remain SDK-owned constants; sister crates
  (`ppoppo-token`, PCS chat-auth) hold their own private copy.

### Migration

```rust
// 90% case (default backend) — no changes needed.
let resolver = pas_auth.resolver();

// Custom backend (multi-pod) — rename the trait impl:
- impl SvCacheBackend for MyRedisCache { ... }
+ impl SvCachePort for MyRedisCache { ... }
let resolver = pas_auth.resolver_with_backend(MyRedisCache::new(client));
```

For SDK boundary tests calling `SvAwareSessionResolver::new` directly:

```rust
- let policy = SvCachePolicy::with_backend(backend);
- SvAwareSessionResolver::new(base, store, pas, policy, cipher)
+ SvAwareSessionResolver::new(base, store, pas, Arc::new(backend), cipher)
```

## [0.3.0] — 2026-05-01

Implements RFC_2026-05-01 (`fold-sv-cache-into-policy`). The cache
extension surface — previously split across the public
`SessionVersionCache` trait, `MemorySessionVersionCache`,
`SV_CACHE_KEY_PREFIX`, and `SV_CACHE_TTL` — is folded into a single
`SvCachePolicy` strategy. The previously-violated TTL invariant
(`SessionVersionCache::set` accepted a `Duration` that the in-memory
impl ignored) is now structurally enforced: the policy owns the
spec-fixed 60 s TTL and pushes it down to backends with native expiry
(Redis, KVRocks).

### Breaking

- **`session_version` module removed.** Replaced by
  `middleware::sv_cache`. The old top-level module path is gone; all
  cache types are re-exported only from `pas_external::middleware`.
- **`SessionVersionCache` trait removed.** Replaced by `SvCacheBackend`.
  Migration is mechanical:
  - `impl SessionVersionCache for MyCache` → `impl SvCacheBackend for MyCache`
  - Method renames: `get` → `load`, `set` → `store`
  - The `_ttl: Duration` parameter on `set` was previously ignored by
    in-memory impls; on `store` it must now be honored (Redis `SETEX`,
    KVRocks TTL, etc).
- **`MemorySessionVersionCache` renamed to `MemorySvBackend`.** The new
  name signals it's a substrate, not a protocol. Same Arc-internal
  shape, same 10 000-entry FIFO cap.
- **`SV_CACHE_KEY_PREFIX` and `SV_CACHE_TTL` are no longer public.**
  Consumers writing a custom backend never need them — the policy is
  the only public reader. Sister crates that share the namespace
  (`ppoppo-token`, PCS chat-auth) hold their own private const, same
  as today.
- **`SvAwareSessionResolver` generic count: `<S, C, P>` → `<S, P, B = MemorySvBackend>`.**
  The cache concern collapsed into a single defaulted backend. 90 % of
  consumers (single-pod, default cache) drop the `<C>` parameter from
  every type alias and never type the new `<B>`.
- **`PasAuth::resolver_with_cache(cache)` renamed to
  `resolver_with_backend(backend)`.** Same signature shape; takes
  anything implementing `SvCacheBackend` and wraps it in
  `SvCachePolicy::with_backend`.
- **`PasAuth::default_cache: Arc<MemorySessionVersionCache>` field
  renamed to `default_policy: SvCachePolicy<MemorySvBackend>`.**
  Internal field; affects only consumers reaching past the public
  API.

### Added

- `middleware::SvCachePolicy<B>` — strategy object owning the
  `sv:{ppnum_id}` namespace and the spec-fixed 60 s TTL. Single
  injection point for all cache concerns the resolver cares about.
- `middleware::SvCacheBackend` — narrow substrate trait (`load`,
  `store`). Honest about its TTL contract: backends with native expiry
  must apply the `Duration` the policy passes.
- `middleware::CheckResult` — three-arm result (`Fresh | Stale |
  Unknown`) now recorded on the resolver's tracing span as
  `sv_cache_outcome` so operators can distinguish "break-glass
  converged across pods" from "cold cache" without changing dispatch.
- `middleware::MemorySvBackend` — default per-pod in-memory backend.
  Now honors caller-provided TTL (regression-tested).

### Migration

For consumers using the default cache (most), the diff is two lines:

```rust
// before
use pas_external::MemorySessionVersionCache;
type MyResolver = SvAwareSessionResolver<MyStore, MemorySessionVersionCache, AuthClient>;

// after
type MyResolver = SvAwareSessionResolver<MyStore, AuthClient>;
```

For consumers with a custom Redis/KVRocks adapter, rename the trait
impl, drop the now-meaningful TTL ignore, and switch
`PasAuth::resolver_with_cache(...)` → `resolver_with_backend(...)`.

## [0.2.0] — 2026-05-01

Implements RFC_2026-05-01 (deepen S-L6 path through `pas_refresh`,
delete `RefreshTokenResolver`). The S-L6 sv-aware refresh path now
routes through the same `pas_refresh` deep core that S-L3 already
uses — decrypt happens once in the SDK for both paths, eliminating
the consumer-owned decrypt step.

### Breaking

- **`RefreshTokenResolver` trait removed.** Its single method folds
  into a new `SessionStore::get_refresh_ciphertext` returning
  `Option<EncryptedRefreshToken>` (the **encrypted** ciphertext, not
  plaintext). Consumers no longer decrypt; the SDK does it via
  `pas_refresh`. Migrate by deleting `impl RefreshTokenResolver for
  YourAdapter` and adding `get_refresh_ciphertext` to your existing
  `impl SessionStore for YourAdapter` block.
- **`pas_refresh` signature tightened** from `(cipher, port,
  ciphertext: &str)` to `(cipher, port, ct: &EncryptedRefreshToken)`.
  Plaintext-as-`&str` cannot accidentally flow into the SDK's decrypt
  site at any call site.
- **`attempt_liveness_refresh` signature tightened** identically:
  third argument is now `&EncryptedRefreshToken`. Wrap the stored
  column value via `EncryptedRefreshToken::from_stored(string)` before
  calling.
- **`SvAwareSessionResolver`: 4 generics → 3.** The `R:
  RefreshTokenResolver` parameter is gone. The public constructor
  gains a `cipher: Option<Arc<TokenCipher>>` parameter so the resolver
  can call `pas_refresh` directly. `None` is a soft misconfiguration
  (ciphertext present + no cipher = `Expired` with logged error).
- **`PasAuth`: 3 generics → 2.** The `R` parameter is removed.
  `PasAuth::new(...)` takes 3 arguments instead of 4 (drop the
  `refresh_resolver` argument).

### Migration

Per-consumer migration is mechanical and net-negative LOC:

1. `Cargo.toml`: bump `pas-external` to `"0.2"`.
2. Adapter file: delete `impl RefreshTokenResolver for ...` block
   (includes the consumer's `cipher.decrypt(...)` call).
3. Adapter file: add `get_refresh_ciphertext` method to existing
   `impl SessionStore for ...` block. Read the
   `refresh_token_ciphertext` column and wrap via
   `EncryptedRefreshToken::from_stored(...)`. **No decryption.**
4. Wiring code: drop the 4th argument from `PasAuth::new(...)`.
5. If a `TokenCipher` field on the adapter was held only to support
   the deleted `RefreshTokenResolver` impl, remove it. Keep the cipher
   passed to `PasAuthConfig::with_refresh_token_cipher`.
6. Test code that constructed `SvAwareSessionResolver` directly:
   the `new` signature is now `(base, store, pas, cache, cipher:
   Option<Arc<TokenCipher>>)` — pass `Some(Arc::new(cipher))` for
   real-refresh scenarios, `None` to exercise the misconfig path.

The SDK `tests/sv_aware_refresh.rs` ships 10 boundary tests covering
the S-L6 fail-CLOSED invariants (cipher failure / PAS 4xx / 5xx /
transport / userinfo session_version=None / userinfo 5xx / update_sv
DB failure / happy path / no ciphertext / no cipher configured).

## [0.1.0] — 2026-04-30

First public release after the pre-1.0 reset. Equivalent in scope to
the (yanked) `5.0.0` development line. Implements RFC_2026-04-30
(`PasAuthPort` port boundary at the PAS network seam) — see
`0context/STANDARDS_SESSION_LIVENESS.md` for the consumer-facing
contract.

### Breaking

- **`AuthClient::refresh_token` removed.** Use
  `<AuthClient as PasAuthPort>::refresh(&rt).await` — add
  `use pas_external::pas_port::PasAuthPort;` at the call site. Return
  type changes from `Result<TokenResponse, Error>` to
  `Result<TokenResponse, PasFailure>`.
- **`AuthClient::get_user_info` removed.** Use
  `<AuthClient as PasAuthPort>::userinfo(&at).await`. Same import;
  same return-type shift.
- **`classify_refresh_error` free function removed.** Its semantics
  live in `AuthClient::send_classified` (produces `PasFailure`) and
  the new `pas_refresh` deep core (translates `PasFailure` into
  `PasRefreshOutcome`). `PasFailure` is now the unified vocabulary for
  both the S-L3 fail-open path and the S-L6 fail-closed path.
- **`TransientCause::Transport` and `TransientCause::Unknown` removed.**
  Both are merged into `PasServerError`. Detail strings remain in the
  `detail` field; the cause-level distinction was a no-op for S-L3
  policy (every transient flavor serves cache).
- **`SvAwareSessionResolver<S, R, C>` is now
  `SvAwareSessionResolver<S, R, C, P>`.** The new `P: PasAuthPort`
  parameter allows test code to substitute `MemoryPasAuth`. Production
  callers of `PasAuth::resolver()` see no change — the default
  `P = AuthClient` is inferred. Test code that constructed
  `SvAwareSessionResolver` directly uses the new public
  `SvAwareSessionResolver::new(base, store, refresh_resolver, pas,
  cache)` constructor.

### Added

- **`pas_port` module**`PasAuthPort` trait (`refresh` / `userinfo`),
  `PasFailure` enum (`Rejected` / `ServerError` / `Transport`), and
  `pas_refresh` deep core (decrypt → call PAS → translate failure).
  Both the S-L3 liveness path and the S-L6 sv-enforcement path compose
  this primitive.
- **`MemoryPasAuth` test adapter** — scriptable in-process PAS stub
  (`expect_refresh` / `expect_userinfo`). Exposed under the new
  `test-support` Cargo feature; zero runtime cost when disabled.
- **`test-support` Cargo feature** — re-exports `MemoryPasAuth` for
  downstream consumer integration tests. Add
  `pas-external = { version = "0.1", features = ["test-support"] }` in
  `[dev-dependencies]`.
- **`SvAwareSessionResolver::new` is now `pub`** (was `pub(super)`) —
  required for test code that substitutes `MemoryPasAuth`.
- **`SessionResolver::new` is now `pub`** — same rationale.
- **`PasAuth<S, R, P = AuthClient>`**`P` default type parameter
  preserves source compatibility for all existing `PasAuth` use sites.

### Changed

- **`attempt_liveness_refresh` is now generic over `P: PasAuthPort`**
  (was concrete `&AuthClient`). Existing call sites compile unchanged;
  `&AuthClient` satisfies the bound.

### Migration

See `STANDARDS_SESSION_LIVENESS.md §10` (§ "v4.0.x → v0.1.0 consumer
migration checklist") for the full step-by-step. Also see the design
RFC: `0context/RFC_2026-04-30_deepen-pas-refresh-port.md`.

Production migration is typically **0 LOC** — `PasAuth::resolver()`
keeps inferring `P = AuthClient`. Test code touching the resolver
directly gets a one-line constructor update.

---

# Yanked versions (historical)

The entries below describe versions `1.0.1` through `4.0.2`, all yanked
from crates.io on 2026-04-30 as part of the pre-1.0 reset. They are
preserved for git archaeology only — new adopters do not need to read
them.

## [4.0.2] — 2026-04-30 (yanked)

### Fixed

- **(security) Fail-CLOSED when `update_sv` persistence fails after a
  refresh.** Previously, if the consumer's `SessionStore::update_sv`
  returned an error (DB outage, serialization conflict), the resolver
  logged a warning and *still admitted* the session — backed by the
  in-memory cache that had just been written with the new `sv`. That
  left the cache and the durable store divergent: subsequent requests
  on the same pod kept admitting based on the in-memory `sv`, while a
  pod restart or eviction reverted to the stale persisted `sv`. Multi-
  pod deployments could disagree indefinitely. Resolved by returning
  `SessionResolution::Expired` on `update_sv` error and *not* writing
  the cache, so the next request retries persistence.
- **(security) Fail-CLOSED when post-refresh userinfo returns
  `session_version=None` on a Human session.** A Human session reaches
  the refresh path only because `session.sv()` is `Some(_)`. If PAS then
  responds with `session_version=None`, that is anomalous (PAS regression,
  dev/prod skew, proxy mangling), not the documented AI-agent admit
  case. Previously the resolver admitted the **stale** session, silently
  bypassing the very check this module exists to perform. Now logs at
  `error` level and returns `Expired`.
- **`PasAuth::resolver()` now returns resolvers backed by a single
  shared `MemorySessionVersionCache`.** Previously each call constructed
  a new cache, so layered Axum setups that built resolvers per-route
  saw disjoint caches — a break-glass refresh on one cache wouldn't be
  visible to requests routed through another. The cache is now
  constructed once in `PasAuth::new` and shared via `Arc::clone`.
  `resolver_with_cache` is unchanged (callers continue to provide their
  own substrate).
- **`MemorySessionVersionCache` is now bounded at 10 000 entries.**
  Previously the in-memory cache had no cap and only evicted lazily on
  read. Long-lived consumer pods leaked one entry per unique
  `ppnum_id` ever resolved (~80 bytes each). On `set`, when the cache
  is at cap and the key is new, the cache first prunes expired entries,
  then evicts the single oldest entry (FIFO by write time). Under the
  fixed 60 s TTL, FIFO is effectively LRU — entries don't live long
  enough for hot/cold patterns to develop. Consumers needing larger
  caps should plug in their own substrate via `resolver_with_cache`.

### Documentation

- Fixed stale rustdoc references to removed types: `HttpUserInfoFetcher`
  in `oauth.rs`, the free function `validate_sv` in `token.rs`, and
  `SessionVersionFetcher` in `Cargo.toml` comments. All were superseded
  in 4.0.0 by `SvAwareSessionResolver`; doc-comments now point there.
- Updated the `middleware` Quick Start example to the 4-argument
  `PasAuth::new` signature (was: 3-argument, missing
  `refresh_resolver`).
- Updated the `SessionStore` impl example to include the
  `update_sv` method required since 4.0.0 (was: only `create` / `find` /
  `delete`).

### Backward compatibility

This is a **patch** release: no public API changes. The behavioral
changes (fail-closed on two anomalous paths, shared cache across
`resolver()` calls, bounded cache) all replace prior buggy behavior
with the documented contract. Consumers should observe **fewer**
spurious admissions during partial outages and a strict memory ceiling
on the in-memory cache; no consumer code changes are required.

### Tests

- `memory_cache_bounded_by_max_entries` — verifies cap enforcement
  after inserting `MAX_ENTRIES + 100` unique keys, plus that the
  most-recently-written entry survives FIFO eviction.

## [4.0.1] — 2026-04-30 (yanked)

### Fixed

- **`Ppnum` validation aligned with PAS DB CHECK constraint.** Previously
  `Ppnum::try_from` rejected any value not matching `len() == 11 &&
  starts_with("777")` — but the actual DB constraint is just
  `^[0-9]{11,}$`. The hardcoded `"777"` prefix and fixed `11`-digit
  length were never part of the contract: prefix is band-allocated
  (current canonical seed `100`, e.g. `123-1234-5678`) and length is
  variable (11 = independent, 15/19/... = dependent sub-agent
  hierarchy, +4 digits per nesting level). Production accounts with
  `100`-band ppnums or sub-agent ppnums could not authenticate via
  consumer apps using this SDK.
- **Constitution Principle III alignment.** Leading digits carry no
  semantic meaning — class is decided by `ppnums.entity_type` /
  `ppnums.number_class`, never by prefix. SDK no longer validates
  prefix.

### Backward compatibility

This is a **patch** release: every input that was accepted under
4.0.0 is still accepted under 4.0.1. The fix only widens the accepted
set (previously-rejected `100`-band and `15`-digit dependent ppnums
now pass), so no consumer code changes are required. SemVer-wise this
is a *patch* (bug fix to match the documented contract), not a *minor*
(new functionality).

### Tests

- `valid_ppnum_independent_11_digits` — 100/777 prefix, edge values
- `valid_ppnum_dependent_variable_length` — 15/19/23-digit sub-agents
- `invalid_ppnum_too_short``<11` digit rejection
- `invalid_ppnum_non_digits` — letters, hyphens (display form), spaces
- `invalid_ppnum_wrong_prefix` (removed — no longer applicable)

## [4.0.0] — 2026-04-25 (yanked)

### Breaking

- **`SessionStore::AuthContext` now requires the new `SvAware` trait.**
  Implementers must expose `ppnum_id() -> &str` and `sv() -> Option<i64>`
  on their auth context type. The bound is what lets the SDK enforce
  PASETO `sv`-claim invalidation per
  [`STANDARDS_AUTH_INVALIDATION §5`]https://github.com/hakchin/ppoppo.
- **`SessionStore` gains a fourth method, `update_sv(session_id, new_sv)`.**
  Called by `SvAwareSessionResolver` after a refresh that picks up a
  newer `sv` from PAS. Persist the updated value alongside the session
  row.
- **`PasAuth::new` takes a fourth argument: `refresh_resolver: Arc<R>`**
  where `R: RefreshTokenResolver`. The new trait is a small lookup that
  returns the **plaintext** PAS refresh token for a given session id —
  consumers typically read the encrypted ciphertext from their session
  table and decrypt with the same `TokenCipher` they passed to
  `PasAuthConfig::with_refresh_token_cipher`. See the trait docs for a
  complete example.
- **`PasAuth::resolver()` now returns `SvAwareSessionResolver` instead of
  the bare `SessionResolver`.** Consumers get sv enforcement
  *automatically* — no manual wrapping. The new resolver intercepts
  every authenticated request, compares the session's stored `sv` to
  a 60 s consumer-local cache, and refreshes via `/token` on miss /
  stale.
- **The free function `validate_sv` is removed.** Its API
  (per-request bearer token in hand) was incompatible with the
  cookie-session middleware pattern. Replacement: just use
  `PasAuth::resolver()` — sv enforcement is now built in.
  `SessionVersionCache`, `MemorySessionVersionCache`,
  `SV_CACHE_KEY_PREFIX`, and `SV_CACHE_TTL` are still exported for
  consumers that want to inject a custom cache substrate via
  `PasAuth::resolver_with_cache`.
- **`NewSession` gains a `sv: Option<i64>` field**, populated by the
  OAuth callback from `UserInfo::session_version`. Consumers must
  persist this alongside the session row so the resolver can
  enforce sv on subsequent requests. DEV_AUTH sessions and AI-agent
  tokens carry `None` and bypass sv enforcement (spec §4.2.1).

### Added

- `SvAware` supertrait — exposes `ppnum_id` + `sv` on the auth context.
- `RefreshTokenResolver` trait — consumer-provided plaintext refresh
  token lookup for the resolver's refresh-and-recheck path.
- `SvAwareSessionResolver<S, R, C>` — the new default resolver. Wraps
  the base `SessionResolver` with sv enforcement. Ships a cache-hit
  fast path (zero PAS round-trips) and a cache-miss / stale path
  (one `/token` + one `/userinfo` round-trip).
- `PasAuth::resolver_with_cache(cache)` — opt-in for consumers that
  want a shared cache substrate (Redis, KVRocks) so a break-glass on
  PAS converges across pods within network RTT instead of per-pod
  60 s TTL.

### Migration (3.1.0 → 4.0.0)

For each consumer (RCW, CTW, third-party):

1. Add a `sv BIGINT NULL` column to the session storage table
   (`scrcall.user_sessions` / `scctime.user_sessions` /
   equivalent). Existing rows get `NULL` and will be populated by
   the next refresh.
2. Add `pub sv: Option<i64>` to your session domain type and
   populate it in your `SessionStore::create` impl from
   `NewSession::sv`.
3. Add `pub ppnum_id: String` (or equivalent — must match PAS's
   `sub` claim ULID) to your `AuthContext` type if you didn't have
   it already.
4. `impl SvAware for AuthContext`.
5. Implement `SessionStore::update_sv` — straightforward UPDATE on
   the session row.
6. Implement `RefreshTokenResolver` for your adapter — typically a
   `find_by_id` + decrypt with the existing `TokenCipher`.
7. Update the `PasAuth::new(...)` call site to pass the new
   `Arc<RefreshResolver>`.
8. Remove any direct `validate_sv(...)` wrapping in your auth
   middleware — `PasAuth::resolver()` now handles it.

Per-consumer migration is ~80–100 LOC, mechanical. The compiler
guides every step via the new trait bounds.

## [3.1.0] — 2026-04-25 (yanked)

### Added

- **#005 break-glass `sv` claim support.** New `session_version` module
  (feature: `oauth`) ships the validator plumbing that PAS's break-glass
  recovery flow (spec 005) relies on downstream. Without this, a token
  stolen before a break-glass would remain valid for its full 1-hour TTL.
  - `SessionVersionCache` trait — abstracts the 60 s `sv:{ppnum_id}` cache
    so consumers can plug in KVRocks / Redis / in-memory.
  - `MemorySessionVersionCache` — default in-memory impl
    (`tokio::sync::RwLock<HashMap>`); suitable for single-pod consumers or
    consumers without a shared cache substrate.
  - `SessionVersionFetcher` trait — cache-miss source.
    `HttpUserInfoFetcher` is the default impl; calls
    [`AuthClient::get_user_info`] and reads the new
    `UserInfo.session_version` field.
  - `validate_sv(token_sv, ppnum_id, bearer_token, cache, fetcher)` — the
    entry point consumers wrap around their existing
    `verify_v4_public_access_token` call. Legacy tokens (no `sv` claim)
    admit unconditionally (R6 backwards-compat); stale tokens return
    `ValidateSvError::Stale`; fetch failure on cache miss returns
    `ValidateSvError::Transient` (fail-closed — consumers should reject
    so a DB/network blip can't silently admit a revoked token).
- `VerifiedClaims::session_version()` and `VerifiedClaims::magic_link_id()`
  getters on the existing `token` module (feature: `token`). The `mlt`
  claim is PAS-internal, exposed for SDK-consumer introspection /
  audit/debug purposes only.
- `UserInfo.session_version: Option<i64>` — populated by PAS for
  Human-entity tokens; `None` for AI agents.

### Changed

- The `oauth` feature now transitively pulls in `tokio` (sync) and
  `async-trait` because the new `session_version` module needs them.
  Consumers that already enable `oauth` see no change in their feature
  graph; consumers that enable only `token` are unaffected.

### Dependencies

- Added optional `async-trait = "0.1"`, gated on the `oauth` feature.
- `tokio` (previously only used by `well-known-fetch`) is now also used
  by `oauth` via `session_version`.

### Compatibility

Additive, no breaking changes. `UserInfo` already used `#[non_exhaustive]`
so adding `session_version` is SemVer-minor-safe; the field's
`#[serde(default)]` means decoding older userinfo responses (or responses
from PAS instances pre-#005) continues to work.

## [3.0.0] and earlier (yanked)

See git log.