# 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.