Skip to content

fix(objectql): the Archiver honours a declared ttl instead of archiving by created_at age alone - #10541

Merged
os-elon merged 3 commits into
mainfrom
claude/issue-10347-archive-honours-ttl
Aug 21, 2026
Merged

fix(objectql): the Archiver honours a declared ttl instead of archiving by created_at age alone#10541
os-elon merged 3 commits into
mainfrom
claude/issue-10347-archive-honours-ttl

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#10347

Maintainer ruling 2026-08-20, option 2 from the card body: when a lifecycle declares both ttl and archive, archiving runs against the ttl cutoff rather than created_at age alone. What the author declared is what executes.

The defect

LifecycleSchema accepts ttl beside archive — ADR-0057 §3.5's refine is satisfied because ttl is a bounding policy, and the archive.after === retention.maxAge refine only fires when retention is present, which the pair skips. But LifecycleService.reapObject short-circuits with if (lc.archive) return this.archiveObject(...)before its ttl branch is reachable, so no reap on ttl.field ever ran and the Archiver copied and hot-deleted by created_at age alone. Declared, not enforced.

The change

Three functional lines, all in packages/objectql/src/lifecycle/lifecycle-service.ts, inside archiveObject:

constdueField=lc.ttl ? lc.ttl.field : 'created_at';constdueWindow=lc.ttl ? lc.ttl.expireAfter : archive.after;constcutoff=newDate(this.now()-parseLifecycleDuration(dueWindow)).toISOString();// ... and the batch loop's candidate read:where: {[dueField]: {$lt: cutoff}},

Everything downstream is untouched: the same copy-then-hot-delete pair, the same batching and abort checkpoints, the same report.swept entry. 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 ttl declared, dueField/dueWindow resolve to exactly today's created_at / archive.after. Every archive-declaring object shipped with the platform is that shape — censused, not assumed: sys_audit_log and sys_metadata_audit are the only two, and both declare retention + archive, no ttl.

A missing or null ttl.field — the decision

A row with no expiry stamp is retained, not archived. It is not "due at the epoch".

Mechanically this falls out of the predicate: $lt is a positive comparison, and a value that is not there satisfies none of them — the platform-wide null answer settled in #5298/#5299 and spelled default: return false in every backend's nullValueSatisfiesOperator. 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:

  1. A hot-store fake that really evaluates the where it is handed. The pre-existing hotStore() in this file deliberately ignores where (its subjects are batching and teardown), so a control built on it returns every row under either policy and can never separate them.
  2. Rows on which the two policies disagree in both directions — one row created_at age 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:

FAIL DISCRIMINATING CONTROL: a declared ttl decides which rows move — not created_at age
AssertionError: expected [ Array(1) ] to deeply equal [ Array(1) ]
- "expires_at": {
+ "created_at": {
"$lt": "2023-08-16T22:13:20.000Z",
FAIL a row whose ttl.field is null or absent is NOT due at the epoch — it is retained
AssertionError: expected [ 'null-stamp', 'absent-stamp', …(1) ] to deeply equal [ 'expired' ]
+ "null-stamp",
+ "absent-stamp",
"expired",

Both fail for the right reason on main: the candidate read names created_at, and the two stampless rows are copied because their created_at is past the window.

Positive controlarchive without ttl must keep archiving by created_at exactly as before. It passes on main (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:

FAIL POSITIVE CONTROL: archive WITHOUT ttl still moves rows by created_at age
- "created_at": {
+ "expires_at": {

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 from src/, no dist/ 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 to HEAD afterwards).

Verification

All at ef5c36db6, the merge commit this PR pushes.

  • packages/objectql: tsc --noEmit clean; full suite 224 files, 3959 tests passed.
  • Gate union derived by node scripts/pm/dispatch-gates.mjs (no path arguments — it takes the change set from the merge base), all re-run at ef5c36db6, 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.
  • Verdict lines rather than exit codes, for the ratchets that could have moved:
    • ✓ 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 new
    • check-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-ratio is report-only by its own header. Reading unmoved by this diff (it counts commits to engine.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


Generated by Claude Code

os-elonand others added 3 commits August 21, 2026 01:43
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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 3 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/drivers.mdx(via LifecycleService (symbol))
  • content/docs/data-modeling/objects.mdx(via LifecycleService (symbol))
  • content/docs/kernel/services.mdx(via LifecycleService (symbol))
  • content/docs/permissions/attachments-access.mdx(via LifecycleService (symbol))
  • content/docs/protocol/knowledge.mdx(via LifecycleService (symbol))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx(via LifecycleService (symbol))
  • content/docs/releases/v15.mdx(via LifecycleService (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 34 pages)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 359f5956d7910aed7ae9f8fccc9fbb988b3e4882packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7d74542ab7818abdd59fcfcbfb508d25e0d03e84 — the merge of head ef5c36db6eccd377b1810c1896812ef99a8b6c1a into base 359f5956d7910aed7ae9f8fccc9fbb988b3e4882, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 359f5956d7910aed7ae9f8fccc9fbb988b3e4882 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 21, 2026
@os-elon
os-elon marked this pull request as ready for review August 21, 2026 03:17
@os-elon
os-elon added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 530c1dfAug 21, 2026
32 checks passed
@os-elon
os-elon deleted the claude/issue-10347-archive-honours-ttl branch August 21, 2026 03:47
os-elon pushed a commit that referenced this pull request Aug 21, 2026
…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@os-elon