From fe2f316a4be48864fafbeb66c5b5e20bf0d3a435 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 06:43:32 +0000 Subject: [PATCH] fix(spec): refuse a diverging retention + ttl + archive lifecycle triple at parse time (#10527) --- .../lifecycle-triple-alignment-refine.md | 23 ++++++ packages/spec/src/data/object.test.ts | 71 +++++++++++++++++++ packages/spec/src/data/object.zod.ts | 19 +++++ 3 files changed, 113 insertions(+) create mode 100644 .changeset/lifecycle-triple-alignment-refine.md diff --git a/.changeset/lifecycle-triple-alignment-refine.md b/.changeset/lifecycle-triple-alignment-refine.md new file mode 100644 index 0000000000..a564fd0fdf --- /dev/null +++ b/.changeset/lifecycle-triple-alignment-refine.md @@ -0,0 +1,23 @@ +--- +"@objectstack/spec": patch +--- + +`LifecycleSchema` now refuses the `retention` + `ttl` + `archive` triple at +parse time unless the ttl restates the age bound exactly — `ttl.field: +'created_at'` with `ttl.expireAfter` equal to `retention.maxAge` (#10527). + +Since #10347 the Archiver selects the rows it moves by the declared ttl cutoff +(`ttl.field` older than `ttl.expireAfter`) whenever `ttl` is declared, and by +`created_at`/`archive.after` only when it is not. On a diverging triple that +leaves `retention.maxAge` (pinned equal to `archive.after` by the existing +alignment refine) declared but enforced by nothing — a row whose `ttl.field` +sits in the future stays hot past `retention.maxAge`, silently. A declared +bound nothing enforces is the class this block already refuses loudly, so the +divergence is now rejected at authoring time with a named message instead of +being resolved by whichever column the sweep happens to read. + +No shipped or example object declares the triple (censused in #10527: +`sys_audit_log` and `sys_metadata_audit` are the only archive-declaring +objects, both `retention` + `archive` pairs) — so no bundled object changes +behaviour, and the ruled-legal shapes are unchanged: `retention` + `archive` +aligned pairs and `ttl` + `archive` pairs parse exactly as before. diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index d793952910..7070c7aef1 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -176,6 +176,77 @@ describe('LifecycleSchema (ADR-0057)', () => { expect(result.success).toBe(false); }); + // [#10527] The retention + ttl + archive triple. Since #10347 the Archiver + // selects rows by the ttl cutoff whenever `ttl` is declared, so a triple + // whose ttl diverges from the age bound leaves `retention.maxAge` declared + // but enforced by nothing — refused at parse time unless the ttl restates + // the age bound (same clock, same window). + describe('retention + ttl + archive triple (#10527)', () => { + const messagesOf = (result: ReturnType) => + result.success ? '' : result.error.issues.map((i) => i.message).join('\n'); + + it('still accepts the #10347-ruled ttl + archive pair (no retention)', () => { + const result = LifecycleSchema.safeParse({ + class: 'audit', + ttl: { field: 'expires_at', expireAfter: '90d' }, + archive: { after: '90d', to: 'datalake' }, + }); + expect(result.success).toBe(true); + }); + + it('accepts the triple when the ttl restates the age bound exactly (created_at, equal windows)', () => { + const result = LifecycleSchema.safeParse({ + class: 'audit', + retention: { maxAge: '90d' }, + ttl: { field: 'created_at', expireAfter: '90d' }, + archive: { after: '90d', to: 'datalake' }, + }); + expect(result.success).toBe(true); + }); + + it("rejects the issue's own triple — ttl on a different clock AND window — naming both bounds and the runtime truth", () => { + const result = LifecycleSchema.safeParse({ + class: 'audit', + retention: { maxAge: '90d' }, + ttl: { field: 'expires_at', expireAfter: '30d' }, + archive: { after: '90d', to: 'datalake' }, + }); + expect(result.success).toBe(false); + const msg = messagesOf(result); + // Named values on both sides of the divergence … + expect(msg).toContain("lifecycle.ttl ('30d' after 'expires_at')"); + expect(msg).toContain("retention.maxAge ('90d' after created_at)"); + // … and the POST-#10347 runtime truth: the ttl cutoff selects, so the + // age bound is the one left inert (not "moves rows by age alone"). + expect(msg).toContain('the Archiver moves rows by the ttl cutoff when ttl is declared'); + expect(msg).toContain('no longer bounds the hot store'); + }); + + it('rejects a diverging window even on the same clock (created_at, 30d vs 90d)', () => { + const result = LifecycleSchema.safeParse({ + class: 'audit', + retention: { maxAge: '90d' }, + ttl: { field: 'created_at', expireAfter: '30d' }, + archive: { after: '90d', to: 'datalake' }, + }); + expect(result.success).toBe(false); + expect(messagesOf(result)).toContain("lifecycle.ttl ('30d' after 'created_at')"); + }); + + it('rejects a diverging clock even with equal windows (expires_at, 90d = 90d)', () => { + // Equal durations do NOT make the age bound enforced: a row whose + // `expires_at` sits in the future stays hot past `retention.maxAge`. + const result = LifecycleSchema.safeParse({ + class: 'audit', + retention: { maxAge: '90d' }, + ttl: { field: 'expires_at', expireAfter: '90d' }, + archive: { after: '90d', to: 'datalake' }, + }); + expect(result.success).toBe(false); + expect(messagesOf(result)).toContain("lifecycle.ttl ('90d' after 'expires_at')"); + }); + }); + it('rejects malformed duration literals', () => { for (const bad of ['14', 'd14', '14 days', '2mo', '-3d', '1.5d']) { const result = LifecycleSchema.safeParse({ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index e102dd5039..55fb48d7ab 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -940,6 +940,25 @@ export const LifecycleSchema = lazySchema(() => strictObject({ message: `lifecycle.archive.after ('${lc.archive.after}') must equal retention.maxAge ('${lc.retention.maxAge}') — the hot window ends where the archive begins`, }); } + // [#10527] The retention + ttl + archive triple — the alignment above, one + // policy wider. Since [#10347] the Archiver selects the rows it moves by the + // ttl cutoff (`ttl.field` older than `ttl.expireAfter`) whenever `ttl` is + // declared, and by `created_at`/`archive.after` only when it is not — so on + // this triple the age bound (`retention.maxAge`, pinned equal to + // `archive.after` above) no longer separately bounds the hot store: a row + // whose `ttl.field` sits in the future stays hot past `retention.maxAge`, + // silently. One declared bound nothing enforces is exactly the class this + // block refuses loudly (ADR-0049 declared ≠ enforced), so the triple parses + // only when the ttl restates the age bound — same clock (`created_at`), same + // window — and any divergence is refused here, at authoring time, rather + // than resolved by whichever column the sweep happens to read. + if (lc.retention && lc.ttl && lc.archive + && (lc.ttl.field !== 'created_at' || lc.ttl.expireAfter !== lc.retention.maxAge)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + 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`, + }); + } if (lc.retention?.onlyWhen && lc.storage?.strategy === 'rotation') { ctx.addIssue({ code: z.ZodIssueCode.custom,