Accepted

ADR 0062 — Session expiry slides on the bearer-cookie path

Bearer-format session cookies extend their 30-day expiry on use, through a second sanctioned non-versioned write on api_tokens

The security conventions describe sessions as a sliding 30-day window: expires_at is extended to 30 days from now whenever the session is validated inside the slide threshold. That behaviour was implemented in the legacy sessions-table resolver (local-impl.ts) — but ADR 0026 moved live sessions onto api_tokens rows carried in a bearer-format cookie, and that path verified expiry without ever extending it. The result was a silent regression against the documented contract: an editor who used Cusp every day was still logged out 30 days after signing in, mid-edit if unlucky.

The decision

  • The cookie-carrier session resolver now slides: when a verified session token's remaining lifetime has dropped below (lifetime − slide threshold), expires_at is extended to a full lifetime from now, the Set-Cookie header is re-issued with the matching Max-Age, and the KV token cache record is rewritten in place so the warm verify fast-path serves the extended window immediately. The D1 write is guarded on the row still being active (not revoked, not expired); when the guard reports no row updated — a logout, password change, or admin revoke landed between the request's verify and the deferred slide — the cache entry is deleted rather than rewritten, so a slide can never resurrect a revoked session.
  • The threshold (5 days, from src/shared/auth/config.ts) doubles as a write throttle: an active session pays the D1 write at most once per threshold interval, mirroring the legacy path's behaviour exactly.
  • The write is the second sanctioned non-versioned UPDATE on api_tokens, alongside touchLastUsedAt (ADR 0023 §9). The repository method is constrained to kind = 'session' rows in the SQL itself, and the custom ESLint rule's column-aware allow-list accepts exactly the {expires_at [, updated_at]} shape — any other direct UPDATE on api_tokens is still flagged.
  • Header-carrier bearer tokens (the headless API) do not slide. A user-minted token's expiry is owner-chosen, audited state; extending it implicitly would be surprising and would erode the audit trail the versioning invariant exists to protect.

Why not a versioned write

Versioning every slide would mint an entity_versions row per active session per threshold interval — keep-alive telemetry, not an editorial decision, drowning the token's audit history in noise for the same reason last_used_at was exempted in ADR 0023 §9. The meaningful audit events on a session row (mint at login, revoke at logout or password change) remain versioned writes.

Consequences

  • Active editors stay signed in indefinitely; only a genuinely idle session (untouched for its remaining window) expires. The absolute-wall cap (expires_at_max) noted in the security conventions remains a v1.x follow-up.
  • Revocation semantics are unchanged: logout, password change, and admin revoke delete the KV record and revoke the row, taking effect on the next request regardless of any slide.
  • The legacy sessions-table shim keeps its own slide until it ages out; both paths now honour the same documented contract.