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_tokenis 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.
TokenManageroutcome set is unchanged — it sees onlyREFRESHEDorLOCK_TIMEOUT_ERROR.
Comparison to 401
| Status | error value | CLI action |
|---|---|---|
| 409 | refresh_replay_benign_retry | Reload persisted, retry if newer token |
| 401 | invalid_grant | CURRENT_REJECTION_CLEARED → re-authenticate |
| 401 | session_invalid | CURRENT_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
Authorizationheader. Token possession is the proof of authorization. token: the refresh token from local session state.token_type_hint: alwaysrefresh_tokenwhen the CLI holds a refresh token.- Body must not contain session_id, token_family_id, or any other field.
Responses and CLI Behavior
| HTTP | Body | RevokeOutcome | CLI output |
|---|---|---|---|
| 200 | {"revoked": true} | REVOKED | "Session revoked on server. Local credentials deleted." |
| 5xx | any | SERVER_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 error | — | NETWORK_ERROR | "Server revocation not confirmed (network error). Local credentials deleted." |
| no refresh token | — | NO_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.
REVOKEDis 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
| HTTP | Meaning | ServerSessionStatus | auth doctor --server output |
|---|---|---|---|
| 200 | Session active | active=True, session_id=<id> | "Server session: active (session: <id>)" |
| 401 | Expired, revoked, or invalid | active=False, error="re-authenticate" | "Server session: invalid. Run spec-kitty auth login to re-authenticate." |
| network error | Unreachable | active=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 200current_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 responseis_revoked— absent from responserevocation_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")