Skip to content

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

Description

@os-elon

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):

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

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:

  1. 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.
  2. 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.
  3. 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

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.

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions