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
# STANDARDS — pas-external session-liveness contract

**Audience**: Consumers of `pas-external` who persist PAS `refresh_token`s
server-side (CTW, RCW, future Leptos fullstack apps).
**Last updated**: 2026-05-05 (pas-external v0.6.0 — JWT/RFC 9068 era)

> **Token format**: PAS access tokens are JWTs (RFC 9068, EdDSA) verified
> through `ppoppo-token` via the γ port [`BearerVerifier`]. The S-L
> invariants below are token-format-agnostic — refresh_token is opaque
> regardless, sv survived the migration. See §10 v0.6.0 for the surface
> rename catalog (`KeySet``PasJwtVerifier`, etc.).

This document is the **SSOT for the session-liveness design**. Consumer
standards docs (`STANDARDS_AUTH_CLASSYTIME.md`, `STANDARDS_AUTH_ROLLCALL.md`)
reference this file for the shared invariants and describe only the
consumer-specific delta (schema column names, service bucket, cleanup
interval).

## 1. Problem

A consumer that holds PAS `refresh_token` for its users must answer two
questions every time a session is touched:

1. **Is this session still alive?** — PAS may have revoked the token
   server-side (user logged out elsewhere, admin revoke, etc.).
2. **Is this session ours to trust?** — if the database was leaked, can an
   attacker walk away with renewable credentials?

