Skip to content

[finding][spec] lifecycle.storage guidance says "Rotation does not reap by age … DROPs the oldest shard whole (O(1) reclaim)" — true only on SQLite; Postgres/MySQL take the age-based rotation-fallback leg #6631

Description

@os-project-manager

Found during a read-only truth sweep of packages/spec's text surfaces against the mechanisms they name. Filed unassigned for triage.

The defect

packages/spec/src/data/object.zod.ts:734-744 — the lifecycle.storage block (re-anchored 2026-08-08T13:1xZ after e0f300ba5: the maxAge guidance now at :735, text unchanged). Three statements, all unconditional:

    guidance: {
      maxAge:
        '`maxAge` is a `retention` key. Rotation does not reap by age  it retains ' +
        '`shards` × `unit` of history and DROPs the oldest shard whole. Set the window ' +
        'with `shards`/`unit`, or use `retention` instead of rotation.',
    },
  }, {
    strategy: z.literal('rotation').describe('Time-shard the table; rotate by DROPping the oldest shard (O(1) reclaim).'),

and the module TSDoc above it: "Rotator (time-shard + DROP oldest)".

"Rotation does not reap by age", "DROPs the oldest shard whole" and "(O(1) reclaim)" are each true on exactly one dialect and false on the rest.

The authority

Physical rotation is a SQLite-only driver capability. packages/drivers/driver-sql/src/sql-driver.ts:4128-4130:

  get supportsRotation(): boolean {
    return this.isSqlite;
  }

and the driver's own note directly above it states the split plainly: "SQLite-only ({@link supportsRotation}); on other dialects the LifecycleService falls back to an age-based reap, so the declared bound holds everywhere — only the reclamation mechanics differ." rotateShards refuses outright elsewhere (sql-driver.ts:4173-4175).

The service agrees (packages/objectql/src/lifecycle/lifecycle-service.ts:18-20), and the fallback leg itself (:925-937) is reached whenever rotateShards did not run:

    } else if (lc.storage?.strategy === 'rotation' && !rotated && !lc.ttl) {
      // Rotation declared but the driver can't shard physically: the shard
      // window IS the bound — enforce the same window with an age-based reap
      // so the declaration is never inert.
      
      outcomes.push(await this.reap(engine, object, lc, 'rotation-fallback', 'created_at', windowMs, report));
    }

That is a reap by age, from created_at — precisely the mechanism the guidance tells the author rotation does not use. The service even names the leg 'rotation-fallback' in its own outcome enum.

The two mechanisms are not interchangeable in the ways the text advertises:

SQLite (supportsRotation) Postgres / MySQL / others
mechanism DROP TABLE of the expired shard row-by-row batched DELETE by created_at
reclaim O(1), real page reclamation O(rows), autovacuum debt
bound granularity whole shard to the row
per-sweep cap none needed REAP_BATCH_SIZE × REAP_MAX_BATCHES_PER_SWEEP, remainder drains across later sweeps
afterDelete hooks none one per reaped row

The declared window does hold on every dialect — that part of the contract is honoured, and the driver note says so. What does not hold is every operational property the spec text attaches to it.

Driver census (packages/drivers/): driver-memory, driver-mongodb, driver-sql, driver-sqlite-wasm, driver-turso. supportsRotation is implemented only on driver-sql and only under isSqlite; no other driver declares rotateShards at all.

Why it matters — the authoring path

guidance entries are rejection text: this one is what an author (or an AI writing object metadata) is handed the moment they write maxAge inside lifecycle.storage. They are already confused about which bounding policy they want, and the message's job is to send them to the right one. It does so by contrasting the two mechanisms — and the contrast is the part that is dialect-specific.

The concrete wrong decision: rotation vs. retention is chosen for its operational profile, not its window. An author on Postgres with a high-frequency telemetry table reads "Rotation does not reap by age … DROPs the oldest shard whole … (O(1) reclaim)" and picks storage: { strategy: 'rotation', shards: 7, unit: 'day' } specifically to avoid a large periodic delete. On Postgres they get exactly the large periodic delete they were avoiding — bounded and batched, but still row-by-row against created_at, with autovacuum debt and one afterDelete hook per row. Nothing rejects the declaration and nothing warns; the table is correctly bounded, so no test goes red. The author's reason for the choice was silently untrue.

The .describe() strings are the second channel: they land in content/docs/references/, so "(O(1) reclaim)" is currently published as an unconditional property of the rotation strategy.

Suggested direction

Non-binding: add the dialect caveat the driver already writes, in the guidance and in strategy's .describe() — rotation is physically sharded on SQLite and falls back to an equivalent age-bounded reap elsewhere, so the retained window is identical on every dialect but the reclamation is not. The maxAge guidance's actual routing advice ("set the window with shards/unit, or use retention") is correct and should survive unchanged; it is only the mechanism contrast that needs the qualifier.

Not in scope

  • Widening supportsRotation to other dialects — engine/driver work, not a text fix, and the fallback is a deliberate design.
  • The superRefine that rejects retention.onlyWhen alongside rotation. Its stated reason is dialect-specific by the same measurement, but changing it would change what the schema accepts, a different defect class. Flagged so whoever picks this up does not silently widen acceptance while fixing prose.
  • No acceptance change in this finding. LifecycleSchema accepts exactly what it accepts today.

Provenance

  • Audited at origin/main = 1f65bfc390ca896ec2575346520c4af60fe4176d, read via git show origin/main:. Re-verified at e0f300ba5+ on 2026-08-08T13:1xZ — premise intact, anchor :734:735.
  • Measurement: rotateShards / supportsRotation across packages/**; the 'rotation-fallback' leg traced through lifecycle-service.ts. Controls: LifecycleService, Reaper, Archiver all resolve to real implementations, so the instrument is not returning false absences.
  • Dedup searches run before filing: is:open lifecycle rotation shard, spec finding describe enforced — none on this surface.
  • Sibling repos: not relevant (the authority is objectstack-internal). /home/user/cloud is not checked out in this environment.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions