fix(metadata): compute the history retention cutoff on one calendar, not two - #16060
fix(metadata): compute the history retention cutoff on one calendar, not two#16060claude[bot] wants to merge 1 commit into
Conversation
…not two
`HistoryCleanupManager` built its `recorded_at: { $lt: … }` bound with
`setDate(getDate() - maxAgeDays)` — the LOCAL calendar — and rendered it with
`toISOString()` — UTC. `setDate` preserves wall-clock time, so a window that
straddles a DST transition moves the instant by 23h or 25h per day-step instead
of 24h, and the cutoff handed to a DELETE filter slips by the size of that
transition. Both call sites (the delete path in `runCleanup()` and the preview
count in `getCleanupStats()`) carried the byte-identical spelling; both now use
`setUTCDate`/`getUTCDate`.
Pinned by `history-cleanup-dst.test.ts`: 16 MEASURED cells from a 12-zone x
366-day x 48-half-hour x 7-maxAgeDays sweep of 2026, both hemispheres, both
transition directions, three sub-hour standard offsets and one sub-hour
transition (Lord_Howe, 30 minutes) — each cell carrying an inline control that
asserts the old spelling disagrees there, so a green run cannot be mistaken for
"the instants were not in a transition window".
Not in scope, deliberately: making retention timezone-aware, and the ordinary
preview-vs-delete disagreement that comes from the two sites reading
`new Date()` independently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 13 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 afd7002674f16d3ac0ee3bb3431b8e8c6db90156 && git checkout afd7002674f16d3ac0ee3bb3431b8e8c6db90156
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 53cbad9f75572600ef43bb2a18071633fd6c0f68 d2308c952989b31814c2665b0b824b31891dadd0 && git checkout -B drift-repro 53cbad9f75572600ef43bb2a18071633fd6c0f68 && git merge --no-ff d2308c952989b31814c2665b0b824b31891dadd0
node scripts/docs-audit/affected-docs.mjs --json 53cbad9f75572600ef43bb2a18071633fd6c0f68 |
Fixes #15824
The defect
packages/metadata/src/utils/history-cleanup.tsbuilt its retention cutoff on the local calendar and rendered it on UTC:setDatepreserves wall-clock time, so stepping the local calendar backndays moves the instant by exactlyn × 24honly while every local day in the window is 24 hours long. When the window straddles a DST transition it is 23 hours (spring-forward) or 25 (fall-back), and the cutoff instant slips by the size of that transition — one hour in most zones, thirty minutes on Lord Howe Island.Both sites, one change
The byte-identical spelling appeared twice. Line numbers re-derived at merge base
e1d4f9e3f:e1d4f9e3frunCleanup():96bulkDeleteByFilter— a real$ltDELETEgetCleanupStats():259driver.count— the previewed row countBoth now spell it
cutoffDate.setUTCDate(cutoffDate.getUTCDate() - this.policy.maxAgeDays).Measured blast radius
The card describes the error as "twice a year". That is exact for
maxAgeDays: 1, and an understatement above it: the window only has to straddle a transition, with no second condition about crossing a UTC midnight, so the exposure grows with the retention period. Measured over a 16-zone × 366-day × 48-half-hour sweep of 2026, as the fraction of instants at which the old spelling produced a wrong cutoff:maxAgeDays: 1America/New_YorkEurope/BerlinAustralia/SydneyPacific/AucklandUTC,Asia/Shanghai,Asia/Kolkata,Australia/PerthThat bottom row is why nothing in CI has ever gone red on this, and why the pin below cannot be written to run only at
TZ=UTC.The pin
packages/metadata/src/utils/history-cleanup-dst.test.ts— 27 tests, of which 16 are MEASURED DST cells, drawn from a 12-zone × 366-day × 48-half-hour × 7-maxAgeDayssweep of 2026 that found 347,539 disagreeing combinations. Both hemispheres, both transition directions,maxAgeDaysfrom 1 to 180, three zones whose standard offset is not a whole hour (America/St_Johns−03:30,Australia/Adelaide+09:30,Pacific/Chatham+12:45), and one whose transition is not a whole hour (Australia/Lord_Howe, ±30 minutes) so a whole-hour assumption cannot hide in the fix.Each cell fakes both halves of the environment (
process.env.TZplus a frozen clock) and carries an inline control that asserts the old mixed spelling disagrees there — evaluated before the assertion about the subject. Without it a green run would be ambiguous between "the fix works" and "these instants were not in a transition window", the second being the failure mode that hid the defect. Every cell asserts both call sites, against an oracle that is the definition of the window (instant − maxAgeDays × 86_400_000) rather than a re-implementation of the fix.Ablation
The mutation was proven on disk before the measurement and the restore proven after, in one shell:
All 16 measured cells reddened, plus the timezone-invariance fence and both filter-scoping fences. The 8 that stayed green are the ones that do not read the subject's spelling — the cell-table meta-assertions, the non-DST fences and the TZ-restoration fence — which is what they are for.
Fences — what this deliberately does NOT do
maxAgeDays, the cutoff is now identical in every zone, DST-observing or not.new Date()independently, so they can straddle any boundary for that ordinary reason. That is inherent and this change does not remove it; each site is pinned against the truth separately, at one frozen instant.organization_idscoping, the ADR-0009executionPinned$ninexclusion, themaxVersionspath, and that a policy withoutmaxAgeDaysbuilds no age filter at all.Published-surface reading
Clause-②: no, both limbs, re-derived rather than assumed —HistoryCleanupManageris exported public API (packages/metadata/src/index.ts:33), so the question is live.@objectstack/metadataat this head, swapped the one changed source file back toe1d4f9e3f, rebuilt, diffed the emitteddist/index.d.ts: byte-identical,git hash-object=79b154732b61f586e182e1678c11af085c9cbaddon both sides,diff -uexit 0 / 0 lines. Source restored byte-exact afterwards and proven (blob == HEAD blob,git diff HEADempty), and the post-restore rebuild reproduced the samed.tshash.MetadataHistoryRetentionPolicyis untouched;maxAgeDaysstill accepts exactlyz.number().int().positive().optional(). What changes is the cutoff instant — an answer computed from the policy — not which inputs are accepted or rejected.Verification
All readings below are at
d2308c952, with each exit code captured before any pipe.pnpm --filter @objectstack/metadata testPKG_TEST_EXIT=0—Test Files 48 passed (48),Tests 745 passed (745)pnpm --filter @objectstack/metadata typecheckPKG_TYPECHECK_EXIT=0tsc --noEmit --listFiles: 1 hit forhistory-cleanup-dst.test.ts, 1 forutils/history-cleanup.ts, of 547 files — not a green over source nothing readnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackatd2308c952, provenance line checked; 53 families, all runPREREQUISITE NOT MET, belowpnpm lint(whole repo,eslint . --no-inline-config)LINT_FULL_EXIT=0, no findings — the full scan, not a narrowed oneGate verdict lines worth quoting:
The two exit-3 families are NOT MEASURED, in their own words, and both need a full workspace build that CI performs:
pnpm check:dual-build-cjs-loads— self-test passed (93 cases); thenPREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.…⛔ This is NOT a pass: nothing was measured.pnpm check:type-check-debt— the coverage leg passed (check-type-check-coverage: OK — 75/79 workspace packages type-checked); the--re-measureleg refused:20 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk…⛔ This is NOT a pass and NOT a finding.Generated by Claude Code