Skip to content

[finding] sys_session has no retention policy — nothing sweeps it, here or in better-auth, and #7732 makes revoked rows permanent #7826

Description

@os-help

Blocked-by: #10165

⚠️ The former Restart-when: line has been REMOVED, deliberately. This card is no longer waiting on a condition — as of the maintainer's 2026-08-20 option-A ruling it is waiting on a card, #10165, which gives lifecycle.ttl the onlyWhen row filter this object's policy cannot be written without. Leaving a Restart-when: behind would let the daily executable-criteria scan fire on a card that the unlock scan already owns. Its substance is preserved below and nothing else in this body was touched. Ruling and the four-facet analysis: comment 5355771705.

Escalated by the dev on #7732 (PR #7825) and carded rather than invented inside a bug fix. Nothing is broken today; this records a policy nobody owns, with the measurements that make it decidable.

⚠️ Trigger history, kept because it is the useful part. The original trigger read "closed #7675" and fired without delivering what it stood for#7675 closed 2026-08-16 and did land login/logout as real sys_audit_log rows, but revocation was deliberately excluded: logoutEventFor returns null for every path except /sign-out (auth-session-audit.ts:157), and that module states the reason — "the revoke families already have their own trail (ADR-0069 D4 tombstones)". There are still two trails, not one. The remaining promote-trigger was an operational symptom on sys_session (table size / read latency / monitoring alarm); it never fired, and the card was promoted on the independent ground that sys_session is the odd one out among the objects declaring a lifecycle policy. Every measurement below still stands.

Measured

better-auth 1.7.0-rc.2 has no scheduled sweeper of session rows. Its only expiry-driven collection in the whole library is one line inside GET /get-session: on a row whose expiresAt has passed, it calls internalAdapter.deleteSession(token). That collector fires only on a row findSession returned, i.e. only when the client presents that cookie again.

Two consequences follow, and the first predates everything:

  1. An abandoned session is already immortal. A user who closes the tab and never returns leaves a row nothing will ever collect, because the only collector needs the cookie to come back. sys_session has therefore always accumulated dead rows, at a rate strictly higher than interactive revocations occur.
  2. After session-list-revoke: sys_session.revoked_at/revoke_reason are declared readonly "System-managed" but the interactive-revoke path deletes rather than tombstones — ADR-0069 D4 revoke-audit trail is effectively inert #7732, a revoked session is deliberately permanent. Tombstoned rows are hidden from better-auth's own session reads (that is what stops the collector eating the audit record), so the one collector cannot reach them by construction.

There is no TTL, no retention window, no sweeper and no setting for sys_session in this repo either.

Why it is worth owning rather than shrugging at

ADR-0069 D4's intent, in the issue's own words, is to keep the row "long enough to audit". That phrase implies a window, and there is none — "forever" is the current answer by default rather than by decision. Two different questions hide in it:

  • Operational: unbounded growth on a table every authenticated request reads. Bounded in practice today by row size and by the fact that the abandoned-session leak is larger, but it has no ceiling and no monitoring.
  • Compliance: a revoked-session tombstone is an audit record about a person. Whether it is retained on the same schedule as sys_audit_log, longer, or shorter is a retention-policy question, not an engineering one. Note the user-erasure paths already remove tombstones (/delete-user, /delete-user/callback, /admin/remove-user), so the erasure story is coherent; it is the steady-state schedule that is unowned.

The dispatchable scope, once #10165 lands

Declare a lifecycle policy for ordinary expired/inactive sys_session rows, using the onlyWhen filter #10165 adds:

ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }

Hard fence, unchanged and load-bearing: the revoked-row/tombstone retention window is compliance/audit-trail semantics and is NOT in scope. Every option put to the maintainer excluded it explicitly and the ruling did not settle it.

⚠️ Measured, so it is not re-derivable from the object file: a tombstone is a strict superset of an ordinary row (session-tombstone.ts:281-283 stamps expires_at, revoked_at, revoke_reason and clears nothing), and that write backdates expires_at to now - 1000 — so a ttl keyed on expires_at without the filter reaps tombstones first and hardest, with no test going red. The 1d figure is derivable without fresh policy from sys_device_code, the only other managedBy: 'better-auth' transient object.

Provenance

Escalated in #7732's dev report and accepted at PM review of PR #7825. Filed as a finding rather than needs-user-decision because nothing was blocked on it; promoted 2026-08-17, re-routed to domain:engine 2026-08-20, dispatched and measured the same day, escalated on the measurement, and ruled the same day.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions