Uh oh!
There was an error while loading. Please reload this page.
[RESCUED — evidence complete] feat(platform-objects): declare sys_session ttl sparing revoked tombstones (#7826) - #10633
Conversation
…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
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Uh oh!
There was an error while loading. Please reload this page.
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_sessiongains the ADR-0057 policy ruled on 2026-08-20 (option A):Merge-base anchored: 5 files, +383 / −0 — the declaration, two suites, the changeset, one
engine-double-contractpin row. Re-merged ontoorigin/main(89 commits, no conflicts) asba4fdf8f71.sys_sessionstill 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.tsstill backdates when it tombstones — verified on this merged tree, not inherited: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
ttlonexpires_atwithout the filter reaps the audit trail first and hardest.Evidence 1 — the ablation, with its non-effects predicted first
sys_sessionhas nolifecycleonmain, so the pre-fix tree proves nothing: there is no sweep and a tombstone survives trivially. The control was falsified the only honest way — droponlyWhenfrom the new declaration, keep thettl, 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'exportsmap sends every subpath to./dist/…;plugin-auth/vitest.config.tsdeclares no alias; and the repo's own ledgerscripts/check-test-source-alias.mjs → KNOWN_UNALIASED_TEST_IMPORTS['@objectstack/plugin-auth']lists'@objectstack/platform-objects'. ⇒ resolves intodist/, 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:
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-readexpected [ 'sess_live' ] to include 'sess_tombstone'['sess_live','sess_tombstone'])expected [ 'sess_live' ] to deeply equal [ 'sess_live', 'sess_tombstone' ]session-tombstone.test.tsTest 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)expected { class: 'transient', ttl: { …(2) } } to deeply equal { … ttl: { …(3) } }Object.keys(undefined)throwsCannot convert undefined or null to objectarchiverefusedlc.ttl?.onlyWhen)expected true to be falseexpected true to be falsesys_device_code's windowTest 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)
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_atnull,expires_atgenuinely past — identicalexpires_atto the tombstone, so onlyrevoked_atcan separate their fates) is still deleted by the same sweep:deleted: 1is 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:
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.tsmodels sqlite/pg/mysql and calls mssql/oracle clients "a knex client this driver does not model".Since
sys_sessionis the first real consumer, the remaining half was measured here on live servers (PostgreSQL 16.13 viainitdb/pg_ctl; MariaDB 10.11.14 as a MySQL-protocol stand-in), driving the real declaration → realLifecycleService→ realSqlDriver→ real table created byinitObjectsfrom that same declaration:The column types are the reason this was worth running: sqlite stores⚠️ MariaDB is not MySQL 8.0 — used for predicate compilation and row selection, never for asserting MySQL error wording.
expires_atas TEXT, while the same declaration createstimestamptzon pg anddatetimeon MariaDB, and an ISO-8601…Zcomparand against a real temporal column under strict mode is precisely what a text assertion cannot see. It holds on all three.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 existingsql-driver-ttl-onlywhen-null-dialects.test.tslive legs through the repo's ownPG_CELL/MYSQL_CELLtestkit, 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.tsalready accepts anonlyWhen, 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$oroverorganization_id), and the merged where then feeds eitherbatchedReap's candidatefindor the no-findfallback's singledelete. 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 afterexpires_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.mjswith no path arguments, atba4fdf8f71, 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: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 passedcheck-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.mjscheck:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none newcheck: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 newcheck: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 loudlycheck: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 diagnosticspnpm --filter @objectstack/platform-objects typechecktsc --noEmit, exit 0, no diagnosticscheck:type-check-debtrequired the full-farm closure, run alone withNODE_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 rescuedtest(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 basee717ba111, so the drift ismain'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