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.
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— thelifecycle.storageblock (re-anchored 2026-08-08T13:1xZ aftere0f300ba5: themaxAgeguidance now at:735, text unchanged). Three statements, all unconditional: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: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."
rotateShardsrefuses 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 wheneverrotateShardsdid not run: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:
supportsRotation)DROP TABLEof the expired shardDELETEbycreated_atREAP_BATCH_SIZE×REAP_MAX_BATCHES_PER_SWEEP, remainder drains across later sweepsafterDeletehooksThe 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.supportsRotationis implemented only ondriver-sqland only underisSqlite; no other driver declaresrotateShardsat all.Why it matters — the authoring path
guidanceentries are rejection text: this one is what an author (or an AI writing object metadata) is handed the moment they writemaxAgeinsidelifecycle.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 againstcreated_at, with autovacuum debt and oneafterDeletehook 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 incontent/docs/references/, so "(O(1) reclaim)" is currently published as an unconditional property of therotationstrategy.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. ThemaxAgeguidance's actual routing advice ("set the window withshards/unit, or useretention") is correct and should survive unchanged; it is only the mechanism contrast that needs the qualifier.Not in scope
supportsRotationto other dialects — engine/driver work, not a text fix, and the fallback is a deliberate design.superRefinethat rejectsretention.onlyWhenalongside 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.LifecycleSchemaaccepts exactly what it accepts today.Provenance
origin/main=1f65bfc390ca896ec2575346520c4af60fe4176d, read viagit show origin/main:. Re-verified ate0f300ba5+ on 2026-08-08T13:1xZ — premise intact, anchor:734→:735.rotateShards/supportsRotationacrosspackages/**; the'rotation-fallback'leg traced throughlifecycle-service.ts. Controls:LifecycleService,Reaper,Archiverall resolve to real implementations, so the instrument is not returning false absences.is:open lifecycle rotation shard,spec finding describe enforced— none on this surface./home/user/cloudis not checked out in this environment.