Skip to content

feat(BL-P2-083): interim session-expired guard for rescoped tokens#88

Merged
bluejayA merged 3 commits into
mainfrom
feat/bl-p2-083-session-expired-guard
May 13, 2026
Merged

feat(BL-P2-083): interim session-expired guard for rescoped tokens#88
bluejayA merged 3 commits into
mainfrom
feat/bl-p2-083-session-expired-guard

Conversation

@bluejayA
Copy link
Copy Markdown
Owner

Summary / 요약

  • EN: Interim guard so the 55-minute deterministic failure of rescoped sessions surfaces as `ApiError::SessionExpired` (and `AppEvent::SessionExpired`) instead of being mis-classified as `AuthFailed`. Operators stop guiding users to "check credentials" when the real fix is `:switch-context`, and we get the telemetry that justifies the rollout of BL-P2-052 Part A (full background auto-refresh).
  • KR: BL-P2-052 Part A 본격 auto-refresh 전까지 55분 후 rescoped 세션이 결정론적으로 실패하는데, 현재는 `AuthFailed("refresh_token refused...")`로 표면화되어 "자격증명 문제"로 오인됨. interim guard로 `SessionExpired { project }` 변형을 도입해 명시적으로 reauth 안내 + tracing 텔레메트리 확보.

What changed

Layer Change
`port/error.rs` New `ApiError::SessionExpired { project: String }` variant
`adapter/auth/keystone.rs::get_token` Pre-empts the 1-min fast path: when active scope != initial scope AND cached token is within 5-min margin (or missing), short-circuit with `SessionExpired` + `tracing::warn`
`worker.rs::api_error` Routes `ApiError::SessionExpired` to dedicated `AppEvent::SessionExpired { project }` event
`event.rs` New `AppEvent::SessionExpired { project }` variant
`app.rs::generate_toast` Korean reauth toast: " 세션이 만료되었습니다. :switch-context로 재전환하거나 앱을 다시 시작하세요."

TDD trail

  • RED: 3 keystone tests fail (`get_token` returns `AuthFailed` or `Ok` instead of `SessionExpired` for rescoped near-expiry cases).
  • GREEN: SessionExpired guard wires the short-circuit + 2 funnel tests (worker routing + toast).

Telemetry rationale

`tracing::warn!(project, expires_at, "session_expired")` at the adapter (structured) + `tracing::warn!(operation, project, "session_expired surfaced to UI")` at the worker (op-tagged). Both fire on the same event so log analysts can correlate adapter detection to UI surface. This is the data BL-P2-052 Part A rollout needs ("how often does this fire in production").

Verification

  • cargo test → 1509 passed (main = 1504 → +5)
  • cargo clippy --all-targets -- -D warnings → clean
  • cargo fmt --all -- --check → clean

Out of scope

  • Background auto-refresh for rescoped sessions (BL-P2-052 Part A).
  • The 5-minute margin is preemptive by design — a rescoped token with 4 minutes left fails fast rather than failing mid-API-call after a long-running operation starts.

Test plan

  • DevStack manual: `:switch-context demo` → wait ~55 min (or shorten Fernet TTL) → trigger any API call → confirm toast reads the Korean reauth message AND no audit log entry classifies it as `AuthFailed`.
  • Unit: 5 tests (3 guard + 1 funnel + 1 toast)
  • Suite: 1509 passing
  • Lint: clippy + fmt clean

Refs: 2026-04-21 Codex adversarial-review M1. Parent: BL-P2-052 Part A.

🤖 Generated with Claude Code

bluejayA and others added 3 commits May 13, 2026 09:59
BL-P2-052 Part A (full background auto-refresh) is scoped beyond P0/P1,
but the 55-minute deterministic auth failure that follows every rescoped
session ships with P0/P1 today. Without this guard, the failure surfaces
as `ApiError::AuthFailed("refresh_token refused: active scope ... differs
from initial scope ...")` — indistinguishable from real credential
rejection. Operators have prompted users to "check credentials" when the
actual fix is `:switch-context` to reauth, and we have no telemetry on
how often this happens.

This change splits the path off cleanly:

- `ApiError::SessionExpired { project }` — distinct from AuthFailed so
  callers (worker funnel + future BL-P2-052 Part A) can branch.
- `KeystoneAuthAdapter::get_token` — pre-empts the existing 1-minute
  fast path for rescoped sessions only (active scope != initial scope):
  if the cached token is within 5 minutes of expiry (or missing for an
  active rescoped key), short-circuit with `SessionExpired` and
  `tracing::warn!(project, expires_at, "session_expired")`. Initial-scope
  tokens fall through to refresh_token unchanged — those CAN be
  self-refreshed via do_authenticate.
- `worker::api_error` — routes `ApiError::SessionExpired` to a dedicated
  `AppEvent::SessionExpired { project }` variant instead of the generic
  `AppEvent::ApiError` funnel.
- `App::generate_toast` — Korean reauth message: "<project> 세션이 만료
  되었습니다. :switch-context로 재전환하거나 앱을 다시 시작하세요."

Telemetry: tracing::warn at the adapter (structured fields for grep) +
tracing::warn at the worker (operation tag for find-by-operation
workflows). Both fire on the same event so analysts can correlate.

TDD: 5 new tests covering the full path:
  - test_get_token_returns_session_expired_for_expired_rescoped_token
  - test_get_token_returns_session_expired_within_5min_margin_for_rescoped
  - test_get_token_initial_scope_near_expiry_does_not_return_session_expired
  - test_api_error_routes_session_expired_to_dedicated_event
  - test_generate_toast_for_session_expired_uses_reauth_message

cargo test = 1509 passed (1504 → +5). clippy clean. fmt clean.

Out of scope (BL-P2-083 spec): background auto-refresh for rescoped
sessions (BL-P2-052 Part A). The interim guard ships in front so BL-P2-052
arrives with telemetry-justified rollout instead of guessing how often
55-minute failures occur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex review P2: the original toast suggested either `:switch-context`
or app restart, but `KeystoneRescopeAdapter::rescope` authenticates the
rescope request with the (now-expired) current token, so for a
fully-expired session `:switch-context` fails with a generic
`RescopeRejected` — the user follows the prompt and hits a second
cryptic error.

The interim toast now recommends only the path that always works (app
restart). The "still rescopeable within the 5-min margin" UX nuance is
tracked as BL-P2-096 follow-up.

Also registers BL-P2-096 in devflow-docs/backlog.md so the smart
recovery work doesn't get lost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Local rustfmt was lenient with chain wrap + format!() single-line; CI's
stricter rustfmt flagged two diffs. No semantic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bluejayA bluejayA merged commit 6c800ac into main May 13, 2026
3 checks passed
@bluejayA bluejayA deleted the feat/bl-p2-083-session-expired-guard branch May 13, 2026 02:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant