You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
lifecycle ttl accepts no row filter, so a transient object with terminal rows in-band cannot spare them — give ttl an onlyWhen mirroring retention.onlyWhen #10165
Filed by the domain:engine PM seat (session session_019yDEhPBC3tcGkW9bkce1HM) as the contract-first half of #7826, under the standing rule that anything touching packages/spec goes to the domain:spec seat regardless of who needs it. ⚠️ I set domain:spec by applying that rule mechanically, not by exercising routing judgment — triage owns domain:* and should correct this if the rule reads otherwise here.
Downstream consumer: #7826 (Blocked-by: this card). ⛔ Do not implement the sys_session declaration here — that half stays in domain:engine.
Maintainer ruling, 2026-08-20 — option A
Given in a live PM session in direct answer to a four-option escalation (A/B/C/D) carrying the standard four-facet block. Verbatim, quoted not translated:
「A 给 ttl 补 onlyWhen(推荐)」
The full facet analysis and the three rejected options are at #7826 comment 5355771705.
What is asked for
Give the lifecycle.ttl block an onlyWhen row filter mirroring the one retention already has, and let its value union accept the platform's canonical null predicate {$null: true} — which is already first-class in the query layer and validated as a boolean there.
The consumer declaration this unblocks (⛔ in #7826, not here):
Why — measured on origin/main @ 2d3860df9, not argued
A dev round on #7826 measured the gap rather than reasoning about it, and stopped without writing a line. Findings, each re-verified independently by the PM seat against the tree:
ttl accepts no row filter at all.object.zod.ts:795 declares onlyWhen on the retention block only (with aliases filter / where / when); there is no equivalent on ttl. A probe supplying ttl.onlyWhen fails with unrecognized_keys on the lifecycle.ttl block.
retention cannot substitute for ttl here.retention's field is hard-coded to created_at (lifecycle-service.ts:994 passes the literal; the schema's own maxAge describe says "Rows older than this (by created_at)"). A maxAge window on a session table would delete still-LIVE sessions whose expiry has not passed.
retention.onlyWhen's value union has no null/absence member. Its describe says "per-field equality or {$in: [...]}"; a probe supplying {revoked_at: null} fails with invalid_union at retention.onlyWhen.revoked_at — "expected string / number / boolean / object, received null".
So the shipped filter language can key on a value's presence, and the case that needs sparing is defined by a value's absence.
The concrete case that has no expression today
sys_session interleaves live rows with terminal audit tombstones in one table. A tombstone is a strict superset of an ordinary row — session-tombstone.ts:281-283 stamps expires_at, revoked_at and revoke_reason and clears nothing — so there is no timestamp tombstones lack, and no value ordinary rows carry that tombstones do not. Neither of the two sparing idioms the platform ships can be written.
⭐ Worse than "an edge case": that same write backdates expires_at to now - 1000. A ttl keyed on expires_at therefore reaps tombstones first and hardest — tombstoning makes a row look maximally expired. The naive declaration is aimed squarely at the rows it must spare, and would silently destroy the audit trail #7732 was deliberately built to preserve, with no test going red.
Why this is a class, not one object
retention.onlyWhen's own describe names the general shape — "for tables that interleave live workflow state with terminal history (sys_automation_run)". Any transient-class object with terminal rows in-band hits this same wall: it cannot use retention's created_at window without reaping live rows, and cannot change class without ADR-0057 §3.6 relocating the table. The asymmetry is that retention got the filter and ttl did not.
Dispatch notes
⛔ Clause-② is yes — this changes contract accept/reject behaviour and widens a declared surface. It must be dispatched at the contract-review tier. The domain:engine lane had no fable available and that is precisely why the work is here rather than there.
Executable acceptance criterion:ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } } parses, and the Reaper's emitted where excludes rows carrying a non-null revoked_at. A positive control must accompany it: an ordinary expired row is still deleted by the same sweep, so the exclusion is demonstrably the filter and not a dead harness.
⛔ Out of scope, and deliberately so: how long a revoked-session tombstone is retained. That is compliance/audit-trail semantics and belongs to the maintainer; every option put to them explicitly excluded it. This card makes the exclusion expressible, and picks no window.
Filed by the⚠️ I set
domain:enginePM seat (sessionsession_019yDEhPBC3tcGkW9bkce1HM) as the contract-first half of #7826, under the standing rule that anything touchingpackages/specgoes to thedomain:specseat regardless of who needs it.domain:specby applying that rule mechanically, not by exercising routing judgment — triage ownsdomain:*and should correct this if the rule reads otherwise here.Downstream consumer: #7826 (
Blocked-by:this card). ⛔ Do not implement thesys_sessiondeclaration here — that half stays indomain:engine.Maintainer ruling, 2026-08-20 — option A
Given in a live PM session in direct answer to a four-option escalation (A/B/C/D) carrying the standard four-facet block. Verbatim, quoted not translated:
The full facet analysis and the three rejected options are at #7826 comment
5355771705.What is asked for
Give the
lifecycle.ttlblock anonlyWhenrow filter mirroring the oneretentionalready has, and let its value union accept the platform's canonical null predicate{$null: true}— which is already first-class in the query layer and validated as a boolean there.The consumer declaration this unblocks (⛔ in #7826, not here):
Why — measured on
origin/main@2d3860df9, not arguedA dev round on #7826 measured the gap rather than reasoning about it, and stopped without writing a line. Findings, each re-verified independently by the PM seat against the tree:
ttlaccepts no row filter at all.object.zod.ts:795declaresonlyWhenon theretentionblock only (with aliasesfilter/where/when); there is no equivalent onttl. A probe supplyingttl.onlyWhenfails withunrecognized_keyson thelifecycle.ttlblock.retentioncannot substitute forttlhere.retention's field is hard-coded tocreated_at(lifecycle-service.ts:994passes the literal; the schema's ownmaxAgedescribe says "Rows older than this (by created_at)"). AmaxAgewindow on a session table would delete still-LIVE sessions whose expiry has not passed.retention.onlyWhen's value union has no null/absence member. Its describe says "per-field equality or{$in: [...]}"; a probe supplying{revoked_at: null}fails withinvalid_unionatretention.onlyWhen.revoked_at— "expected string / number / boolean / object, received null".So the shipped filter language can key on a value's presence, and the case that needs sparing is defined by a value's absence.
The concrete case that has no expression today
sys_sessioninterleaves live rows with terminal audit tombstones in one table. A tombstone is a strict superset of an ordinary row —session-tombstone.ts:281-283stampsexpires_at,revoked_atandrevoke_reasonand clears nothing — so there is no timestamp tombstones lack, and no value ordinary rows carry that tombstones do not. Neither of the two sparing idioms the platform ships can be written.⭐ Worse than "an edge case": that same write backdates
expires_attonow - 1000. Attlkeyed onexpires_attherefore reaps tombstones first and hardest — tombstoning makes a row look maximally expired. The naive declaration is aimed squarely at the rows it must spare, and would silently destroy the audit trail #7732 was deliberately built to preserve, with no test going red.Why this is a class, not one object
retention.onlyWhen's own describe names the general shape — "for tables that interleave live workflow state with terminal history (sys_automation_run)". Anytransient-class object with terminal rows in-band hits this same wall: it cannot useretention'screated_atwindow without reaping live rows, and cannot change class without ADR-0057 §3.6 relocating the table. The asymmetry is thatretentiongot the filter andttldid not.Dispatch notes
yes— this changes contract accept/reject behaviour and widens a declared surface. It must be dispatched at the contract-review tier. Thedomain:enginelane had no fable available and that is precisely why the work is here rather than there.ttl: { field: 'expires_at', expireAfter: '1d', onlyWhen: { revoked_at: { $null: true } } }parses, and the Reaper's emittedwhereexcludes rows carrying a non-nullrevoked_at. A positive control must accompany it: an ordinary expired row is still deleted by the same sweep, so the exclusion is demonstrably the filter and not a dead harness.lifecycle-service.ts:1247already accepts anonlyWhenparameter, so the runtime half is reported to be a one-argument pass-through.sys_sessionhas no retention policy — nothing sweeps it, here or in better-auth, and #7732 makes revoked rows permanent #7826 dev's reading, not from a PM measurement.sys_sessionhas no retention policy — nothing sweeps it, here or in better-auth, and #7732 makes revoked rows permanent #7826: whether{$null: true}insideonlyWhencompiles correctly across all three SQL dialects is unmeasured. The [finding]sys_sessionhas no retention policy — nothing sweeps it, here or in better-auth, and #7732 makes revoked rows permanent #7826 round covered schema parsing and a fake engine, ⛔ not the real driver compile path. That should be this card's first deliverable.⛔ Out of scope, and deliberately so: how long a revoked-session tombstone is retained. That is compliance/audit-trail semantics and belongs to the maintainer; every option put to them explicitly excluded it. This card makes the exclusion expressible, and picks no window.