Skip to content

Commit 5649efb

Browse files
Elon Muskclaude
andauthored
fix(spec): refuse a diverging retention + ttl + archive lifecycle triple at parse time (#10527) (#10643)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7bf3fb7 commit 5649efb

3 files changed

Lines changed: 113 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`LifecycleSchema` now refuses the `retention` + `ttl` + `archive` triple at
6+
parse time unless the ttl restates the age bound exactly — `ttl.field:
7+
'created_at'` with `ttl.expireAfter` equal to `retention.maxAge` (#10527).
8+
9+
Since #10347 the Archiver selects the rows it moves by the declared ttl cutoff
10+
(`ttl.field` older than `ttl.expireAfter`) whenever `ttl` is declared, and by
11+
`created_at`/`archive.after` only when it is not. On a diverging triple that
12+
leaves `retention.maxAge` (pinned equal to `archive.after` by the existing
13+
alignment refine) declared but enforced by nothing — a row whose `ttl.field`
14+
sits in the future stays hot past `retention.maxAge`, silently. A declared
15+
bound nothing enforces is the class this block already refuses loudly, so the
16+
divergence is now rejected at authoring time with a named message instead of
17+
being resolved by whichever column the sweep happens to read.
18+
19+
No shipped or example object declares the triple (censused in #10527:
20+
`sys_audit_log` and `sys_metadata_audit` are the only archive-declaring
21+
objects, both `retention` + `archive` pairs) — so no bundled object changes
22+
behaviour, and the ruled-legal shapes are unchanged: `retention` + `archive`
23+
aligned pairs and `ttl` + `archive` pairs parse exactly as before.

‎packages/spec/src/data/object.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,77 @@ describe('LifecycleSchema (ADR-0057)', () => {
176176
expect(result.success).toBe(false);
177177
});
178178

179+
// [#10527] The retention + ttl + archive triple. Since #10347 the Archiver
180+
// selects rows by the ttl cutoff whenever `ttl` is declared, so a triple
181+
// whose ttl diverges from the age bound leaves `retention.maxAge` declared
182+
// but enforced by nothing — refused at parse time unless the ttl restates
183+
// the age bound (same clock, same window).
184+
describe('retention + ttl + archive triple (#10527)', () => {
185+
const messagesOf = (result: ReturnType<typeof LifecycleSchema.safeParse>) =>
186+
result.success ? '' : result.error.issues.map((i) => i.message).join('\n');
187+
188+
it('still accepts the #10347-ruled ttl + archive pair (no retention)', () => {
189+
const result = LifecycleSchema.safeParse({
190+
class: 'audit',
191+
ttl: { field: 'expires_at', expireAfter: '90d' },
192+
archive: { after: '90d', to: 'datalake' },
193+
});
194+
expect(result.success).toBe(true);
195+
});
196+
197+
it('accepts the triple when the ttl restates the age bound exactly (created_at, equal windows)', () => {
198+
const result = LifecycleSchema.safeParse({
199+
class: 'audit',
200+
retention: { maxAge: '90d' },
201+
ttl: { field: 'created_at', expireAfter: '90d' },
202+
archive: { after: '90d', to: 'datalake' },
203+
});
204+
expect(result.success).toBe(true);
205+
});
206+
207+
it("rejects the issue's own triple — ttl on a different clock AND window — naming both bounds and the runtime truth", () => {
208+
const result = LifecycleSchema.safeParse({
209+
class: 'audit',
210+
retention: { maxAge: '90d' },
211+
ttl: { field: 'expires_at', expireAfter: '30d' },
212+
archive: { after: '90d', to: 'datalake' },
213+
});
214+
expect(result.success).toBe(false);
215+
const msg = messagesOf(result);
216+
// Named values on both sides of the divergence …
217+
expect(msg).toContain("lifecycle.ttl ('30d' after 'expires_at')");
218+
expect(msg).toContain("retention.maxAge ('90d' after created_at)");
219+
// … and the POST-#10347 runtime truth: the ttl cutoff selects, so the
220+
// age bound is the one left inert (not "moves rows by age alone").
221+
expect(msg).toContain('the Archiver moves rows by the ttl cutoff when ttl is declared');
222+
expect(msg).toContain('no longer bounds the hot store');
223+
});
224+
225+
it('rejects a diverging window even on the same clock (created_at, 30d vs 90d)', () => {
226+
const result = LifecycleSchema.safeParse({
227+
class: 'audit',
228+
retention: { maxAge: '90d' },
229+
ttl: { field: 'created_at', expireAfter: '30d' },
230+
archive: { after: '90d', to: 'datalake' },
231+
});
232+
expect(result.success).toBe(false);
233+
expect(messagesOf(result)).toContain("lifecycle.ttl ('30d' after 'created_at')");
234+
});
235+
236+
it('rejects a diverging clock even with equal windows (expires_at, 90d = 90d)', () => {
237+
// Equal durations do NOT make the age bound enforced: a row whose
238+
// `expires_at` sits in the future stays hot past `retention.maxAge`.
239+
const result = LifecycleSchema.safeParse({
240+
class: 'audit',
241+
retention: { maxAge: '90d' },
242+
ttl: { field: 'expires_at', expireAfter: '90d' },
243+
archive: { after: '90d', to: 'datalake' },
244+
});
245+
expect(result.success).toBe(false);
246+
expect(messagesOf(result)).toContain("lifecycle.ttl ('90d' after 'expires_at')");
247+
});
248+
});
249+
179250
it('rejects malformed duration literals', () => {
180251
for (const bad of ['14', 'd14', '14 days', '2mo', '-3d', '1.5d']) {
181252
const result = LifecycleSchema.safeParse({

‎packages/spec/src/data/object.zod.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,25 @@ export const LifecycleSchema = lazySchema(() => strictObject({
940940
message: `lifecycle.archive.after ('${lc.archive.after}') must equal retention.maxAge ('${lc.retention.maxAge}') — the hot window ends where the archive begins`,
941941
});
942942
}
943+
// [#10527] The retention + ttl + archive triple — the alignment above, one
944+
// policy wider. Since [#10347] the Archiver selects the rows it moves by the
945+
// ttl cutoff (`ttl.field` older than `ttl.expireAfter`) whenever `ttl` is
946+
// declared, and by `created_at`/`archive.after` only when it is not — so on
947+
// this triple the age bound (`retention.maxAge`, pinned equal to
948+
// `archive.after` above) no longer separately bounds the hot store: a row
949+
// whose `ttl.field` sits in the future stays hot past `retention.maxAge`,
950+
// silently. One declared bound nothing enforces is exactly the class this
951+
// block refuses loudly (ADR-0049 declared ≠ enforced), so the triple parses
952+
// only when the ttl restates the age bound — same clock (`created_at`), same
953+
// window — and any divergence is refused here, at authoring time, rather
954+
// than resolved by whichever column the sweep happens to read.
955+
if (lc.retention && lc.ttl && lc.archive
956+
&& (lc.ttl.field !== 'created_at' || lc.ttl.expireAfter !== lc.retention.maxAge)) {
957+
ctx.addIssue({
958+
code: z.ZodIssueCode.custom,
959+
message: `lifecycle.ttl ('${lc.ttl.expireAfter}' after '${lc.ttl.field}') must restate retention.maxAge ('${lc.retention.maxAge}' after created_at) when retention, ttl and archive are all declared — the Archiver moves rows by the ttl cutoff when ttl is declared, so a diverging age bound no longer bounds the hot store; align ttl to { field: 'created_at', expireAfter: '${lc.retention.maxAge}' } or drop retention or ttl`,
960+
});
961+
}
943962
if (lc.retention?.onlyWhen && lc.storage?.strategy === 'rotation') {
944963
ctx.addIssue({
945964
code: z.ZodIssueCode.custom,

0 commit comments

Comments
 (0)