Non-mutating lookup of the family aggregate.
Returns null if no record exists OR the record is expired (lazy GC).
Per A3 §5.1.
Atomically register a new refresh-token family.
MUST throw RefreshTokenStorageError({ reason: "duplicate-family" })
if a family with the same familyId already exists (regardless of
revoke / TTL state — duplicate familyId indicates RNG collision or
programming bug).
MUST throw RefreshTokenStorageError({ reason: "expired-at-issue" })
if family.expiresAtMs <= now() at call time.
Concurrency contract: N concurrent calls with the same familyId
MUST result in exactly one success and N-1 throws of
"duplicate-family".
Per A3 §5.1.
Atomically read-modify-write the family aggregate.
Adapter performs:
updater(current).{ action: "commit", family }, attempt atomic
CAS commit using an adapter-internal version token (NOT exposed in
RefreshTokenFamily — adapters may use a separate version field,
ETag, Redis WATCH, or any backend-native CAS primitive).RefreshTokenStorageError({ reason: "conflict-exhausted" }).{ action: "abort" }, abort without state
change (no retry).Updater contract (NORMATIVE):
{ outcome: "not-found" } directly without invoking the updater).readonly at the type level; runtime adapters MAY freeze it
additionally as defence-in-depth).{ action: "commit", family, reason? } or
{ action: "abort", reason? }. Returning a bare
RefreshTokenFamily, or null, is the pre-#274 shape and is no
longer accepted.reason is opaque to the adapter. The adapter MUST
echo the settling decision's reason on the returned result and
MUST NOT interpret, validate, or persist it. This is how a caller
classifies an outcome inside the same atomic operation — the
replacement for the pre-#274 closure-captured-variable pattern,
which could not describe a commit that is nevertheless a rejection
(see createRefreshTokenFamilyRotation, #274).reason, the adapter MUST
omit the key from the result rather than set it to undefined.
The field is optional, so "absent" and "present but undefined"
must not both occur: they are one value to the type and two to
"reason" in result, Object.keys, toStrictEqual, and anything
serialising the result. Use the exported withReason helper
— { outcome: "aborted", ...withReason(decision.reason) } — which
is what both in-tree adapters do, so a third store stays
substitutable for them.expiresAtMs is
<= now(). Both adapters fail-closed by throwing
RefreshTokenStorageError({ reason: "expired-at-issue" }) —
symmetric with registerFamily and prevents committing a
dead-on-arrival entry. Callers shrinking TTL during rotation
should compute the new expiresAtMs from a forward window.Return value:
{ outcome: "committed", family, reason? } — CAS succeeded; family
is the newly-persisted state.{ outcome: "not-found" } — family did not exist; updater not
invoked.{ outcome: "aborted", reason? } — updater aborted; no state
change.Per A3 §5.1 + #274.
Storage primitive for refresh-token families.
Theme A: this interface exposes only single-key atomic primitives. The 4-outcome rotation ceremony (
rotated | replayed | revoked | unknown_family) is composed in the wrapper layer (RefreshTokenFamilyRotation), NOT classified by the adapter.Per A3 §5.1.