Skip to content

Commit dccbcec

Browse files
os-elonclaude
andauthored
[RESCUED — evidence complete] feat(platform-objects): declare sys_session ttl sparing revoked tombstones (#7826) (#10633)
* feat(platform-objects): declare sys_session ttl sparing revoked tombstones Fixes #7826. sys_session declared no lifecycle at all, so nothing swept it: better-auth's only expiry-driven collector runs inside GET /get-session and can never reach a row whose cookie is never presented again. Declares class 'transient' + ttl on expires_at with a 1d window (matching sys_device_code), and onlyWhen { revoked_at: { $null: true } } so the #7732 ADR-0069 D4 audit tombstones are spared. That filter is load-bearing: the tombstone write backdates expires_at to now - 1000 and clears nothing, so an unfiltered ttl on expires_at reaps the audit records first and hardest. Tombstone retention duration remains out of scope (compliance semantics). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * test(plugin-auth): type the sweep double's driver queries, record its pinned delete seam check:query-options-erasure counted 3 new test-surface sites from the LifecycleEngineLike double's driver calls. Fixed at the author's end — the query bags are now typed `DriverQuery` instead of erased to `any` — rather than by raising the ratchet's ceiling. check:engine-double-contract wanted the file's delete() seam recorded: the double already routes through assertEngineDeleteDispatch, so this records new PINNED coverage (engine-double-contract.pinned.json); the shrink-only debt baseline is untouched ("0 added or grown, 0 lost"). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM * test(plugin-auth): narrow the dispatch id's bigint arm instead of casting it check:type-check-debt --re-measure caught plugin-auth's TEST_DEBT drifting 109 -> 110: EngineDeleteDispatch.id admits bigint while the driver's by-id delete takes string | number, a mismatch the earlier `as any` had hidden. Narrowed at the author's end (stringify, as LifecycleService's own idKey does); the shrink-only ledger is untouched and re-measures at 109. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5886ee6 commit dccbcec

5 files changed

Lines changed: 383 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/platform-objects": minor
3+
---
4+
5+
Declare an ADR-0057 lifecycle policy on `sys_session` (#7826): the object is
6+
now `class: 'transient'` with
7+
`ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }`.
8+
9+
**Ordinary expired sessions are now reaped** by the LifecycleService Reaper one
10+
day after `expires_at` passes — the same window `sys_device_code` uses. Until
11+
now nothing swept this table: better-auth's only expiry-driven collector fires
12+
inside `GET /get-session`, so it can never reach a row whose cookie is never
13+
presented again, and an abandoned session was effectively immortal.
14+
15+
**Revoked tombstones are deliberately spared.** The `onlyWhen` filter (#10165)
16+
is load-bearing, not defensive: the #7732 revocation write backdates
17+
`expires_at` to `now - 1000` and clears nothing, so an ADR-0069 D4 audit
18+
tombstone looks *maximally* expired — a TTL on `expires_at` without the filter
19+
would reap the audit trail first and hardest.
20+
21+
Deliberate, known consequence: because tombstones are spared entirely,
22+
`sys_session` still grows without bound on the revoked arm. How long a
23+
revoked-session tombstone should be retained is compliance / audit-trail
24+
policy and is not settled here.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#7826] `sys_session`'s ADR-0057 lifecycle declaration, at the SPEC tier.
4+
//
5+
// This is the third control on the card: the declaration parses, and neither
6+
// of #10165's two `ttl.onlyWhen` conflict refines fires for it.
7+
//
8+
// ⚠️ "Neither refine fires" is worth nothing as a bare absence — `sys_session`
9+
// declares no `archive` and no rotation `storage`, so of course they do not
10+
// fire, and the same green would be printed by a build in which both refines
11+
// had been deleted. So each is measured against its own counterfactual: the
12+
// exact declaration plus the conflicting block must be REFUSED, with #10165's
13+
// own message. That turns "no refine fired" into a statement about live rules.
14+
//
15+
// The sweep behaviour these keys buy — the tombstone-sparing and positive
16+
// controls — is measured where a real Reaper and a real SQL backend are
17+
// reachable: `@objectstack/plugin-auth`'s `sys-session-ttl-sweep.test.ts`.
18+
19+
import { describe, it, expect } from 'vitest';
20+
import { LifecycleSchema, ObjectSchema } from '@objectstack/spec/data';
21+
import { SysSession } from './sys-session.object.js';
22+
import { SysDeviceCode } from './sys-device-code.object.js';
23+
24+
const lifecycle = (SysSession as any).lifecycle;
25+
26+
describe('[#7826] sys_session lifecycle declaration', () => {
27+
it('is exactly the ruled declaration (maintainer 2026-08-20, option A)', () => {
28+
expect(lifecycle).toEqual({
29+
class: 'transient',
30+
ttl: {
31+
field: 'expires_at',
32+
expireAfter: '1d',
33+
onlyWhen: { revoked_at: { $null: true } },
34+
},
35+
});
36+
});
37+
38+
it('parses — both as a lifecycle block and as part of the whole object', () => {
39+
expect(LifecycleSchema.safeParse(lifecycle).success).toBe(true);
40+
const parsed = ObjectSchema.safeParse(SysSession);
41+
expect(parsed.success).toBe(true);
42+
});
43+
44+
it('filters on a field the object actually declares, of a nullable type', () => {
45+
// A filter naming a column that does not exist would compile to a
46+
// predicate matching nothing — the sweep would silently stop reaping.
47+
const field: any = (SysSession.fields as any)[Object.keys(lifecycle.ttl.onlyWhen)[0]];
48+
expect(field).toBeTruthy();
49+
expect(field.required).not.toBe(true);
50+
expect((SysSession.fields as any)[lifecycle.ttl.field]).toBeTruthy();
51+
});
52+
53+
it('matches the window of sys_device_code, the only other better-auth transient object', () => {
54+
expect((SysDeviceCode as any).lifecycle.class).toBe('transient');
55+
expect((SysDeviceCode as any).lifecycle.ttl.expireAfter).toBe(lifecycle.ttl.expireAfter);
56+
});
57+
58+
// ── #10165's two refines: not fired here, and proved to be live ──────────
59+
60+
it('declares neither conflicting block, so neither #10165 refine fires', () => {
61+
expect(lifecycle.archive).toBeUndefined();
62+
expect(lifecycle.storage).toBeUndefined();
63+
});
64+
65+
it('COUNTERFACTUAL — adding `archive` to this exact declaration is refused', () => {
66+
const r = LifecycleSchema.safeParse({ ...lifecycle, archive: { after: '7y', to: 'cold_store' } });
67+
expect(r.success).toBe(false);
68+
expect(r.success ? '' : r.error.issues.map((i: any) => i.message).join(' | '))
69+
.toContain('lifecycle.ttl.onlyWhen cannot be combined with archive');
70+
});
71+
72+
it('COUNTERFACTUAL — adding rotation storage to this exact declaration is refused', () => {
73+
const r = LifecycleSchema.safeParse({
74+
...lifecycle,
75+
storage: { strategy: 'rotation', shards: 7, unit: 'day' },
76+
});
77+
expect(r.success).toBe(false);
78+
expect(r.success ? '' : r.error.issues.map((i: any) => i.message).join(' | '))
79+
.toContain('lifecycle.ttl.onlyWhen cannot be combined with rotation storage');
80+
});
81+
});

packages/platform-objects/src/identity/sys-session.object.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,37 @@ export const SysSession = ObjectSchema.create({
2121
icon: 'key',
2222
isSystem: true,
2323
managedBy: 'better-auth',
24+
25+
// [#7826] ADR-0057 lifecycle — ordinary expired sessions are swept by the
26+
// Reaper; revoked TOMBSTONES are spared ENTIRELY.
27+
//
28+
// `onlyWhen` here is load-bearing, not defensive. The #7732 tombstone write
29+
// (`plugin-auth`'s `reconcileSessionDelete`) BACKDATES `expires_at` to
30+
// `now - 1000` and clears nothing, so a tombstone is a strict SUPERSET of an
31+
// ordinary row that looks MAXIMALLY expired. A `ttl` keyed on `expires_at`
32+
// without this filter would therefore reap the ADR-0069 D4 audit records
33+
// FIRST AND HARDEST — the very rows it exists to preserve — and no existing
34+
// test would go red. The canonical null predicate (`{$null: true}`, #10165)
35+
// is what lets that exclusion be declared HERE, in the object file, instead
36+
// of hiding in a plugin registration one package away.
37+
//
38+
// ⚠️ Deliberate consequence, stated rather than left implicit: tombstones are
39+
// never swept, so `sys_session` still grows without bound on that arm. How
40+
// long a revoked-session tombstone is retained is compliance / audit-trail
41+
// policy and is the maintainer's to settle (#7826's hard fence) — this
42+
// declaration picks no window for it.
43+
//
44+
// `1d` (a grace day AFTER `expires_at` passes) matches `sys_device_code`,
45+
// the only other `managedBy: 'better-auth'` transient object.
46+
lifecycle: {
47+
class: 'transient',
48+
ttl: {
49+
field: 'expires_at',
50+
expireAfter: '1d',
51+
onlyWhen: { revoked_at: { $null: true } },
52+
},
53+
},
54+
2455
// ADR-0010 §3.7 — managed by better-auth; tenants may not edit schema,
2556
// but may add overlay row-level config. Use `no-overlay` if you need to
2657
// forbid sys_metadata overlays entirely.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#7826] `sys_session`'s ADR-0057 TTL sweep, driven end to end: the REAL
4+
// declaration (`@objectstack/platform-objects`) through the REAL Reaper
5+
// (`@objectstack/objectql` `LifecycleService`) against a REAL SQL backend
6+
// (`@objectstack/driver-sql`, live better-sqlite3), over a table this driver
7+
// created from that same declaration.
8+
//
9+
// ## Why this suite exists at all
10+
//
11+
// The declaration it exercises is
12+
//
13+
// ttl: { field: 'expires_at', expireAfter: '1d',
14+
// onlyWhen: { revoked_at: { $null: true } } }
15+
//
16+
// and the `onlyWhen` clause is the whole point. `reconcileSessionDelete` in
17+
// `session-tombstone.ts` (#7732 / ADR-0069 D4) BACKDATES `expires_at` to
18+
// `now - 1000` when it tombstones a revoked session, and clears nothing — so a
19+
// tombstone is a strict SUPERSET of an ordinary row that looks MAXIMALLY
20+
// expired. A TTL keyed on `expires_at` without the filter therefore reaps the
21+
// audit records FIRST AND HARDEST. That backdating is pinned independently in
22+
// `session-tombstone.test.ts`; here it is produced by that same function and
23+
// then fed to the sweep, so the row under test is the one production writes
24+
// rather than one this file imagined.
25+
//
26+
// ## The two controls, and why neither is sufficient alone
27+
//
28+
// * SPARING — the tombstone survives the sweep.
29+
// * POSITIVE — an ordinary expired row is deleted BY THE SAME SWEEP.
30+
//
31+
// Without the positive control the filter could be disabling the sweep
32+
// outright and the sparing control would still pass; without the sparing
33+
// control the sweep is just a sweep. They are made maximally discriminating by
34+
// giving both rows the IDENTICAL `expires_at`: the only property that differs
35+
// is `revoked_at`, so nothing but the filter can separate their fates.
36+
//
37+
// ⚠️ The honest before-state for the sparing control is NOT the pre-fix tree:
38+
// on `origin/main` `sys_session` declared no `lifecycle` at all, so there was
39+
// no sweep and a tombstone survived trivially. The control was proved to
40+
// discriminate by ABLATING the declaration itself — dropping `onlyWhen` while
41+
// keeping the `ttl`, rebuilding `@objectstack/platform-objects` (this package
42+
// resolves it through `exports`, i.e. `dist/`) and watching the tombstone get
43+
// reaped. See the PR body for that run.
44+
45+
import { describe, it, expect, afterEach, vi } from 'vitest';
46+
import { SqlDriver } from '@objectstack/driver-sql';
47+
import { LifecycleService, assertEngineDeleteDispatch } from '@objectstack/objectql';
48+
import type { LifecycleEngineLike, LifecycleObjectLike } from '@objectstack/objectql';
49+
import type { DriverQuery } from '@objectstack/spec/contracts';
50+
import { runWithEndpointContext } from '@better-auth/core/context';
51+
import { SysSession } from '@objectstack/platform-objects/identity';
52+
import { reconcileSessionDelete } from './session-tombstone';
53+
54+
/** The instant the revocation happens; the sweep runs two days later. */
55+
const REVOKED_AT_MS = Date.parse('2026-08-01T00:00:00.000Z');
56+
const SWEEP_AT_MS = REVOKED_AT_MS + 2 * 86_400_000;
57+
58+
const openDrivers: SqlDriver[] = [];
59+
afterEach(async () => {
60+
while (openDrivers.length) {
61+
const d = openDrivers.pop();
62+
try { await d?.disconnect(); } catch { /* noop */ }
63+
}
64+
});
65+
66+
const silentLogger = { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} };
67+
68+
/**
69+
* The tombstone patch as the REAL writer composes it — `reconcileSessionDelete`
70+
* under a real better-auth endpoint context for an interactive revoke. Only the
71+
* `update` surface is needed: the function answers the delete by writing this
72+
* patch instead of deleting.
73+
*/
74+
async function realTombstonePatch(atMs = REVOKED_AT_MS): Promise<Record<string, any>> {
75+
const patches: Array<Record<string, any>> = [];
76+
const engine = { update: async (_o: string, p: any) => { patches.push(p); } };
77+
// The writer stamps from `Date.now()`. Pinning the clock to the simulated
78+
// revocation instant is what puts the row on the same timeline as the sweep,
79+
// WITHOUT rebasing (and so possibly flattening) the backdating this suite is
80+
// about — the offset is still the one the real function chose.
81+
vi.useFakeTimers();
82+
vi.setSystemTime(new Date(atMs));
83+
try {
84+
const proceed = await runWithEndpointContext(
85+
{ path: '/revoke-session', context: {} } as any,
86+
() => reconcileSessionDelete(engine as any, 'sys_session', { id: 'sess_tombstone', revoked_at: null }),
87+
);
88+
expect(proceed).toBe(false); // answered by a tombstone, not a delete
89+
} finally {
90+
vi.useRealTimers();
91+
}
92+
expect(patches).toHaveLength(1);
93+
return patches[0];
94+
}
95+
96+
/**
97+
* `LifecycleEngineLike` over a live `SqlDriver`. `delete` opens with ObjectQL's
98+
* own dispatch predicate so this double refuses exactly what the real engine
99+
* refuses (#4550) rather than re-deriving the rule.
100+
*/
101+
function sweepEngine(driver: SqlDriver, objects: LifecycleObjectLike[]): LifecycleEngineLike {
102+
return {
103+
registry: { getAllObjects: () => objects },
104+
getDriverForObject: () => driver,
105+
async find(object: string, options: any) {
106+
// Typed rather than erased to `any`: the driver silently DROPS an
107+
// unrecognised query key, so `tsc` is the only channel that can reject a
108+
// misspelt one here (#4918).
109+
const query: DriverQuery = { where: options?.where, limit: options?.limit };
110+
return driver.find(object, query);
111+
},
112+
async delete(object: string, options: any) {
113+
const dispatch = assertEngineDeleteDispatch(options);
114+
if (dispatch.kind === 'by-id') {
115+
// `EngineDeleteDispatch.id` admits `bigint`; the driver's by-id delete
116+
// takes `string | number`. Narrowed by stringifying — the same reason
117+
// `LifecycleService`'s own `idKey` stringifies — rather than cast away,
118+
// which is what hid the mismatch here in the first place.
119+
const id = typeof dispatch.id === 'bigint' ? dispatch.id.toString() : dispatch.id;
120+
return (await driver.delete(object, id)) ? 1 : 0;
121+
}
122+
const query: DriverQuery = { where: options?.where };
123+
return driver.deleteMany(object, query);
124+
},
125+
};
126+
}
127+
128+
/**
129+
* Live `sys_session` table, created by the driver from the REAL object
130+
* declaration, seeded with the three rows the policy has to tell apart.
131+
*
132+
* `lifecycle` is the declaration under test unless `override` replaces it —
133+
* that parameter is what lets the ablation be expressed as a case in this file
134+
* as well as being run for real against a rebuilt `dist/` (see the header).
135+
*/
136+
async function seeded(override?: any) {
137+
const driver = new SqlDriver({
138+
client: 'better-sqlite3',
139+
connection: { filename: ':memory:' },
140+
useNullAsDefault: true,
141+
});
142+
openDrivers.push(driver);
143+
await driver.initObjects([SysSession as any]);
144+
145+
const patch = await realTombstonePatch();
146+
const tombstoneExpiry = new Date(patch.expires_at).toISOString();
147+
148+
await driver.create('sys_session', {
149+
id: 'sess_tombstone',
150+
user_id: 'usr_1',
151+
token: 'tok_tombstone',
152+
// Exactly what the real tombstone writer produced.
153+
expires_at: tombstoneExpiry,
154+
revoked_at: new Date(patch.revoked_at).toISOString(),
155+
revoke_reason: patch.revoke_reason,
156+
});
157+
await driver.create('sys_session', {
158+
id: 'sess_expired',
159+
user_id: 'usr_1',
160+
token: 'tok_expired',
161+
// IDENTICAL expiry to the tombstone — `revoked_at` is the only difference.
162+
expires_at: tombstoneExpiry,
163+
revoked_at: null,
164+
});
165+
await driver.create('sys_session', {
166+
id: 'sess_live',
167+
user_id: 'usr_1',
168+
token: 'tok_live',
169+
expires_at: new Date(SWEEP_AT_MS + 7 * 86_400_000).toISOString(),
170+
revoked_at: null,
171+
});
172+
173+
const object: LifecycleObjectLike = {
174+
name: SysSession.name,
175+
lifecycle: (override === undefined ? (SysSession as any).lifecycle : override),
176+
fields: SysSession.fields as any,
177+
};
178+
const service = new LifecycleService({
179+
getEngine: () => sweepEngine(driver, [object]),
180+
logger: silentLogger,
181+
now: () => SWEEP_AT_MS,
182+
initialDelayMs: 1,
183+
sweepIntervalMs: 10,
184+
} as any);
185+
186+
return { driver, service, patch, tombstoneExpiry };
187+
}
188+
189+
const ALL_ROWS: DriverQuery = {};
190+
const survivors = async (driver: SqlDriver) =>
191+
(await driver.find('sys_session', ALL_ROWS)).map((r: any) => r.id).sort();
192+
193+
describe('[#7826] sys_session TTL sweep — real declaration, real Reaper, live SQL', () => {
194+
it('the hazard is real: the tombstone writer backdates expires_at below the revocation instant', async () => {
195+
const patch = await realTombstonePatch();
196+
expect(patch.revoked_at).toBeInstanceOf(Date);
197+
expect(patch.revoke_reason).toBeTruthy();
198+
// The defining property: the tombstone looks MORE expired than a session
199+
// that merely lapsed, which is why a naive TTL reaps tombstones first.
200+
expect(new Date(patch.expires_at).getTime()).toBeLessThan(new Date(patch.revoked_at).getTime());
201+
});
202+
203+
it('SPARING CONTROL — the revoked tombstone survives the sweep', async () => {
204+
const { driver, service } = await seeded();
205+
206+
const report = await service.sweep();
207+
208+
expect(await survivors(driver)).toContain('sess_tombstone');
209+
const tombstoneById: DriverQuery = { where: { id: 'sess_tombstone' } };
210+
const row: any = await driver.findOne('sys_session', tombstoneById);
211+
expect(row).toBeTruthy();
212+
expect(row.revoke_reason).toBeTruthy(); // the audit content is intact
213+
expect(report.errors).toEqual([]);
214+
});
215+
216+
it('POSITIVE CONTROL — an ordinary expired session IS deleted by that same sweep', async () => {
217+
const { driver, service } = await seeded();
218+
219+
const report = await service.sweep();
220+
221+
// One sweep, three rows, two verdicts: the expired row is gone, the
222+
// tombstone and the live session remain.
223+
expect(await survivors(driver)).toEqual(['sess_live', 'sess_tombstone']);
224+
const ttl = report.swept.find((s: any) => s.object === 'sys_session' && s.policy === 'ttl');
225+
expect(ttl).toBeTruthy();
226+
expect(ttl!.deleted).toBe(1);
227+
});
228+
229+
it('ABLATION — without `onlyWhen` the same sweep reaps the tombstone too', async () => {
230+
// The declaration minus its filter: the naive policy #10165 existed to
231+
// make avoidable. This is the case the sparing control has to discriminate
232+
// against, so the control is not vacuous.
233+
const { driver, service } = await seeded({
234+
class: 'transient',
235+
ttl: { field: 'expires_at', expireAfter: '1d' },
236+
});
237+
238+
await service.sweep();
239+
240+
expect(await survivors(driver)).toEqual(['sess_live']);
241+
});
242+
});

scripts/engine-double-contract.pinned.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,11 @@
13111311
"verb": "update",
13121312
"pinned": 1
13131313
},
1314+
{
1315+
"file": "packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts",
1316+
"verb": "delete",
1317+
"pinned": 1
1318+
},
13141319
{
13151320
"file": "packages/plugins/plugin-email/src/attachment-reclaim.test.ts",
13161321
"verb": "delete",

0 commit comments

Comments
 (0)