Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/lifecycle-triple-alignment-refine.md
Original file line numberDiff line numberDiff line change
@@ -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.
71 changes: 71 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof LifecycleSchema.safeParse>) =>
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({
Expand Down
19 changes: 19 additions & 0 deletions packages/spec/src/data/object.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Loading