Skip to content

[RESCUED — evidence complete] feat(platform-objects): declare sys_session ttl sparing revoked tombstones (#7826) - #10633

Merged
os-elon merged 6 commits into
mainfrom
claude/issue-7826-sys-session-ttl
Aug 21, 2026
Merged

[RESCUED — evidence complete] feat(platform-objects): declare sys_session ttl sparing revoked tombstones (#7826)#10633
os-elon merged 6 commits into
mainfrom
claude/issue-7826-sys-session-ttl

Conversation

@os-elon

@os-elonos-elon commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7826

Still a draft, and still not flipped by me. The rescued commits are unchanged; what was missing was the evidence, and this body is now that evidence. The PM decides acceptance.

Rescue history (dev SIGKILLed at ~2026-08-21T05:18:00Z mid gate-run, Error: Exit code 137, commits pushed unmodified by the PM seat) is in the comment thread on #7826. This body replaces the "what is missing" version.

What landed

sys_session gains the ADR-0057 policy ruled on 2026-08-20 (option A):

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

Merge-base anchored: 5 files, +383 / −0 — the declaration, two suites, the changeset, one engine-double-contract pin row. Re-merged onto origin/main (89 commits, no conflicts) as ba4fdf8f71.

⚠️Deliberate, known consequence, stated rather than left implicit: because revoked tombstones are spared entirely, nothing sweeps them — sys_session still grows without bound on the revoked arm. How long a revoked-session tombstone should be retained is compliance / audit-trail policy, the maintainer's to settle, and it is the card's hard fence. This PR picks no window for it. That is a deferral, not an oversight.

The hazard, re-verified on the merged base

packages/plugins/plugin-auth/src/session-tombstone.ts still backdates when it tombstones — verified on this merged tree, not inherited:

constnow=Date.now();constpatch: SessionTombstonePatch={id: row.id,expires_at: newDate(now-1000),// ← a tombstone looks MAXIMALLY expiredrevoked_at: newDate(now),revoke_reason: reason,};

It stamps three columns and clears nothing, so a tombstone is a strict superset of an ordinary row that is more expired than one which merely lapsed. A ttl on expires_at without the filter reaps the audit trail first and hardest.

Evidence 1 — the ablation, with its non-effects predicted first

sys_session has no lifecycle on main, so the pre-fix tree proves nothing: there is no sweep and a tombstone survives trivially. The control was falsified the only honest way — drop onlyWhen from the new declaration, keep the ttl, watch the tombstone get reaped.

src/dist: reasoned in both directions, not assumed

  • plugin-auth's suite imports @objectstack/platform-objects/identity — a cross-package bare specifier. platform-objects' exports map sends every subpath to ./dist/…; plugin-auth/vitest.config.ts declares no alias; and the repo's own ledger scripts/check-test-source-alias.mjs → KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/plugin-auth'] lists '@objectstack/platform-objects'. ⇒ resolves into dist/, a rebuild is owed on every leg.
  • platform-objects' own suite imports ./sys-session.object.js — relative, in-package, vitest transforms from source. ⇒ no rebuild; it must react to the mutation with no build at all.

Both readings were then measured rather than trusted.

Leg A1 — mutate the source, deliberately SKIP the rebuild

Predicted: all 4 green — a false green. Observed, exactly that:

✓ the hazard is real … ✓ SPARING CONTROL …
✓ POSITIVE CONTROL … ✓ ABLATION (in-file override) …
Test Files 1 passed (1) Tests 4 passed (4)
ablation-dist-preflight: ✓ marker present in 4 built files ← stale dist still carries onlyWhen

This is the leg that makes the rebuild load-bearing instead of ceremonial: an ablation read here would have certified a mutation the suite never saw.

Leg A2 — rebuild @objectstack/platform-objects, prove the artifact, re-read

ablation-dist-preflight @objectstack/platform-objects 'onlyWhen: { revoked_at: { $null: true } }' --absent
✓ marker absent from all 66 built files -- the artifact the suite consumes no longer carries it.
testpredictedobserved
SPARING CONTROL — tombstone survivesREDRED — expected [ 'sess_live' ] to include 'sess_tombstone'
POSITIVE CONTROL — expired row deletedRED (asserts ['sess_live','sess_tombstone'])RED — expected [ 'sess_live' ] to deeply equal [ 'sess_live', 'sess_tombstone' ]
the hazard is real (tombstone writer)GREEN — reads no declarationGREEN
in-file ABLATION (explicit override)GREEN — supplies its own lifecycleGREEN
the other 63 plugin-auth files / 1351 tests, incl. session-tombstone.test.tsGREEN — the mutation touches a declaration they never readGREEN: Test Files 1 failed | 63 passed (64) · Tests 2 failed | 1353 passed (1355)

The tombstone is reaped when the filter is removed, and reaped by the same sweep that spares it when the filter is present. Note the second row: the positive control goes red by the tombstone's absence, which is also the proof that the sweep really deleted rather than merely failing to match.

Leg A3 — same mutation, platform-objects' own suite, no rebuild (source resolution)

testpredictedobserved
is exactly the ruled declarationREDRED — expected { class: 'transient', ttl: { …(2) } } to deeply equal { … ttl: { …(3) } }
filters on a field the object declaresRED, Object.keys(undefined) throwsRED — Cannot convert undefined or null to object
COUNTERFACTUAL — archive refusedRED (both #10165 refines are gated on lc.ttl?.onlyWhen)RED — expected true to be false
COUNTERFACTUAL — rotation storage refusedRED, same gateRED — expected true to be false
parses as a lifecycle block / whole objectGREEN — a ttl without the filter is still legalGREEN
matches sys_device_code's windowGREENGREEN
declares neither conflicting blockGREENGREEN
the other 25 files / 419 testsGREENGREEN: Test Files 1 failed | 25 passed (26) · Tests 4 failed | 422 passed (426)

8 predictions across three legs, 8 hits, no surprises. The two counterfactual rows are the interesting non-obvious ones: removing the filter does not merely lose an assertion, it silently switches off two live schema refusals — visible only because those tests were written against their own counterfactual.

Restoration, proved on disk (never an editor's exit code)

blob before : 0ff50149d51ec4fdcadcf75173074678b23435c0
blob after : 0ff50149d51ec4fdcadcf75173074678b23435c0 ← byte-identical
git diff --exit-code → 0
git status --porcelain → (empty)
grep -n 'onlyWhen: { revoked_at: { $null: true } },' → 51: onlyWhen: { revoked_at: { $null: true } },
ablation-dist-preflight (present) → ✓ marker present in 4 built files ← restore leg REBUILT too
pnpm --filter @objectstack/plugin-auth test → Test Files 64 passed (64) · Tests 1355 passed (1355)
pnpm --filter @objectstack/platform-objects test → Test Files 26 passed (26) · Tests 426 passed (426)

Both restore legs were rebuilt before being read — a dist/ left in the mutated state would have kept the mutation live for every later measurement.

Evidence 2 — the positive control

An ordinary expired row (revoked_at null, expires_at genuinely past — identicalexpires_at to the tombstone, so only revoked_at can separate their fates) is still deleted by the same sweep:

✓ POSITIVE CONTROL — an ordinary expired session IS deleted by that same sweep
report.swept: [{ object: 'sys_session', policy: 'ttl', cutoff: '2026-08-02T00:00:00.000Z', deleted: 1 }]
survivors: ['sess_live', 'sess_tombstone']

deleted: 1 is the load-bearing number: without it the sparing control would pass just as well if the filter had disabled the sweep outright.

Evidence 3 — the dialect gap: verified, not inherited

#10344 did measure it, and the claim was re-run on this merged base rather than taken from the commit message:

✓ sqlite (live): tombstones are excluded, ordinary expired rows remain candidates
✓ pg (compile): $null: true renders IS NULL beside the ttl cutoff
✓ pg (compile): $null: false renders IS NOT NULL
✓ mysql2 (compile): $null: true renders IS NULL beside the ttl cutoff
✓ mysql2 (compile): $null: false renders IS NOT NULL
Test Files 1 passed (1) Tests 5 passed (5)

What that suite actually measures, stated precisely because "three dialects" overstates it: live row selection on sqlite; compile-only .toSQL() text on pg and mysql2, with no connection opened. The three-dialect universe is right — sql-driver.ts models sqlite/pg/mysql and calls mssql/oracle clients "a knex client this driver does not model".

Since sys_session is the first real consumer, the remaining half was measured here on live servers (PostgreSQL 16.13 via initdb/pg_ctl; MariaDB 10.11.14 as a MySQL-protocol stand-in), driving the real declaration → real LifecycleService → real SqlDriver → real table created by initObjects from that same declaration:

PostgreSQL 16.13 expires_at = timestamp with time zone revoked_at = timestamp with time zone
SPARING survivors after sweep: ["sess_live","sess_tombstone"]
POSITIVE report: [{"object":"sys_session","policy":"ttl","cutoff":"2026-08-02T00:00:00.000Z","deleted":1}]
UNFILTERED survivors: ["sess_live"] ← tombstone reaped when onlyWhen is dropped
MariaDB 10.11.14 sql_mode=STRICT_TRANS_TABLES,… expires_at = datetime revoked_at = datetime
SPARING survivors after sweep: ["sess_live","sess_tombstone"]
POSITIVE report: [{"object":"sys_session","policy":"ttl","cutoff":"2026-08-02T00:00:00.000Z","deleted":1}]
UNFILTERED survivors: ["sess_live"]

The column types are the reason this was worth running: sqlite stores expires_at as TEXT, while the same declaration creates timestamptz on pg and datetime on MariaDB, and an ISO-8601 …Z comparand against a real temporal column under strict mode is precisely what a text assertion cannot see. It holds on all three. ⚠️ MariaDB is not MySQL 8.0 — used for predicate compilation and row selection, never for asserting MySQL error wording.

That live measurement is not committed here (CI provisions no live servers, so a committed live test would add no CI protection today) and was recorded instead as an unassigned finding, #10836, with the numbers and the suggested shape — give the existing sql-driver-ttl-onlywhen-null-dialects.test.ts live legs through the repo's own PG_CELL / MYSQL_CELL testkit, which it is the only dialect suite in that directory not to use.

The runtime half, also verified rather than inherited

The reading "lifecycle-service.ts already accepts an onlyWhen, so the runtime half is a one-argument pass-through" was flagged by #10165 as a dev's reading. Measured: it is not a bare pass-through. reap() merges the filter as { [field]: { $lt: cutoff }, ...scope } at three call sites (no-tenant, per-tenant override, and the global pass with its $or over organization_id), and the merged where then feeds either batchedReap's candidate find or the no-find fallback's single delete. The spread is last, so a filter naming the ttl field itself would override the cutoff — not this declaration's case, and worth knowing it is the shape of the seam.

Changeset

.changeset/sys-session-ttl-spare-tombstones.md (@objectstack/platform-objects: minor) was written by the rescued dev and already says both required things — ordinary expired sessions are now reaped 1d after expires_at, and revoked tombstones are deliberately spared with the unbounded-growth consequence named. Left as authored; it needed nothing added.

Gates — union re-derived at the merged head, after the final commit

node scripts/pm/dispatch-gates.mjs with no path arguments, at ba4fdf8f71, working tree clean (committed 5, working tree 0, untracked 0). Every gate it named was run; exits captured before any pipe. Each gate's own verdict line:

gateverdict line
check:changeset-gate-self-tests✓ check-empty-changeset --self-test: 118 assertions over real temp git repos (+ 212 + 116 assertions)
check:objectui-changeset✓ objectui-range --self-test: all checks passed
check-adr-0087-registration.mjs✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).
check-changeset-no-major.mjs✓ This diff introduces no 'major' bump.
check-empty-changeset.mjs✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
check:cross-package-test-inputsOK: 13 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob. (+ 90 self-test cases)
check-cross-package-test-inputs.mjssame verdict, direct invocation
check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new
check:test-source-aliasOK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/
check:type-source-resolutionOK — 76 packages with a tsconfig.json scanned; 51 registered…
check-plugin-teardown-shape.mjs✓ 54 Plugin implementation(s) across 4384 source(s); every teardown-shaped method sits beside a real destroy()
check-affected-docs.mjs✓ affected-docs self-test: 308 cases pass.
check:query-options-erasure✓ ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new
check:engine-double-contractOK — 372 pinned, 133 in the DEBT ledger, 2 exempt.
check:where-matcher✓ conformance holds: 272 matcher(s) discovered, 272 answer the combinator battery correctly or refuse it loudly
check:type-check-coverageOK — 64/77 workspace packages type-checked (plus the root), 13 in the DEBT ledger, 1 exempt.
check:i18ncheck-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys).
check:type-check-debtOK — 33 ledger entr(ies) re-measured in 235.8s, 1912 raw tsc error(s) total, none above its recorded number.
pnpm --filter @objectstack/plugin-auth typechecktsc --noEmit, exit 0, no diagnostics
pnpm --filter @objectstack/platform-objects typechecktsc --noEmit, exit 0, no diagnostics

check:type-check-debt required the full-farm closure, run alone with NODE_OPTIONS=--max-old-space-size=4096: Tasks: 70 successful, 70 total · Time: 5m23.869s, no OOM.

ℹ️ That gate also reports a surplus, and it is not this branch's: @objectstack/plugin-auth: TEST_DEBT records 109, tsc now reports 97 (-12). Attribution measured, not assumed — both rescued test(plugin-auth) commits touch only the new suite, this branch edits no ledger file, and a diff that only adds a clean file cannot push a count below its ceiling; the entry records base e717ba111, so the drift is main's own progress since then. The gate names #6376 as the standing card for that class. Not lowered here: a bookkeeping shrink in another package is a different card, and this one is a rescue.


Generated by Claude Code

os-elonand others added 4 commits August 21, 2026 03:44
…tones
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
… 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
…ting 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
@github-actions

github-actionsBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/platform-objects, touching 2 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/objects.mdx(via expires_at (literal))
  • content/docs/permissions/authentication.mdx(via expires_at (literal))
  • content/docs/protocol/kernel/realtime-protocol.mdx(via expires_at (literal))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx(via expires_at (literal))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5f2e54cc66330cbc53a17f6e3746acdfcdc14704packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9206ae114833fe5273a090fb77db3cbe804f4866 — the merge of head e223ae41f3573809dc4a49ea6c6f9ef002631ea2 into base 5f2e54cc66330cbc53a17f6e3746acdfcdc14704, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9206ae114833fe5273a090fb77db3cbe804f4866 && git checkout 9206ae114833fe5273a090fb77db3cbe804f4866
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 e223ae41f3573809dc4a49ea6c6f9ef002631ea2 && git checkout -B drift-repro 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 && git merge --no-ff e223ae41f3573809dc4a49ea6c6f9ef002631ea2
node scripts/docs-audit/affected-docs.mjs --json 5f2e54cc66330cbc53a17f6e3746acdfcdc14704

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5f2e54cc66330cbc53a17f6e3746acdfcdc14704 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-elonos-elon changed the title [RESCUED — evidence incomplete] feat(platform-objects): declare sys_session ttl sparing revoked tombstones (#7826)[RESCUED — evidence complete] feat(platform-objects): declare sys_session ttl sparing revoked tombstones (#7826)Aug 21, 2026
@os-elon
os-elon marked this pull request as ready for review August 21, 2026 13:01
@os-elon
os-elon enabled auto-merge August 21, 2026 13:01
@os-elon
os-elon added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit dccbcecAug 21, 2026
32 checks passed
@os-elon
os-elon deleted the claude/issue-7826-sys-session-ttl branch August 21, 2026 14:09
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@os-elon@claude