PAS is the **single source of truth** for (1). Independent TTLs ("session
expires 7 days after creation") *look* conservative but actually mask
revocation — a user who logs out on another device is still logged in on this
one until the TTL fires.

For (2), any plaintext `refresh_token` column is a credential store: a one-off
DB dump hands attackers every active user's PAS token. The SDK prevents
plaintext from ever crossing the SDK→consumer boundary at the type level on
both write and read paths (see §3 S-L1).

## 2. Design

| Concern | SDK layer | Consumer layer |
|---------|-----------|----------------|
| AES-256-GCM at-rest encryption | [`TokenCipher`] + middleware encrypts before `SessionStore::create` | configures `REFRESH_TOKEN_KEY`, supplies cipher to `PasAuthConfig::with_refresh_token_cipher` |
| Newtype enforcement of "ciphertext only" | [`EncryptedRefreshToken`] the only shape `SessionStore::create` ever sees | calls `.into_inner()` to obtain the persistable `String` |
| PAS liveness round-trip | [`attempt_liveness_refresh`] | decides when to call it (stale-check gate) |
| Revoked vs transient classification | [`AuthClient::send_classified`] → [`PasFailure`] consumed by [`attempt_liveness_refresh`] | decides what to do with the outcome |
| Trusted-proxy XFF walking | `PasAuthConfig::with_xff_trusted_proxies(n)` | knows its proxy topology |
| Loopback-only DEV_AUTH guard | `PasAuthConfig::from_env` refuses non-loopback redirect_uri when DEV_AUTH=1 | does not bypass via direct builder calls in prod |
| Session persistence (`last_verified_at`, `revoked_at`, ciphertext column) || owns schema + repository |
| `AuthContext` construction || owns domain model |

The SDK ships primitives, not middleware-for-everything. Each consumer
implements its own `SessionStore::find` using the primitives; this keeps
schemas and domain models from leaking into the SDK.

## 3. The six invariants

Consumers that enable the `axum` feature (which transitively enables
`session-liveness` and `token`) and wire `attempt_liveness_refresh` into
their `SessionStore::find` **must** satisfy all six. S-L6 enforcement is
built into `SessionValidator`, returned by `PasAuth::session_validator()`
(renamed from `PasAuth::resolver()` / `SvAwareSessionResolver` in v0.5.0).
Each invariant corresponds to a consumer-side check that auditors can
verify without reading SDK source.

### S-L1 — `refresh_token` is encrypted at rest (AES-256-GCM), enforced by type

The stored column holds the output of [`TokenCipher::encrypt`] — a
base64-encoded `nonce[12] || ciphertext+tag`. Plaintext storage, logging, and
echoing in error messages are forbidden.

**Type-level enforcement:** Both directions of the SDK→consumer boundary
use [`EncryptedRefreshToken`] — plaintext never crosses on either path:

- **Write path**: `NewSession.refresh_token` is
  `Option<EncryptedRefreshToken>`. The SDK middleware encrypts the
  plaintext PAS response *inside the OAuth callback* using the cipher
  supplied to [`PasAuthConfig::with_refresh_token_cipher`].
- **Read path**: `SessionStore::get_refresh_ciphertext` returns
  `Option<EncryptedRefreshToken>`. Consumers wrap the stored column value
  via [`EncryptedRefreshToken::from_stored`] and **do not decrypt** — the
  SDK owns decrypt for both S-L3 and S-L6 paths via [`pas_refresh`].

A consumer that forgets to call `with_refresh_token_cipher` ends up with
`refresh_token = None` on the write path (no liveness checks possible)
and `cipher = None` on the read path (any stored ciphertext fails closed
to `Expired` with a logged misconfiguration error) — failing closed
instead of leaking plaintext.

**Verification**: grep the consumer's repository for the column name —
`SessionStore::create` should call `.into_inner()` on the
`EncryptedRefreshToken` and store the resulting `String` directly.
`SessionStore::get_refresh_ciphertext` should read the column and wrap
the value via `EncryptedRefreshToken::from_stored(...)`. **No `encrypt()`
or `decrypt()` call should appear in consumer code** (the SDK does both).
The schema `COMMENT` line names the column purpose and the env var
(typical: `REFRESH_TOKEN_KEY`) so DB tooling surfaces it.

### S-L2 — PAS is the liveness SSOT

The consumer does not independently decide "this session is valid." There is
no local absolute TTL (`expires_at`). Validity is exactly "PAS accepted this
refresh_token within the last `interval` seconds OR the session has not yet
aged past the interval since creation."

**Verification**: `SessionStore::find` must gate on
`session.needs_liveness_check(interval)` before serving the cached auth
context. No branch returns `Some(auth_ctx)` on a stale session without
calling [`attempt_liveness_refresh`].

### S-L3 — transient failures do not force logout

When [`attempt_liveness_refresh`] returns `LivenessFailure::Transient`, the
consumer **serves the cached session**. A network blip, PAS 5xx, or timeout
must not cascade into a fleet-wide re-auth storm.

Only `LivenessFailure::Revoked` (PAS 4xx, or cipher failure) calls
`mark_revoked` and drops the session.

The HTTP-status → outcome mapping lives inside the SDK now:
[`AuthClient::send_classified`] reads the status once and produces a
[`PasFailure`] (`Rejected` / `ServerError` / `Transport`). The
`pas_refresh` deep core then translates that into `Refreshed` /
`Rejected` / `Transient`. Consumers do not see HTTP status codes —
`LivenessFailure` is the SSOT.

Cipher decrypt likewise happens inside [`pas_refresh`] (v0.2.0+);
consumers pass the wrapped ciphertext, never plaintext.

[`attempt_liveness_refresh`] takes `&EncryptedRefreshToken` (newtype) —
consumers no longer pass a plaintext or ciphertext `String`. Wrap the
stored column value via [`EncryptedRefreshToken::from_stored`] before
calling.

**Verification**: unit-test the consumer-side branch on the boundary
trait — drive [`attempt_liveness_refresh`] with a `MemoryPasAuth`
scripted to return `PasFailure::ServerError { status: 503, .. }` and
assert the outcome is `Transient`; `PasFailure::Rejected { status: 400,
.. }` must yield `Revoked { PasRejected }`. The SDK's
`tests/liveness_boundary.rs` covers this; consumer tests are a
belt-and-suspenders against accidental inversion. The `test-support`
feature (v0.1.0+) re-exports `MemoryPasAuth` for downstream integration
tests.

### S-L4 — `ciphertext IS NULL` sessions skip liveness

DEV_AUTH dev-login and any future non-OAuth session path stores
`refresh_token_ciphertext = None`. `SessionStore::find` must gate the
liveness branch on `ciphertext.is_some()` so dev sessions take the
activity-touch path and are harvested by normal dead-row cleanup once they
age past the cutoff.

**Verification**: the `needs_liveness` predicate in `find()` reads
something like:

```rust
let needs_liveness = session.refresh_token_ciphertext.is_some()
    && session.needs_liveness_check(self.liveness.interval);
```

### S-L5 — rotated ciphertext must be persisted (or the session goes silent)

When [`attempt_liveness_refresh`] returns
`LivenessOutcome::Fresh { rotated_ciphertext: Some(ct) }`, the consumer
**must** persist `ct` (atomically with `last_verified_at = now`) before
serving the request. If the consumer only persists `last_verified_at` and
drops `ct`, the next liveness check decrypts a stale token, PAS responds
4xx, the SDK classifies as `Revoked { PasRejected }`, and a legitimate user
is silently force-logged-out.

OAuth public clients (RCW, CTW): PAS does not currently apply Refresh Token
Rotation (RTR), so `rotated_ciphertext` is almost always `None`. This
invariant remains in force as a forward-compatibility contract — the SDK
exposes the rotated branch precisely because the SDK should not assume the
PAS RTR policy for OAuth clients is permanent.

**Verification**: in `SessionStore::find`, the `Fresh` arm must pattern-match
on `rotated_ciphertext` and pass `Option<&str>` (or `Option<String>`) to the
`touch_verified` repository call. The repository's UPDATE must conditionally
overwrite the ciphertext column when the value is `Some`. A `let _ =
rotated_ciphertext;` is a code-review red flag.

### S-L6 — `session_version` (sv) is enforced via the SDK validator, not the consumer

JWT access tokens (RFC 9068) issued by PAS carry an `sv` (session_version)
claim. PAS increments `sv` on break-glass recovery (spec #005); a token
whose `sv` is below the current PAS value must be rejected even if its
1-hour TTL has not expired. Without this enforcement, a token stolen
*before* break-glass remains usable for its full TTL — defeating the
recovery flow.

The `sv` claim is preserved across the PASETO→JWT migration (Phase 6.1,
v0.6.0): the `ppoppo-token` engine surfaces it as `Claims::session_version`,
and the γ port `BearerVerifier::verify` returns it via
`AuthSession::session_version()`. The enforcement *mechanism* lives in the
SDK middleware and is unchanged by the format swap.

**Type-level enforcement:** `PasAuth::session_validator()` returns
`SessionValidator`, which intercepts every authenticated request and
gates it on the consumer's auth-context `sv()` matching the PAS-side cached
value (60 s consumer-local cache, miss → `/token` round-trip, then trust-
extract `sv` from the just-issued access_token via the engine claim, then
`SessionStore::update_sv`). The post-Phase-10.13.B path collapses the
prior intermediate `/userinfo` call: the access_token's `sv` claim is the
single SSOT (engine `check_epoch` is the verification mechanism on every
subsequent request), so a parallel `UserInfo::session_version` channel
would be unverified duplicate state. The deprecated free function
`validate_sv` was removed pre-1.0 (its per-request-bearer-token shape was
incompatible with the cookie-session middleware pattern).

The consumer's `AuthContext` type **must** implement the `SvAware`
supertrait (`ppnum_id() -> &str`, `sv() -> Option<i64>`). DEV_AUTH sessions
and AI-agent tokens carry `sv = None` and bypass enforcement (spec §4.2.1)
— the validator short-circuits when either side is `None`.

**Verification**:

1. Schema includes `sv BIGINT NULL` on the session row.
2. `NewSession.sv` is wired through `SessionStore::create`, populated by
   the SDK callback handler via `token::jwt::peek_session_version` on the
   freshly-issued access_token (Phase 10.13.B; was `UserInfo::session_version`
   prior).
3. `SessionStore::update_sv(session_id, new_sv)` performs the obvious
   UPDATE — called by the validator after a refresh that raised `sv`.
4. `SessionStore::get_refresh_ciphertext(session_id)` is implemented:
   load the session row, return the stored ciphertext column wrapped via
   [`EncryptedRefreshToken::from_stored`]. **No decrypt in consumer
   code** — the SDK owns decrypt via [`pas_refresh`] v0.2.0+.
5. `AuthContext: SvAware` compiles — the trait bound is what unlocks
   `PasAuth::session_validator()`.
6. No call site retains a hand-rolled `validate_sv(...)` wrapper around
   the auth middleware. The validator does it.
7. No `impl RefreshTokenResolver for ...` block remains in the consumer
   (trait removed pre-1.0).

Multi-pod consumers may inject a shared cache substrate (Redis, KVRocks)
via `PasAuth::session_validator_with_backend(backend)` so a break-glass
converges across pods within network RTT instead of per-pod 60 s TTL. The
injected type implements `SvCachePort` (renamed from `SvCacheBackend` in
v0.4.0); key namespace and TTL are owned by the SDK-internal driver
(`middleware::sv::adapter`) — re-exported from `ppoppo-token` (engine
SSOT) so PAS / PCS / SDK drift becomes a compile-time ripple.
`MemorySvBackend` is the default for single-pod deployments.

## 4. Enabling the feature

```toml
# In your workspace Cargo.toml (pas-external 0.7.x):
pas-external = { version = "0.7", features = ["axum"] }
# `axum` transitively enables `session-liveness` + `token` — TokenCipher,
# EncryptedRefreshToken, and the BearerVerifier port are always in scope
# when you use the middleware. The default `oauth` feature pulls in
# `tokio::sync` + `async-trait` for the sv-validator driver
# (`middleware::sv` module).
```

The `axum` feature pulls in `aes-gcm` + `base64` + `tokio sync` + the
JWT engine (`ppoppo-token`) transitively. Consumers compile an additional
~800 KB of dependencies. Not enabled by default; the
`default = ["oauth", "token"]` profile is for clients that only verify
access tokens locally via `BearerVerifier`.

For pure JWT verification without middleware, prefer the
`well-known-fetch` feature plus [`PasJwtVerifier::from_jwks_url`] over
hand-rolling the JWKS fetch + cache + rotation logic. (`KeySet` is
`pub(crate)` since v0.6.0 — Finding 2 of the deep-module audit. The
single-step constructor hides the cache.)

## 5. Required consumer schema

Exact column names are up to the consumer; the SDK does not introspect the
DB. The *shape* is invariant:

| Column | Purpose | Notes |
|--------|---------|-------|
| `refresh_token_ciphertext TEXT NULL` | encrypted PAS refresh_token | NULL only for DEV_AUTH sessions (S-L4) |
| `last_verified_at TIMESTAMPTZ NOT NULL` | last successful PAS liveness confirmation | drives `needs_liveness_check(interval)` and dead-row cleanup |
| `revoked_at TIMESTAMPTZ NULL` | NULL = live | set on PAS `invalid_grant` or explicit logout; cleanup hard-deletes |
| `sv BIGINT NULL` | PAS session_version (S-L6) | trust-extracted from the access_token `sv` claim on create (Phase 10.13.B); updated by `SessionStore::update_sv` after a refresh raises sv. NULL for DEV_AUTH and AI-agent sessions (bypass enforcement) |

Notably absent: **no `expires_at` column**. If the consumer has one, S-L2 is
violated.

## 6. Required env vars

| Name | Type | Required | Notes |
|------|------|----------|-------|
| `PAS_CLIENT_ID` | string | yes | OAuth2 client_id assigned by PAS |
| `PAS_REDIRECT_URI` | URL | yes | Must be loopback (`http://localhost/...` etc.) when DEV_AUTH=1 |
| `COOKIE_KEY` | base64-encoded ≥64 bytes | yes (when secure cookies are on) | `from_env()` refuses to start without it. Generate with `openssl rand -base64 64`. |
| `REFRESH_TOKEN_KEY` | base64-encoded 32 bytes | yes | validate format at startup (not per-request); supply to `PasAuthConfig::with_refresh_token_cipher` |
| `SESSION_LIVENESS_INTERVAL_SECS` | u64 | no (default 900 = 15 min) | trade-off: lower = faster revocation, higher PAS load |
| `DEV_AUTH` | `1` / `true` | no | `from_env()` refuses if PAS_REDIRECT_URI is non-loopback |

**Key rotation** is a policy decision, not an SDK feature. The two supported
shapes are (a) "re-auth everyone" on rotation (zero code, loses all active
sessions), or (b) dual-key window owned by the consumer (decrypt-with-either,
encrypt-with-new). The SDK ships no dual-key helper because the right cutoff
is policy-specific.

## 7. Interaction sequence

```
request lands
  │
  ▼
SessionStore::find(session_id)
  │
  ├── repo.find_by_id(id)
  │       └── Some(session)   // else Ok(None)
  │
  ├── session.is_revoked()? ────► Yes → Ok(None)
  │
  ├── needs_liveness = ciphertext.is_some()
  │                    && needs_liveness_check(interval)         (S-L4)
  │
  ├── needs_liveness? ──► No → spawn(touch_used), fall through
  │
  │                      Yes
  │                        │
  │                        ▼
  │                    attempt_liveness_refresh(cipher, client, &EncryptedRefreshToken)
  │                        │
  │                        ├── Fresh { rotated_ciphertext }       (S-L5)
  │                        │     └── touch_verified(id, now, rotated_ciphertext)
  │                        │            Ok → fall through
  │                        │            Err → downgrade to Transient (serve cache)
  │                        │
  │                        ├── Revoked
  │                        │     └── mark_revoked(id, now); return Ok(None)
  │                        │
  │                        └── Transient { retry_after }          (S-L3)
  │                              └── log warn, fall through (serve cache)
  │
  ▼
build AuthContext from session.user_id + repo lookups
return Ok(Some(ctx))
```

## 8. Login path (OAuth callback) — what the SDK does for you

```
GET /api/auth/callback
  │
  ├── exchange_code(code, code_verifier)  →  TokenResponse { access_token, refresh_token, ... }
  │
  ├── userinfo(access_token)              →  UserInfo { sub, ppnum, ... }
  │
  ├── account_resolver.resolve(ppnum_id, ppnum)  →  UserId
  │
  ├── ENCRYPT refresh_token  ← (only if `with_refresh_token_cipher` was set)
  │       │
  │       ├── Ok(ct) → NewSession { refresh_token: Some(EncryptedRefreshToken(ct)), ... }
  │       └── Err(e) → log error, redirect with error=refresh_token_encryption_failed
  │
  ├── extract_client_ip(headers, xff_trusted_proxies)  → walks XFF skipping trusted hops
  │
  └── session_store.create(NewSession)  →  SessionId
```

Consumers cannot accidentally store plaintext: the only shape they receive
is `EncryptedRefreshToken`.

## 9. Testing guidance

- **Classifier**: drive [`attempt_liveness_refresh`] with `MemoryPasAuth`
  scripted to return each [`PasFailure`] variant
  (`Rejected{400|401|403}`, `ServerError{500|503}`, `Transport`) and
  assert the resulting `LivenessFailure`. All `Rejected``Revoked`;
  every `ServerError` / `Transport``Transient`. The SDK's own
  `tests/liveness_boundary.rs` covers this; consumer tests are a
  belt-and-suspenders against accidental inversion. Enable the
  `test-support` feature in dev-dependencies to access `MemoryPasAuth`.

- **Cipher**: roundtrip + tamper. Already covered by SDK tests; consumers
  need not duplicate unless they compose the cipher into their domain error
  type (test the conversion).

- **`SessionStore::find` integration**: mock the PAS server (e.g.,
  `wiremock`) to return 400 / 503 / 200 and assert the session row's
  `revoked_at` / `last_verified_at` moved accordingly.

- **XFF skip**: assert that with the deployment's expected proxy chain
  (e.g. `client, gfe, lb`), `extract_client_ip(headers, n)` returns the
  client IP, not the proxy. The SDK ships unit tests for the walk; consumer
  tests confirm the chosen `n` matches the actual prod topology.

## 10. Migration history

### v0.6.0 (2026-05-05) — Token format: PASETO v4.public → JWT (RFC 9068, EdDSA)

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) through a γ-shaped [`BearerVerifier`] port. **Every S-L invariant in
this document is unaffected** — the format swap lives below the port,
above the policy.

1. **Token format**: PASETO v4.public → JWT (RFC 9068, EdDSA). PAS
   re-fetches keys from `/.well-known/jwks.json` (replaces
   `/.well-known/paseto`).
2. **Verifier surface restructured**: function-style API
   (`verify_v4_public_access_token`, `verify_v4_with_keyset`,
   `extract_unverified_kid`, `parse_public_key_hex`, `PublicKey`,
   `VerifiedClaims`) replaced by port-and-adapter shape:
   - [`BearerVerifier`] async trait — 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
3. **`KeySet` removed from public surface** (now `pub(crate)`).
   `PasJwtVerifier::from_jwks_url` is the single entry point.
4. **`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).
5. **PASETO is gone from the dependency tree**   `cargo tree -p pas-external | grep pasetors` returns empty.

### v0.5.0 → v0.6.0 consumer migration checklist

In `Cargo.toml`:
- [ ] Bump `pas-external` to `"0.6"`.

In bare token-verification call sites (consumers using `BearerVerifier`
directly, not via Axum middleware):
- [ ] Replace `KeySet::fetch(paseto_url)` + `keyset.verify(token, iss, aud)`
      with `PasJwtVerifier::from_jwks_url(jwks_url, Expectations::new(iss, aud))`
      + `verifier.verify(token)`.
- [ ] Update well-known URL: `/.well-known/paseto``/.well-known/jwks.json`.
- [ ] If you read raw claims fields, switch to `AuthSession` typed accessors
      (`ppnum_id()`, `ppnum()`, `session_id()`, `session_version()`,
      `expires_at()`). No `into_inner()` escape hatch — pre-flight grep audit
      confirmed RCW + CTW middleware never accessed raw claims.

In Axum middleware wiring (`PasAuth` chain): **0 LOC changes**. The public
trait surface (`SessionStore`, `AccountResolver`, `SvAware`) is identical;
the JWT swap is below the port.

### v0.5.0 (2026-05-01) — Type rename: `SvAwareSessionResolver``SessionValidator`

Public-API rename only — the SDK now speaks "session validator" instead of
"sv-aware session resolver" (the latter exposed an implementation pattern
in the public name). Same shape, same semantics.

1. `SvAwareSessionResolver``SessionValidator`. Same generics
   (`<S, P, B = MemorySvBackend>`).
2. `SessionValidator::resolve(&jar)``SessionValidator::validate(&jar)`.
   The base `SessionResolver` keeps `.resolve(&jar)` — the rename
   distinguishes "look up" (resolver) from "look up + sv enforce + refresh"
   (validator).
3. `PasAuth::resolver()``PasAuth::session_validator()`. Likewise
   `resolver_with_backend(b)``session_validator_with_backend(b)`.

Migration is global find-and-replace; no behavioral change.

### v0.4.0 (2026-05-01) — `SvCacheBackend``SvCachePort`; cache strategy folds into driver

Internal cache surface reduced to a single port; the strategy wrapper
(`SvCachePolicy`) folds into the SDK's sync state machine driver.

1. `SvCacheBackend` trait renamed to `SvCachePort` (vocabulary alignment
   with the ports & adapters pattern). Method shape unchanged
   (`load`, `store`).
2. `SvCachePolicy<B>` removed from public API. The `sv:` namespace prefix
   and 60 s TTL now live in SDK-internal driver
   (`middleware::sv::adapter`); the port stays namespace- and
   TTL-agnostic.
3. `SvAwareSessionResolver::new` takes `Arc<B>` instead of
   `SvCachePolicy<B>`. Direct callers (rare — primarily SDK boundary
   tests) wrap via `Arc::new`.
4. `CheckResult` no longer public (was only the return type of
   `SvCachePolicy::check`, now internal). The `sv_cache_outcome` tracing
   field is unchanged.

90 % of consumers (single-pod, default backend) need 0 LOC changes —
`PasAuth::resolver()` stays inferrring `B = MemorySvBackend`.

### v0.3.0 (2026-05-01) — `session_version` module folded into `middleware::sv_cache`

Cache extension surface — previously split across the public
`SessionVersionCache` trait, `MemorySessionVersionCache`,
`SV_CACHE_KEY_PREFIX`, and `SV_CACHE_TTL` — 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.

1. `session_version` module **removed**. Replaced by
   `middleware::sv_cache`.
2. `SessionVersionCache` trait → `SvCacheBackend`.
   `get``load`, `set``store`. The `_ttl: Duration` parameter that
   in-memory impls silently ignored on `set` must now be honored on
   `store` (Redis `SETEX`, KVRocks TTL).
3. `MemorySessionVersionCache``MemorySvBackend`. Same Arc-internal
   shape, same 10 000-entry FIFO cap.
4. `SV_CACHE_KEY_PREFIX` and `SV_CACHE_TTL` no longer public. Consumers
   writing custom backends never need them — the policy is the only
   public reader.
5. `SvAwareSessionResolver` generic count:
   `<S, C, P>``<S, P, B = MemorySvBackend>`. Cache concern collapsed
   into a single defaulted backend.
6. `PasAuth::resolver_with_cache(cache)`   `resolver_with_backend(backend)`.

### v0.2.0 (2026-05-01) — Read-path plaintext seam closed; `RefreshTokenResolver` deleted

Breaking changes from v0.1.0. The S-L6 sv-aware refresh path now
routes through the same [`pas_refresh`] deep core that S-L3 already
uses, eliminating the consumer-owned decrypt step. The
`RefreshTokenResolver` trait is removed; its single method folds into
`SessionStore::get_refresh_ciphertext` returning
`Option<EncryptedRefreshToken>`. **No schema change. No env-var
change.** Migration is mechanical and net-negative LOC for consumers.

1. `RefreshTokenResolver` is **removed** from the public surface.
   Its only method (`resolve_refresh_token` returning plaintext) was
   the last place plaintext crossed the SDK→consumer boundary on the
   read path. The replacement
   [`SessionStore::get_refresh_ciphertext`] returns the at-rest
   ciphertext as `Option<EncryptedRefreshToken>`; consumers wrap the
   stored column via [`EncryptedRefreshToken::from_stored`] and never
   decrypt.
2. [`pas_refresh`] now takes `&EncryptedRefreshToken` (was
   `ciphertext: &str`). The newtype is the only shape ciphertext
   travels into the SDK's decrypt site, so plaintext-as-`&str`
   cannot accidentally be passed at any call site. Same change for
   [`attempt_liveness_refresh`].
3. [`SvAwareSessionResolver`] drops the `R` generic parameter
   (4 generics → 3: `S, C, P`). Its public constructor now takes
   `cipher: Option<Arc<TokenCipher>>` so the SDK can call
   `pas_refresh` internally. `None` is a soft misconfiguration:
   ciphertext present + no cipher = `Expired` with a logged error.
4. [`PasAuth::new`] drops the 4th argument (`refresh_resolver`).
   Constructor signature: `(config, account_resolver, session_store)`.
   The `R` generic on `PasAuth` is removed (3 generics → 2: `S, P`).
5. Consumer-trait surface: 4 traits (`SessionStore`,
   `AccountResolver`, `RefreshTokenResolver`, `SvAware`) → 3
   (`SessionStore`, `AccountResolver`, `SvAware`). RCW/CTW
   migration is `delete impl RefreshTokenResolver` (≈10 LOC) +
   `add get_refresh_ciphertext to impl SessionStore` (≈5 LOC) +
   drop the 4th arg from `PasAuth::new` call site.

### v0.1.0 → v0.2.0 consumer migration checklist

In `Cargo.toml`:
- [ ] Bump `pas-external` to `"0.2"`.

In adapters (typically `pas_adapter.rs`):
- [ ] Delete the `impl RefreshTokenResolver for ...` block entirely.
      (Includes any `cipher.decrypt(...)` call inside.)
- [ ] Add `get_refresh_ciphertext` method to your existing
      `impl SessionStore for ...` block. Read the
      `refresh_token_ciphertext` column and wrap via
      `EncryptedRefreshToken::from_stored(...)`. **No decryption.**
- [ ] If the adapter held a `TokenCipher` field solely to support the
      deleted `RefreshTokenResolver` impl, remove it. Keep the cipher
      passed to `PasAuthConfig::with_refresh_token_cipher` as before.

In wiring code (typically `app_state.rs` or `main.rs`):
- [ ] Drop the 4th argument from `PasAuth::new(...)`. The signature
      is now `PasAuth::new(config, account_resolver, session_store)`.

In any test code that constructed `SvAwareSessionResolver` directly:
- [ ] The `new` signature changed to `(base, store, pas, cache,
      cipher: Option<Arc<TokenCipher>>)`. Pass
      `Some(Arc::new(cipher))` when the test scenario involves a real
      refresh round-trip; `None` to exercise the misconfiguration
      fail-CLOSED path.

Per-consumer migration is typically **net-negative LOC** (decrypt
logic removed). The compiler guides every step.

### v0.1.0 (2026-04-30) — `PasAuthPort` port boundary at the PAS network seam

> **Note:** Released as `0.1.0` after a pre-1.0 reset; equivalent in
> scope to the (yanked) `5.0.0` development line. All prior crates.io
> versions (`1.0.1``4.0.2`) were yanked on 2026-04-30. While in
> `0.x.y`, breaking changes are minor bumps (SemVer §11).

Breaking changes from v4.x. The PAS-side network boundary is now a
two-method port (`PasAuthPort::refresh` / `PasAuthPort::userinfo`)
with a single failure vocabulary (`PasFailure`). The S-L3 fail-OPEN
liveness path and the S-L6 fail-CLOSED sv-enforcement path now share
one HTTP-status classifier instead of duplicating the table inline.
Consumers gain a deterministic in-memory PAS substitute for their own
integration tests. **No schema change. No env-var change.** Migration
is a `Cargo.toml` bump for production callers; test code that
constructed the SDK's resolver directly gets a one-line constructor
update.

1. `AuthClient::refresh_token` and `AuthClient::get_user_info` are
   **removed** from the public surface. Use the trait methods —
   `use pas_external::pas_port::PasAuthPort;` then
   `client.refresh(&rt).await` / `client.userinfo(&at).await`. The
   inherent methods were one-call shims; their HTTP-status reading
   logic now lives in `AuthClient::send_classified` and is exercised
   through the trait.
2. `classify_refresh_error` is **removed** from the public surface.
   Its semantics live in `AuthClient::send_classified` (which produces
   a `PasFailure`) plus the `pas_refresh` deep core (which translates
   `PasFailure` into `PasRefreshOutcome`). `PasFailure` is now the
   unified vocabulary for both S-L3 (fail-open) and S-L6 (fail-closed):
   one classifier, two policies.
3. `attempt_liveness_refresh` is now generic over `P: PasAuthPort`
   instead of taking `&AuthClient`. Existing call sites compile
   unchanged (`&AuthClient` satisfies the bound). The function body
   collapses to a `match` over `pas_refresh(...).await`.
4. `TransientCause` loses `Transport` and `Unknown` — both merged into
   `PasServerError`. **Document this loss explicitly so consumers
   grep-find it.** Detail strings (which transport / which 5xx) are
   not exposed at the cause level; consumers needing diagnostic
   granularity implement `PasAuthPort` themselves and log inside the
   adapter. The merge reflects the policy reality: S-L3 serves cache
   for every transient flavor regardless of which.
5. `SvAwareSessionResolver` gains a `P: PasAuthPort` generic with the
   field rename `auth_client → pas`. Production users of `PasAuth`
   are unaffected — the default type parameter is `P = AuthClient` so
   `PasAuth::resolver()` infers correctly. Test code that constructed
   `SvAwareSessionResolver` directly (to substitute `MemoryPasAuth`)
   gets a public `SvAwareSessionResolver::new(base, store,
   refresh_resolver, pas, cache)` constructor.
6. `MemoryPasAuth` is exposed under the new `test-support` Cargo
   feature for downstream consumer integration tests. Add
   `pas-external = { version = "0.1", features = ["test-support"] }` in
   `[dev-dependencies]` to script `expect_refresh` / `expect_userinfo`
   the same way the SDK's own boundary tests do. No runtime cost when
   the feature is disabled.

### v4.0.x (yanked) → v0.1.0 consumer migration checklist

In `Cargo.toml`:
- [ ] Bump `pas-external` to `"0.1"`.
- [ ] Drop any `pub use pas_external::session_liveness::classify_refresh_error;`
      or similar re-export — the function is gone. Drive the same
      decision via the public `LivenessFailure` returned by
      `attempt_liveness_refresh`, or call the deep core
      `pas_refresh(cipher, port, ciphertext)` directly if you need
      the typed `PasRefreshOutcome` (`Refreshed` / `Rejected` /
      `Transient`) instead of the consumer-shaped `LivenessOutcome`.

In call sites (production code):
- [ ] If you called `client.refresh_token(&rt)` or
      `client.get_user_info(&at)` directly: add
      `use pas_external::pas_port::PasAuthPort;` and switch to
      `client.refresh(&rt).await` / `client.userinfo(&at).await`. The
      return types (`TokenResponse`, `UserInfo`) are identical; the
      error type changes from `Error` to `PasFailure`. Map at the call
      site.

In tests:
- [ ] If you constructed `SvAwareSessionResolver` by hand (most
      consumers don't — `PasAuth::resolver()` is the public path): use
      the new public constructor `SvAwareSessionResolver::new(base,
      store, refresh_resolver, pas, cache)` and pass `Arc::new(...)`
      around each substrate. Add the `test-support` feature to
      dev-dependencies if you want to script `MemoryPasAuth` here.

Per-consumer migration is typically 0 LOC for production wiring
(`PasAuth::resolver()` keeps inferring `P = AuthClient`) and a
handful of LOC for any test code that touched the resolver
directly. The compiler guides every step.

### v4.0.1 (2026-04-30) — `Ppnum` validation aligned with PAS DB CHECK

Patch release. `Ppnum::try_from` previously rejected values not matching
`len() == 11 && starts_with("777")`; the actual DB CHECK is `^[0-9]{11,}$`.
Prefix is band-allocated (canonical seed `100`) and length is variable
(11 = independent, 15/19/... = dependent sub-agent hierarchy, +4 digits per
nesting level). Production `100`-band and sub-agent ppnums could not
authenticate via consumer apps. Fix widens the accepted set; no consumer
code change required.

### v4.0.0 (2026-04-25) — sv enforcement as default (S-L6 mandatory)

Breaking changes from v3.x:

1. `SessionStore::AuthContext` must now `: SvAware` — expose
   `ppnum_id() -> &str` and `sv() -> Option<i64>`. The bound is what
   unlocks the new resolver.
2. `SessionStore` gains a fourth method, `update_sv(session_id, new_sv)`.
   Persist the value alongside the session row.
3. `PasAuth::new` takes a fourth argument: `refresh_resolver: Arc<R>`
   where `R: RefreshTokenResolver`. The trait returns the **plaintext**
   PAS refresh token for a session id — typical impl is `find_by_id` +
   decrypt with the existing `TokenCipher`.
4. `PasAuth::resolver()` now returns `SvAwareSessionResolver` instead of
   the bare `SessionResolver`. Consumers get sv enforcement automatically;
   any hand-rolled `validate_sv(...)` wrapper around the auth middleware
   must be removed.
5. The free function `validate_sv` is removed. Per-request bearer-token
   shape was incompatible with cookie-session middleware.
   `SessionVersionCache`, `MemorySessionVersionCache`,
   `SV_CACHE_KEY_PREFIX`, and `SV_CACHE_TTL` remain exported for
   custom-cache injection via `PasAuth::resolver_with_cache(cache)`.
6. `NewSession` gains `sv: Option<i64>`, populated by the OAuth callback
   from `UserInfo::session_version`. Persist alongside the session row.

### v3.1.0 → v4.0.0 consumer migration checklist

In schema:
- [ ] `ALTER TABLE <session_table> ADD COLUMN sv BIGINT NULL;` — existing
      rows get `NULL`; the next refresh populates them.

In domain types:
- [ ] Add `pub sv: Option<i64>` to your session struct; populate it in
      `SessionStore::create` from `NewSession::sv`.
- [ ] Add `pub ppnum_id: String` (PAS `sub` ULID) to `AuthContext` if not
      already present.
- [ ] `impl SvAware for AuthContext`.

In `SessionStore` impl:
- [ ] Implement `update_sv(session_id, new_sv)` — straight UPDATE.

In adapter wiring:
- [ ] Implement `RefreshTokenResolver` for the adapter — `find_by_id` +
      decrypt with the existing `TokenCipher`.
- [ ] Update `PasAuth::new(...)` to pass `Arc::new(refresh_resolver)` as
      the fourth argument.
- [ ] Remove any `validate_sv(...)` wrapping in the auth middleware —
      `PasAuth::resolver()` handles it.

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

### v3.1.0 (2026-04-25) — opt-in `sv` validator (additive, non-breaking)

Added the plumbing later promoted to default in v4.0.0:

- `SessionVersionCache` trait — abstracts the 60 s `sv:{ppnum_id}` cache
  (KVRocks / Redis / in-memory).
- `MemorySessionVersionCache` — default in-memory impl
  (`tokio::sync::RwLock<HashMap>`); single-pod consumers, or consumers
  without a shared cache substrate.
- `SessionVersionFetcher` trait — cache-miss source. `HttpUserInfoFetcher`
  is the default; reads the new `UserInfo.session_version` field.
- `validate_sv(token_sv, ppnum_id, bearer_token, cache, fetcher)` — entry
  point consumers wrapped around their existing
  `verify_v4_public_access_token`. 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 reject so a
  DB/network blip cannot silently admit a revoked token).

The `oauth` feature now transitively pulls in `tokio` (sync) and
`async-trait`. `UserInfo` already used `#[non_exhaustive]`, so adding
`session_version` is SemVer-minor-safe.

This release was deprecated by v4.0.0 (sv enforcement built into
`PasAuth::resolver()`) but the cache/fetcher/free-function primitives
remain exported for the custom-cache injection path.

### v3.0.0 (2026-04-18) — type-level plaintext seam closed

Breaking changes from v2.x:

1. `NewSession.refresh_token: Option<String>``Option<EncryptedRefreshToken>`.
   Consumers must call `.into_inner()` to obtain the persistable string and
   must remove their own `cipher.encrypt()` call from `SessionStore::create`.
2. `axum` feature now depends on `session-liveness` transitively. Consumers
   that listed both can drop `session-liveness` from their feature list.
3. `PasAuthConfig::with_refresh_token_cipher(cipher)` is now required for
   refresh-token persistence. Without it, `NewSession.refresh_token = None`
   and no liveness checks are possible.
4. `AuthClient::new(config)``AuthClient::try_new(config) -> Result`. The
   silent `unwrap_or_default()` fallback (which produced a no-timeout
   client) is removed; build failure is now fatal at startup.
5. `PasAuthConfig::with_xff_trusted_proxies(n)` is the new knob for proxy
   topology. Default `n = 0` keeps single-hop deployments correct; GKE
   Ingress + GFE typically requires `n = 2`.
6. `PasAuthConfig::from_env()` now refuses to start when (a) `DEV_AUTH=1`
   and `PAS_REDIRECT_URI` is non-loopback, or (b) `secure_cookies = true`
   and `COOKIE_KEY` is unset. Both replace previous "warn and continue"
   paths that silently produced incorrect production deployments.
7. `extract_kid_from_token` renamed to `extract_unverified_kid`. New
   `verify_v4_with_keyset(keyset, token, iss, aud)` does the safe
   kid-lookup-then-verify dance; consumers should prefer it over building
   the lookup themselves.
8. New `well-known-fetch` feature ships [`KeySet`] — a TTL-cached
   well-known fetcher with rotation support. Consumers that hand-rolled
   key fetching should migrate.

### v2.x → v3.x consumer migration checklist

In `Cargo.toml`:
- [ ] Bump `pas-external` to `"3.0"`.
- [ ] Drop `"session-liveness"` from the feature list (now transitive).
- [ ] Bump `pcs-external` to `"2.0"` if used.

In the OAuth-callback wiring (typically `main.rs` or `app_state.rs`):
- [ ] Build `TokenCipher` *before* `PasAuthConfig`.
- [ ] Add `.with_refresh_token_cipher(cipher.clone())` to the
      `PasAuthConfig` builder chain.
- [ ] Add `.with_xff_trusted_proxies(N)` matching the proxy topology.
- [ ] Replace `AuthClient::new(...)` with `AuthClient::try_new(...).expect(...)`.

In `SessionStore::create`:
- [ ] Remove the `cipher.encrypt(rt)` call. The SDK already encrypted.
- [ ] Replace `Some(self.cipher.encrypt(rt)?)` with
      `session.refresh_token.map(|t| t.into_inner())`.

In `pcs-external` consumers (RCW only):
- [ ] `auth_request(api_key, body)` now returns `Result<Request<T>, Error>`.
      Map the error to your domain error type at each call site.

[`TokenCipher`]: ../src/session_liveness/cipher.rs
[`TokenCipher::encrypt`]: ../src/session_liveness/cipher.rs
[`EncryptedRefreshToken`]: ../src/session_liveness/cipher.rs
[`EncryptedRefreshToken::from_stored`]: ../src/session_liveness/cipher.rs
[`attempt_liveness_refresh`]: ../src/session_liveness/liveness.rs
[`AuthClient::send_classified`]: ../src/oauth.rs
[`PasFailure`]: ../src/pas_port/port.rs
[`pas_refresh`]: ../src/pas_port/core.rs
[`PasAuthConfig::with_refresh_token_cipher`]: ../src/middleware/config.rs
[`PasAuth::new`]: ../src/middleware/auth.rs
[`SvAware`]: ../src/middleware/traits.rs
[`SessionStore::get_refresh_ciphertext`]: ../src/middleware/traits.rs
[`SessionValidator`]: ../src/middleware/validator.rs
[`SvCachePort`]: ../src/middleware/sv_cache.rs
[`MemorySvBackend`]: ../src/middleware/sv_cache.rs
[`BearerVerifier`]: ../src/token/port.rs
[`PasJwtVerifier`]: ../src/token/jwt.rs
[`PasJwtVerifier::from_jwks_url`]: ../src/token/jwt.rs
[`UserInfo::session_version`]: ../src/oauth.rs