# 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
