Implementation Plan: CLI Auth Tranche 2.5 Contract Consumption
Branch: auth-tranche-2-5-cli-contract-consumption | Date: 2026-04-30 | Spec: spec.md
Summary
Update four CLI surfaces to consume the Tranche 2 server auth contract: (1) swap auth logout from POST /api/v1/logout (bearer-only, retired) to POST /oauth/revoke (token-possession, RFC 7009), with distinct output for three logout outcomes; (2) add 409 benign-replay handling inside run_refresh_transaction's _run_locked, keeping the retry atomic under the existing machine-wide lock; (3) add auth doctor --server as an opt-in network path that refreshes then calls GET /api/v1/session-status, while preserving the default offline doctor contract; (4) update tests to remove legacy /api/v1/logout assertions and cover all new paths. A new RevokeFlow class in auth/flows/revoke.py owns the revoke HTTP call. StoredSession gains an optional generation: int | None field for forward-compatibility with token-family lineage.
Technical Context
Language/Version: Python 3.11+ Primary Dependencies: httpx (HTTP), typer (CLI), Rich (output), pytest + pytest-asyncio (tests) Storage: Encrypted local session file via SecureStorage; machine-wide file lock for refresh serialization Testing: pytest with typer.testing.CliRunner for CLI paths; httpx mocked at httpx.AsyncClient seam; pytest-asyncio for async auth flows Target Platform: macOS/Linux/Windows (cross-platform via specify_cli.paths) Performance Goals: Revoke call bounded to 10s timeout; refresh transaction bounded to _REFRESH_MAX_HOLD_S = 10.0s Constraints: No new auth endpoints; retired /api/v1/logout must not be called; spent refresh token must never be re-submitted; default auth doctor must make zero outbound calls
Charter Check
- Language: Python — confirmed. All changes are within
src/specify_cli/. - Testing: All new code paths require test coverage via
pytest. CLI paths useCliRunner. HTTP paths mock athttpx.AsyncClient. - No implementation details in spec: Confirmed; plan aligns with spec's behavioral requirements.
- Branch strategy: All changes land on
auth-tranche-2-5-cli-contract-consumption. - No server changes: Confirmed;
spec-kitty-saasis reference-only. - Dependency:
spec-kitty-saascontract files are read-only references atkitty-specs/saas-cli-token-family-and-revocation-01KQATJN/contracts/.
Charter check: PASS — no violations.
Project Structure
Documentation (this mission)
kitty-specs/auth-tranche-2-5-cli-contract-consumption-01KQEJZK/
├── plan.md # This file
├── research.md # Phase 0: contract findings and decision rationale
├── data-model.md # Phase 1: changed/new types and state machines
├── contracts/ # Phase 1: CLI-facing contract summaries
│ ├── revoke-call.md
│ ├── refresh-replay.md
│ └── session-status-call.md
└── tasks/ # Created by /spec-kitty.tasks
Source Code (affected files)
src/specify_cli/auth/
├── errors.py # + RefreshReplayError (new)
├── session.py # + generation: int | None = None field
├── flows/
│ ├── refresh.py # + 409 detection → raises RefreshReplayError
│ └── revoke.py # NEW: RevokeFlow class
├── refresh_transaction.py # + replay handling in _run_locked
└── token_manager.py # no interface change; outcome set unchanged
src/specify_cli/cli/commands/
├── auth.py # + --server flag on doctor command
├── _auth_logout.py # rewritten: RevokeFlow replaces _call_server_logout
└── _auth_doctor.py # + server: bool param + ServerSessionStatus + hint
tests/
├── auth/
│ ├── test_refresh_transaction.py # + 409 replay paths
│ ├── flows/
│ │ ├── test_refresh_flow.py # + 409 response handling
│ │ └── test_revoke_flow.py # NEW
│ └── integration/
│ └── test_logout_e2e.py # updated for /oauth/revoke
├── cli/commands/
│ ├── test_auth_logout.py # updated: /api/v1/logout → /oauth/revoke
│ └── test_auth_doctor.py # + --server flag paths
kitty-specs/auth-tranche-2-5-cli-contract-consumption-01KQEJZK/
└── dev-smoke-checklist.md # Created in WP05
Work Packages
WP01 — Error Foundation and StoredSession Extension
Goal: Establish the type changes that WP02, WP03, and WP04 all depend on. Small surface, high leverage.
Files:
src/specify_cli/auth/errors.py— addRefreshReplayErrorsrc/specify_cli/auth/session.py— addgeneration: int | None = None
Changes:
1. errors.py: Add RefreshReplayError(TokenRefreshError). Carries retry_after: int from the server's 409 retry_after field (0–5s). The retry decision is made by _run_locked, not the caller, but the field may be useful for future logging.
2. session.py: Add generation: int | None = None to StoredSession as the last field (default None for backward-compatible deserialization). Update to_dict() to emit "generation": self.generation. Update from_dict() to read data.get("generation") (returns None for existing stored sessions that predate Tranche 2).
Tests: Verify existing StoredSession round-trip tests still pass (field is optional with default None).
WP02 — RevokeFlow and Logout Migration
Goal: Replace the retired /api/v1/logout bearer call with RFC 7009-compliant /oauth/revoke, distinguishing three outcome states in output.
Files:
src/specify_cli/auth/flows/revoke.py— NEWsrc/specify_cli/cli/commands/_auth_logout.py— rewrite server calltests/auth/flows/test_revoke_flow.py— NEWtests/cli/commands/test_auth_logout.py— update
Changes:
1. revoke.py — RevokeFlow class with RevokeOutcome enum:
REVOKED: HTTP 200 +{"revoked": true}SERVER_FAILURE: 4xx/5xx or unexpected body (5xx must never be reported as success)NETWORK_ERROR:httpx.RequestErrorNO_REFRESH_TOKEN: session has no refresh token- POSTs
token=session.refresh_token+token_type_hint=refresh_tokenform-encoded; 10s timeout - Never raises; unexpected exceptions map to
SERVER_FAILUREwith log warning
2. _auth_logout.py — logout_impl update:
- Call
RevokeFlow().revoke(session), map outcome to output line - Local cleanup (
tm.clear_session()) runs unconditionally after the revoke call - Remove
_call_server_logoutentirely
3. test_auth_logout.py — update:
- Remove all assertions on
POST /api/v1/logout - Assert revoke called with correct URL and body shape (
token=...,token_type_hint=refresh_token) - Cover: 200 success, 5xx failure, network error, no-refresh-token,
--force
WP03 — Refresh 409 Benign Replay Handling
Goal: Handle refresh_replay_benign_retry atomically inside _run_locked. One transaction. Spent token never re-submitted.
Files:
src/specify_cli/auth/flows/refresh.py— detect 409src/specify_cli/auth/refresh_transaction.py— replay handler in_run_lockedtests/auth/flows/test_refresh_flow.py— new 409 casestests/auth/test_refresh_transaction.py— new replay paths
Changes:
1. refresh.py: Add 409 branch — check body.get("error") == "refresh_replay_benign_retry", raise RefreshReplayError(retry_after=body.get("retry_after", 0)).
2. refresh_transaction.py: Add except RefreshReplayError in _run_locked after the existing rejection handler:
- Re-read persisted session
- If
Noneor same refresh token as spent → returnLOCK_TIMEOUT_ERROR - If different refresh token → retry
refresh_flow.refresh(repersisted)once - Retry success →
storage.write(updated), returnREFRESHED - Retry failure (any exception) → return
LOCK_TIMEOUT_ERROR
3. _update_session: Capture generation=tokens.get("generation") in returned StoredSession.
Tests:
- 409 body →
RefreshReplayErrorraised byTokenRefreshFlow - Replay + newer persisted token →
REFRESHED, retry called with new token not spent token - Replay + same persisted token →
LOCK_TIMEOUT_ERROR, no second network call - Replay +
Nonepersisted →LOCK_TIMEOUT_ERROR
WP04 — Auth Doctor --server Flag
Goal: Opt-in server-aware session check. Default offline behavior and all existing tests unchanged.
Files:
src/specify_cli/cli/commands/_auth_doctor.py— add server pathsrc/specify_cli/cli/commands/auth.py— wire--serverflagtests/cli/commands/test_auth_doctor.py— add server-path tests
Changes:
1. _auth_doctor.py:
- Add
ServerSessionStatus(active, session_id, error)frozen dataclass - Add
async def _check_server_session() -> ServerSessionStatus: callget_access_token()(auto-refresh), GET/api/v1/session-status, map 200/401/error toServerSessionStatus; no token internals in result doctor_implgainsserver: bool = False; when True: run server check, render "Server Session" section- Default output (server=False): append "Run
spec-kitty auth doctor --serverto verify server session status."
2. auth.py: Add server: bool = typer.Option(False, "--server", ...) to doctor command; pass to doctor_impl.
Tests:
- Default
doctorstill makes zero outbound calls (existing tests pass unchanged) --server+ 200 → output shows "active", session_id displayed, no token content--server+ 401 → output shows re-authenticate guidance--server+ network error → graceful message, no crash
WP05 — Integration Tests and Dev Smoke
Goal: Full suite green, no legacy assertions, dev smoke checklist produced.
Files:
tests/auth/integration/test_logout_e2e.py— update for/oauth/revokekitty-specs/auth-tranche-2-5-cli-contract-consumption-01KQEJZK/dev-smoke-checklist.md— NEW
Steps: 1. uv run pytest tests/cli/commands/test_auth_logout.py tests/auth/integration/test_logout_e2e.py — zero legacy /api/v1/logout calls 2. uv run pytest tests/auth/test_auth_doctor_report.py tests/auth/test_auth_doctor_repair.py tests/auth/test_auth_doctor_offline.py — offline doctor tests unchanged 3. uv run pytest tests/auth tests/cli/commands/test_auth_status.py — full auth suite 4. Produce dev-smoke-checklist.md with commands and expected output for login → status → doctor → doctor --server → logout against https://spec-kitty-dev.fly.dev
Execution Lanes
WP01 (foundation: errors.py + session.py)
├── Lane A: WP02 (revoke/logout) → WP03 (refresh 409)
└── Lane B: WP04 (doctor --server) [independent of WP02/03]
[both lanes complete] → WP05 (integration + smoke)
WP04 is independent of WP02 and WP03 after WP01 completes. WP02 and WP03 share the refresh transaction stack and must be sequential within Lane A.
Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| 409 retry location | Inside _run_locked (one transaction) | Replay is mechanical recovery under the same concurrency contract as stale-session reconciliation. Keeps token_manager outcome set unchanged. |
generation field | `StoredSession.generation: int | None = None` |
| Revoke client | auth/flows/revoke.py RevokeFlow | Security-relevant false-success behavior warrants its own boundary, injection seam for tests, and consistent module layout with TokenRefreshFlow. |
auth doctor --server | Opt-in flag; default stays offline | Preserves existing test assertions and user expectation that doctor is a safe read-only diagnostic. |
| Revoke encoding | Form-encoded | Matches contract's supported types and existing httpx usage in logout. |
| Replay retry limit | One retry only | If the second attempt fails, surface LOCK_TIMEOUT_ERROR. Avoids loops; the one retry covers the network-drop scenario. |