Contracts

refresh-replay.md

CLI Contract: Refresh 409 Benign Replay

Endpoint: POST /oauth/token (existing, grant_type=refresh_token) Canonical source: spec-kitty-saas/kitty-specs/saas-cli-token-family-and-revocation-01KQATJN/contracts/refresh.yaml

409 Response Shape

{
  "error": "refresh_replay_benign_retry",
  "error_description": "Refresh token was just rotated; reload current token and retry.",
  "error_uri": "<uri>",
  "retry_after": 0
}

Note: the field is error, not error_code. retry_after is 0–5 seconds (informational; CLI does not sleep for this duration in Tranche 2.5).

CLI Handling (inside _run_locked)

TokenRefreshFlow.refresh(persisted) → raises RefreshReplayError
  │
  ├── re-read storage.read()
  │
  ├── repersisted is None
  │   └── return LOCK_TIMEOUT_ERROR  (session cleared concurrently)
  │
  ├── repersisted.refresh_token == persisted.refresh_token  (same spent token)
  │   └── return LOCK_TIMEOUT_ERROR  (no newer token; do not retry)
  │
  └── repersisted.refresh_token != persisted.refresh_token  (newer token present)
      │
      ├── refresh_flow.refresh(repersisted) → success
      │   └── storage.write(updated) → return REFRESHED
      │
      └── refresh_flow.refresh(repersisted) → any failure
          └── return LOCK_TIMEOUT_ERROR  (second attempt failed; stop)

Invariants

  • The spent persisted.refresh_token is never submitted to the server again after a 409.
  • The retry uses repersisted (structurally different object with a different token).
  • Maximum one retry per 409; no loop.
  • TokenManager outcome set is unchanged — it sees only REFRESHED or LOCK_TIMEOUT_ERROR.

Comparison to 401

Statuserror valueCLI action
409refresh_replay_benign_retryReload persisted, retry if newer token
401invalid_grantCURRENT_REJECTION_CLEARED → re-authenticate
401session_invalidCURRENT_REJECTION_CLEARED → re-authenticate

401 always means family revocation or genuine expiry. No retry.

revoke-call.md

CLI Contract: Revoke Call

Endpoint: POST /oauth/revoke Canonical source: spec-kitty-saas/kitty-specs/saas-cli-token-family-and-revocation-01KQATJN/contracts/revoke.yaml

Request

POST /oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=<refresh_token>&token_type_hint=refresh_token
  • No Authorization header. Token possession is the proof of authorization.
  • token: the refresh token from local session state.
  • token_type_hint: always refresh_token when the CLI holds a refresh token.
  • Body must not contain session_id, token_family_id, or any other field.

Responses and CLI Behavior

HTTPBodyRevokeOutcomeCLI output
200{"revoked": true}REVOKED"Session revoked on server. Local credentials deleted."
5xxanySERVER_FAILURE"Server revocation not confirmed (server error). Local credentials deleted."
400{"error": "invalid_request"}SERVER_FAILURE"Server revocation not confirmed (server error). Local credentials deleted."
429{"error": "throttled"}SERVER_FAILURE"Server revocation not confirmed (server error). Local credentials deleted."
network errorNETWORK_ERROR"Server revocation not confirmed (network error). Local credentials deleted."
no refresh tokenNO_REFRESH_TOKEN"Server revocation could not be attempted (no refresh token). Local credentials deleted."

Invariants

  • Local tm.clear_session() runs after the revoke call in ALL outcome paths.
  • Exit code is 0 in all outcomes where local cleanup succeeded.
  • REVOKED is the only outcome where the CLI reports server confirmation.
  • A genuine server error (5xx) must never be reported as REVOKED.
  • The spent refresh token must not appear in any log, error, or diagnostic output.

Timeout

10 seconds (matches existing auth HTTP timeout pattern).

session-status-call.md

CLI Contract: Session Status Call

Endpoint: GET /api/v1/session-status Canonical source: spec-kitty-saas/kitty-specs/saas-cli-token-family-and-revocation-01KQATJN/contracts/session-status.yaml

Request

GET /api/v1/session-status
Authorization: Bearer <unexpired_access_token>
  • Requires a valid, unexpired access token.
  • Refresh tokens must not be presented on this endpoint.
  • The CLI must refresh the access token first if it is expired or near expiry.

Responses and CLI Behavior

HTTPMeaningServerSessionStatusauth doctor --server output
200Session activeactive=True, session_id=<id>"Server session: active (session: <id>)"
401Expired, revoked, or invalidactive=False, error="re-authenticate""Server session: invalid. Run spec-kitty auth login to re-authenticate."
network errorUnreachableactive=False, error=<brief message>"Server session check failed: <brief message>"

Safe-to-Display Fields (200 response)

  • session_id: safe to display (not a secret)
  • status: always "active" on 200
  • current_generation: available but not displayed in Tranche 2.5

Fields Never Displayed

Per contract (additionalProperties: false) and spec (NFR-001, C-005):

  • token_family_id — absent from response
  • is_revoked — absent from response
  • revocation_reason — absent from response
  • Raw tokens — never passed through to output

Pre-call Sequence

auth doctor --server
  │
  ├── get_access_token()  (auto-refresh if expired)
  │   ├── refresh succeeds → valid access token obtained
  │   └── refresh fails → ServerSessionStatus(active=False, error="could not refresh")
  │
  └── GET /api/v1/session-status with valid token
      ├── 200 → ServerSessionStatus(active=True, ...)
      └── 401 → ServerSessionStatus(active=False, error="re-authenticate")