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

## [4.0.2] — 2026-04-30

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

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

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

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

See git log.
