Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): the Archiver honours a declared ttl instead of archiving by created_at age alone - #10541
Conversation
A lifecycle declaring both `ttl` and `archive` parses, but `reapObject` returns into `archiveObject` before the ttl branch is reachable, so the declared per-row expiry never ran and the Archiver moved rows by `created_at` age alone — declared not enforced. Maintainer ruling 2026-08-20: what the author declared is what executes. `archiveObject` now selects candidates by the ttl cutoff on `ttl.field` when `ttl` is declared, and by `created_at`/`archive.after` otherwise, so archive-only lifecycles are byte-for-byte unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
Also names the filed follow-up (#10527) at the site that defers it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7d74542ab7818abdd59fcfcbfb508d25e0d03e84 && git checkout 7d74542ab7818abdd59fcfcbfb508d25e0d03e84
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 359f5956d7910aed7ae9f8fccc9fbb988b3e4882 ef5c36db6eccd377b1810c1896812ef99a8b6c1a && git checkout -B drift-repro 359f5956d7910aed7ae9f8fccc9fbb988b3e4882 && git merge --no-ff ef5c36db6eccd377b1810c1896812ef99a8b6c1a
node scripts/docs-audit/affected-docs.mjs --json 359f5956d7910aed7ae9f8fccc9fbb988b3e4882
|
Uh oh!
There was an error while loading. Please reload this page.
…son against the post-#10347 Archiver (#10526) The refine's rationale comment and its author-facing message both justified the refusal with a runtime fact that #10347 (PR #10541) retired: "the ttl sweep never runs ... the Archiver moves rows by age alone". Re-derived against the merged Archiver on this base: - `reapObject` still returns into `archiveObject` before the ttl reap branch (lifecycle-service.ts), so the ttl sweep genuinely never runs under `archive` — that half stands. - `archiveObject` now selects candidates by the declared ttl cutoff (`dueField = lc.ttl ? lc.ttl.field : 'created_at'`), so "moves rows by age alone" is false whenever `ttl` is declared — that half is replaced. - Its candidate read is `where: { [dueField]: { $lt: cutoff } }` and nothing else: the WINDOW carries over to the Archiver, the `onlyWhen` FILTER does not. That is the reason today, and the refusal stands on it. Text only: the refusal itself, and every accepted/refused shape, is unchanged (piece 2 of #10526 — whether the refusal should survive at all — is explicitly out of scope and untouched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw
Fixes#10347
Maintainer ruling 2026-08-20, option 2 from the card body: when a lifecycle declares both
ttlandarchive, archiving runs against thettlcutoff rather thancreated_atage alone. What the author declared is what executes.The defect
LifecycleSchemaacceptsttlbesidearchive— ADR-0057 §3.5's refine is satisfied becausettlis a bounding policy, and thearchive.after === retention.maxAgerefine only fires whenretentionis present, which the pair skips. ButLifecycleService.reapObjectshort-circuits withif (lc.archive) return this.archiveObject(...)before itsttlbranch is reachable, so no reap onttl.fieldever ran and the Archiver copied and hot-deleted bycreated_atage alone. Declared, not enforced.The change
Three functional lines, all in
packages/objectql/src/lifecycle/lifecycle-service.ts, insidearchiveObject:Everything downstream is untouched: the same copy-then-hot-delete pair, the same batching and abort checkpoints, the same
report.sweptentry.reapObject's short-circuit keeps its shape; its comment now says why the return is not a policy drop.Archive-only lifecycles are unaffected. With no
ttldeclared,dueField/dueWindowresolve to exactly today'screated_at/archive.after. Every archive-declaring object shipped with the platform is that shape — censused, not assumed:sys_audit_logandsys_metadata_auditare the only two, and both declareretention+archive, nottl.A missing or null
ttl.field— the decisionA row with no expiry stamp is retained, not archived. It is not "due at the epoch".
Mechanically this falls out of the predicate:
$ltis a positive comparison, and a value that is not there satisfies none of them — the platform-wide null answer settled in #5298/#5299 and spelleddefault: return falsein every backend'snullValueSatisfiesOperator. It is also the answer this method wants on the merits: a row with no stamp has not been given one, so reading "absent" as "expired long ago" would archive exactly the rows whose expiry the author has not decided yet — against the retain-first posture that already makes the Archiver refuse to hot-delete anything the cold store has not taken. Covered by its own case.Two controls, each shown able to fail
The property under test — the declared ttl cutoff governed which rows moved — is invisible to a suite that only asserts rows were archived. Two things make these cases able to fail:
whereit is handed. The pre-existinghotStore()in this file deliberately ignoreswhere(its subjects are batching and teardown), so a control built on it returns every row under either policy and can never separate them.created_atage would move and the ttl would not, and one the reverse — with both windows declared as'90d'so the cutoff instant is identical and only the column can separate them.Discriminating control, run against
origin/main(the fix reverted in the working tree, the tests kept) — 2 failed | 90 passed:Both fail for the right reason on
main: the candidate read namescreated_at, and the two stampless rows are copied because theircreated_atis past the window.Positive control —
archivewithoutttlmust keep archiving bycreated_atexactly as before. It passes onmain(it pins main's behaviour) and after the fix, so its ability to fail was proved separately, by mutating the fix into the leak it guards against (lc.ttl?.field ?? 'expires_at') — 1 failed | 91 passed, and the one that failed is this control:It is fed the same rows as the discriminating control, so the two cases answer differently on identical input. Both legs of every ablation ran on source: the suite imports the service through a relative
./lifecycle-service.js, so vitest resolves it fromsrc/, nodist/is involved, and the mutation and its restoration were each proved on disk before the run they justify (marker present, then absent; tree byte-identical toHEADafterwards).Verification
All at
ef5c36db6, the merge commit this PR pushes.packages/objectql:tsc --noEmitclean; full suite 224 files, 3959 tests passed.node scripts/pm/dispatch-gates.mjs(no path arguments — it takes the change set from the merge base), all re-run atef5c36db6, all exit 0:check:changeset-gate-self-tests·check:objectui-changeset·check:durability-log-level·check:slot-lookup·check:engine-double-contract·check:where-matcher·check:query-options-erasure·check:type-check-coverage·check:type-check-debt·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-engine-split-ratio·check-affected-docs.✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new✓ where-matcher conformance holds: 268 matcher(s) discovered, 268 answer the combinator battery correctly or refuse it loudly✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none newcheck-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 345.8s, 1924 raw tsc error(s) total, none above its recorded number. surplus: none— run on a fully built farm, so objectql's hidden test layer really was measured; the ledger has zero slack, so the ~160 new test lines adding no tsc error is a measurement, not an inference.check-engine-split-ratiois report-only by its own header. Reading unmoved by this diff (it counts commits toengine.ts/registry.ts, which this PR does not touch): 29 engine-core commits, 28 also cross-package, ratio 96.6% over the last 90 days.What this PR deliberately does NOT do
ttlaccepts no row filter, so atransientobject with terminal rows in-band cannot spare them — givettlanonlyWhenmirroringretention.onlyWhen#10165's parse-time refusal ofttl.onlyWhenbesidearchive. The fix needed no change to that refine — the Archiver's candidate read spreads no row filter, so a plainttlwas reachable without relaxing anything. lifecyclettlaccepts no row filter, so atransientobject with terminal rows in-band cannot spare them — givettlanonlyWhenmirroringretention.onlyWhen#10165's stated rationale does go stale (it says the ttl sweep never runs), and that is finding(spec): thettl.onlyWhenxarchiverefine tells authors something that stops being true once the Archiver honoursttl#10526, for the spec lane.packages/spec/src. The acceptance face is untouched:ttl+archiveparsed before and parses now.retention+ttl+archivetriple undecided — with all three declared the ttl cutoff selects and the age window no longer separately bounds the hot store. Filed as finding(objectql): withretention+ttl+archiveall declared, only the ttl cutoff bounds the hot store #10527 with its dispositions, and named in a comment at the site, rather than resolved by inventing semantics for a shape nothing in this repo declares.archive.aftereither; doing only the global half and not the per-tenant half would be a new half-feature. Filed as finding(objectql): lifecycle governance window overrides and retention floors never reach the Archiver #10528.archive.keepprune is unchanged. It bounds how long archived rows survive in cold storage, not which hot rows are due, and it still measures fromcreated_atunder either policy — asserted in the positive control.Generated by Claude Code