Policies & Guarantees
SessionTether owns the semantics. Database adapters own the mechanics. Both must preserve the contract.
SessionTether is a database-agnostic session lifecycle engine. Its architecture separates the policies governing session behavior from the atomic persistence operations needed to uphold that behavior. A storage interface alone cannot make a lifecycle transition safe.
Design requirements, not verified guarantees
G1–G5 below describe the required correctness contract. The SQL adapters implement transactional conditional consumption, successor creation, and family revocation ordering. Expiration is rechecked at the locked consumption decision point. A full shared concurrency conformance suite is not implemented. See Implementation Status.
Policies vs. Guarantees
A policy defines how SessionTether should respond to an event or state transition. A guarantee, or invariant, defines a property that must always hold, including under concurrent requests, transaction failures, and different database implementations.
For example, the core may decide to rotate an eligible refresh token R1 into R2. The adapter must ensure that consumption of R1 and creation of R2 form one safe transition. A preflight eligibility check followed by independent writes is insufficient.
| SessionTether Policy | Required Guarantee |
|---|---|
| Rotate valid refresh tokens | At most one rotation of a given token can succeed |
| Consume R1 when issuing R2 | Consumption and successor creation commit atomically |
| Revoke a token family upon detected reuse, according to configured policy | Revocation and concurrent rotation obey a well-defined ordering |
| Reject expired refresh tokens | Expired tokens cannot be successfully rotated |
| Revoke individual sessions | Rotations ordered after committed revocation must fail |
Choosing the response to suspected reuse is a core policy decision. Making the resulting persistence atomic and consistent is an adapter responsibility. Atomic rotation does not establish that a token was stolen or prevent theft on its own.
The table describes the intended separation. Currently, consumed-token retries are allowed for reuseGraceTTL milliseconds (default 30,000) from consumption, subject to expiration and revocation. Reuse at or after that boundary revokes the family. Setting the window to zero restores immediate revocation. Consumed-token conflicts are rechecked against this policy; broader policy selection and confirmed-reuse criteria remain open design work.
Five Core Guarantees
G1 — At-Most-Once Consumption
Definition: A refresh token must not be successfully consumed more than once. At most one rotation of R1 can succeed, even when multiple requests execute concurrently.
Why it matters: Without exclusive consumption, two callers can both report success for the same credential and retain competing refresh capability.
Failure scenario: Server A and Server B both read R1 as unconsumed. Each writes a consumption timestamp and returns a successful rotation because neither mutation checks whether the other already consumed R1.
Correct behavior: One attempt wins. The losing attempt does not report successful consumption. Currently the store signals TokenAlreadyConsumedError, and the core rechecks state: an eligible retry returns grace; reuse outside grace revokes the family and returns consumed. Missing, revoked, and expired states also have explicit results.
Adapter responsibility: Enforce eligibility and exclusive consumption within the synchronized persistence operation. A core check before the operation is insufficient. Use an appropriate locking, conditional-write, versioning, or equivalent mechanism.
G2 — Atomic Rotation
Definition: Consuming R1 and creating its successor R2 commit together, or neither commits.
Why it matters: A consumed token without a successor strands the client; a successor without consumption leaves both credentials available for use.
Failure scenario: The adapter marks R1 consumed, then inserting R2 fails because the database becomes unavailable. The first write survives independently.
Correct behavior: Both state changes are committed as one operation. An aborted operation leaves R1 unconsumed and no committed R2 from that attempt.
Adapter responsibility: Own the transaction boundary or an equivalent atomic database operation spanning the domain transition. Do not expose a partially applied rotation as a successful result.
G3 — Revocation Ordering
Definition: Once family revocation commits, no rotation ordered after that revocation may successfully commit for the same family.
Why it matters: A committed logout or family revocation must prevent later renewal; an earlier stale read must not authorize a later rotation.
Failure scenario: Server A checks that the family is active. Server B revokes the family and commits. Server A then consumes R1 and inserts R2 based solely on its earlier read, reporting rotation success after revocation.
Correct behavior: Rotation and revocation have a well-defined serialization order. If rotation is ordered first, it may commit, and subsequent family revocation covers its successor. If revocation is ordered first, rotation must fail. Request arrival or response timing alone does not establish that order.
Adapter responsibility: Coordinate the active-family check and token mutation with family revocation. Token-level synchronization that ignores the family state is insufficient. The database-specific mechanism can vary, but the observable ordering must preserve this invariant.
G4 — Single-Successor Invariant
Definition: Every refresh token has at most one successfully committed successor. R1 must not branch into both R2 and R3.
Why it matters: A linear token history makes rotation and replay semantics meaningful. Branches can leave multiple independently usable credentials after one rotation step.
Failure scenario: Competing or retried operations each insert a replacement for R1. Distinct token hashes satisfy the table's token-hash uniqueness constraint, so both rows commit.
Correct behavior: At most one successor of R1 exists as a committed token. A losing attempt leaves no committed extra successor.
Adapter responsibility: Enforce predecessor-to-successor uniqueness within the atomic transition, through constraints or another valid concurrency mechanism. Unique successor token hashes alone do not enforce a unique successor per predecessor.
G1 concerns successful consumption of R1. G4 concerns successor uniqueness in persisted history. They are related but distinct: an implementation could record consumption once and still insert two successors through a retry or another write path. Both properties must hold.
G5 — Failure Atomicity
Definition: If a rotation fails and its database transaction rolls back, R1 remains unconsumed and R2 does not exist as a committed token from that attempt.
Why it matters: Failed storage operations must not silently destroy a valid credential or leave a partial successor behind.
Failure scenario: A transaction consumes R1 and starts creating R2, then a constraint failure or injected storage fault forces rollback.
Correct behavior: After rollback, both the consumption and successor insertion are absent. R1 is eligible for another attempt unless an independent concurrent operation has changed or revoked its state.
Adapter responsibility: Roll back the entire transition on failure and expose failure through the agreed error contract. Verify persisted state after failure; an exception alone is not evidence of rollback.
G2 defines the all-or-nothing transition. G5 specifies the state visible after an aborted transition. Neither implies rollback after a successful commit: a lost HTTP response can leave R1 consumed and R2 committed even though the client received no replacement.
Ownership Boundaries
The matrix describes architectural responsibilities. It does not imply that every listed facility exists in the current core or adapter.
| Responsibility | SessionTether Core | Database Adapters |
|---|---|---|
| Lifecycle semantics | Define what creation, rotation, expiration, and revocation mean | Persist transitions without changing their meaning |
| Reuse and revocation policy | Determine the response and revocation scope | Execute the requested transition consistently |
| Valid state transitions | Specify eligibility and permitted transitions | Recheck storage-dependent eligibility at the atomic boundary |
| Policy configuration | Define and validate supported configurations | Honor the selected policy contract without inventing policy |
| Outcomes and errors | Define domain-level success, rejection, conflict, and failure semantics | Translate storage-specific results into those agreed outcomes |
| Atomic operations | Specify required invariants | Own transaction boundaries and atomic domain-level persistence |
| Concurrency and constraints | Specify externally observable safety guarantees | Implement database-specific synchronization and relevant constraints |
| Conformance | Specify the shared correctness contract and test expectations | Pass the same contract tests against the real database |
SessionTether does not prescribe row-level locks, conditional updates, optimistic concurrency, or any particular storage mechanism. A different mechanism is acceptable if it delivers the same externally observable safety guarantees across application instances sharing a database. An in-process lock in one server is not sufficient coordination for multiple servers.
The core can check policy before calling the store, but storage-dependent facts can change afterward. Adapters must enforce those facts at the operation's synchronization point, including token consumption, expiration eligibility, and family revocation. SQL adapters read the predecessor under the family write lock, then sample Date.now() once as the consumption decision point. Invalid expiration or expiresAt <= now raises TokenExpiredError, mapped to expired, with no consumption, successor, or revocation. The same timestamp is persisted as consumedAt; successor expiration is that timestamp plus the requested lifetime (newExpiresAt - consumedAt from the input). Expiration after this decision does not abort the transaction or response. There is no expiration grace period. Application clocks must be synchronized across instances; the database clock is not used.
Concurrency Walkthrough
Two application instances receive the same R1. The following shows the full required contract. SQL adapters coordinate family revocation, exclusive consumption, and atomic replacement. Expiration is also rechecked at the locked consumption decision point. Server A is shown as the winner only for illustration.
Server A Shared adapter / database Server B
| | |
|-- attempt R1 → R2 ---------------->|<---------------- attempt R1 → R3 --|
| | |
| Coordinate eligibility, consumption, |
| and successor creation atomically |
| | |
|<-- committed: R1 consumed, R2 -----| |
| |-- losing attempt: no R3 -------->|
| | agreed domain outcome |
| | |
| Core applies reuse/conflict policy |The SQL adapters commit at most one successor through conditional consumption. A losing attempt raises TokenAlreadyConsumedError; Core rechecks current state and returns grace within the configured window or enforces reuse revocation outside it. The losing attempt does not report a second rotation success.
An unsuccessful concurrent rotation does not automatically prove credential theft. Two legitimate browser tabs, overlapping refresh requests, or a retry after a lost response can present the same token. The current policy allows consumed-token retries within grace and revokes the family outside grace, including when a conditional update loses after preflight validation.
Guarantees vs. Limitations
- Database atomicity is not delivery atomicity. The database can commit R1 → R2 while the HTTP response carrying R2 is lost.
- Network failures can make the result ambiguous. A caller that times out may not know whether the transaction committed. An error observed outside the transaction does not prove rollback; blind retries need an explicit design.
- Legitimate retries and attacker replay can look identical. Presenting consumed R1 alone does not establish who sent the request or why.
- An idempotency window is not proof of legitimacy. Time proximity alone does not securely bind a retry to the original request.
- Returning R2 to anyone presenting consumed R1 can be unsafe. An attacker holding R1 could obtain the current credential. Any recovery mechanism needs its own request-binding and authorization design.
- Access-token revocation remains separate. Stateless access tokens can remain usable until expiration after a refresh family is revoked unless the application adds validation or revocation mechanisms.
Atomic rotation prevents inconsistent storage transitions; it does not by itself establish protection against token theft or solve safe recovery after lost responses.
Implementation Status
The source checks eligibility through Core's shared token check, then calls SessionStore.consumeAndReplace(). The adapter call receives hashes and timestamps and returns Promise<void>. A consumed-token conflict is signaled by the exported TokenAlreadyConsumedError, which Core handles by rechecking state and applying grace/reuse policy.
Completed rotation work
The original atomic-rotation gaps are addressed in both SQL adapters: conditional consumption allows one winner, successor insertion commits with consumption, insertion failure rolls back consumption, and consumed-token conflicts are rechecked against the configured grace policy. An active-family write lock now orders rotation with family and user-wide revocation. G1–G5 have implementations; G4 applies through the adapter operation rather than a schema-level predecessor constraint. Targeted regression tests cover these paths, with PostgreSQL tests requiring a configured database.
Remaining boundaries
Expiration is checked at the locked consumption decision point. Family revocation is rechecked under a write lock before consumption. A revocation committed first causes SessionRevokedError, mapped to revoked by the core. A rotation that acquires the lock first commits before revocation can cover its successor. This orders commits, not HTTP response delivery, and does not revoke access JWTs.
A shared adapter conformance suite and broader policy configuration are separate unfinished work; they do not negate the completed atomic-rotation behavior.
| Requirement or facility | Current evidence and remaining scope |
|---|---|
| G1: at-most-once consumption | Both SQL adapters update only an unconsumed predecessor. Tests coordinate competing preflight reads, including separate PostgreSQL clients. |
| G2: atomic rotation | Conditional consumption and successor insertion commit in one transaction. |
| G3: revocation ordering | A conditional no-op update locks the active family through consumption and replacement. Both revocation methods update the same row; revoked families cannot rotate. SQLite tests use separate connections; PostgreSQL tests observe the row-lock wait when a database is configured. |
| G4: single successor | Only the winning conditional update inserts a successor. This protects the adapter operation; there is no schema constraint preventing direct external inserts. |
| G5: failure atomicity | A failed successor insertion rolls back consumption; regression tests inject an insertion failure and verify rollback and retry. |
| Reject expired rotation | Core preflight and the locked adapter check reject invalid or elapsed expiration. Targeted tests cover delays after preflight and lock acquisition, the exact cutoff, and expiration after consumption but before commit. |
| Reuse policy configuration | reuseGraceTTL configures a fixed retry window from consumption; reuse outside grace revokes the family. There is no broader reuse-response selection or confirmed-reuse classification. |
| Domain conflict/error contract | TokenAlreadyConsumedError triggers core state revalidation and grace/reuse handling. SessionRevokedError maps to revoked. Other storage errors propagate; general transient-error retry handling remains application-owned. |
| Policy validation | The constructor validates reuseGraceTTL as a non-negative safe integer and assigns store and expiryTTL directly. Retention validation also exists for cleanup. |
| Shared adapter conformance | Existing tests do not establish a shared concurrency/rollback contract suite. |
The SessionStore API documents the current interface; it must not be read as proof that implementing its method signatures satisfies these invariants. No runtime or storage behavior is changed by this documentation.
Adapter Conformance Expectations
Every database adapter must pass the same shared contract suite before being considered compatible with this correctness contract. The scenarios below are proposed test expectations, not a claim that such a suite currently exists or that the SQLite adapter passes it.
Run them against real databases with independent connections and coordinated overlapping operations. Sequential happy-path tests and one-instance mocks cannot establish cross-instance concurrency behavior.
| Proposed scenario | Required assertions |
|---|---|
| Concurrent rotations of R1 | Release competing attempts together. At most one succeeds; the loser receives the agreed domain outcome and leaves no committed successor. |
| Rollback during successor creation | Inject failure after consumption but before commit. After rollback, R1 is unconsumed and no R2 from that attempt exists. |
| Concurrent rotation and family revocation | Exercise both controlled orderings. Rotation ordered after committed revocation fails; revocation ordered after rotation covers the successor. |
| Prevention of multiple successors | Inspect committed history after contention and retries. There is at most one successor per predecessor, not merely unique token hashes. |
| Expired-token rotation | Reject tokens expired at the agreed eligibility boundary. Assert no consumption or successor mutation, including when time elapses after preflight. |
| Multiple application instances | Repeat race scenarios through separate instances/connections sharing the database. Verify the same outcomes without relying on a process-local lock. |
Existing root tests cover token/password utilities, cleanup, fixed grace boundaries, conditional consumption, insertion rollback, retry after rollback, and competing SQLite rotations. PostgreSQL tests cover separate-client contention and rollback when a database is configured. The SQLite example adds authentication, JWT renewal, revocation, persistence, and cleanup integration tests. These targeted tests are evidence for those paths; they are not a shared adapter conformance suite. Targeted expiration tests cover the locked consumption decision and commit boundary. Separate-connection tests also cover both revocation orderings, for family and user-wide revocation.
Open Design Questions
The current implementation uses conditional consumption, TokenAlreadyConsumedError, a fixed retry grace window, and family revocation outside that window. Broader policy and conformance questions remain:
- What exactly constitutes confirmed refresh-token reuse, and how does it differ from overlap or an ambiguous retry?
- Is the configured grace window sufficient for an application's legitimate retry requirements?
- How should idempotent retry requests be securely bound to the original request?
- What revocation scope should apply after suspected reuse: token, family, user, or another boundary?
- How should adapters expose transaction conflicts and retriable errors as agreed domain outcomes?
- Which guarantees should be mandatory for every supported database, and how should unsupported behavior be surfaced?
G1–G5 are the correctness requirements described here; formal adapter compatibility criteria and the resolution of these questions still need owner-led decisions. The repository's docs/decisions/ workflow records such choices as ADRs. No accepted ADR or new runtime guarantee is created by this page.
