Skip to content

fix(service-analytics): lower a dateRange preset in runCompare instead of refusing it as an invalid date - #18241

Merged
claude[bot] merged 4 commits into
mainfrom
claude/issue-17973-analytics-compare-preset-lowering
Sep 15, 2026
Merged

claude[bot] merged 4 commits into
mainfrom
claude/issue-17973-analytics-compare-preset-lowering

Conversation

@claude

@claude claude Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #17973

Clause-②: no

What was wrong

DatasetExecutor.runCompare read the STRING arm of dateRange as [range, range] — the degenerate fallback PR #17015 removed from every other analytics face. parseUTC was handed the preset NAME, so a declared, honoured member of the closed vocabulary was refused outright.

Reproduced first, driven through the real executor on b3b43b6ea (the card's 2c87a48f line numbers had moved; the shape had not — the degenerate arm was at :1318):

### PRESET last_30_days + compareTo
### threw: Error code=DATASET_INVALID status=400 msg=[dataset-executor] invalid date in dateRange: "last_30_days"
### CONTROL explicit window -> [[["2026-01-01","2026-01-31"]],[["2025-12-01","2025-12-31"]]]
### CONTROL bogus string threw: code=DATASET_INVALID status=400

The severity is the FALSE diagnostic. last_30_days is exactly what the schema, the dashboard date filter and the docs tell an author to write, so "invalid date" sends them to check a date that is already correct — a repair that does not exist.

What changed

Both arms now go through one face lowering, lowerDatasetCompareDateRange, which calls the shared resolveAnalyticsDateRangeString for the string arm — the same call the ObjectQL strategy, the native-SQL strategy, the draft-preview evaluator and driver-memory's cube face make. ⛔ No fourth spelling of the lowering was invented.

The resolved window is then projected onto the comparison math's UTC calendar by inclusiveUtcDayWindow, because shiftRange measures previousPeriod as whole days between two day-starts and alignedCompareBucketKey reads the upper bound as the LAST bucket. endExclusive is load-bearing there: the ten calendar presets report the first instant AFTER the window, so reading their bound as inclusive would make every one of them a day too long.

That split — report the window the vocabulary resolved, keep the per-face calendar translation downstream — is the one lowerPreviewDateRange already makes when it leaves the bare-day widening in its own predicate.

Timezone precedence is buildQuery's, verbatim (selection.timezone then context.timezone then UTC), so the comparison window resolves in the same calendar as the primary pass it is compared against.

The array arm is untouched, bound for bound.

After:

### PRESET last_30_days + compareTo
### threw: NOTHING
### windows: [["last_30_days"],[["2026-07-16","2026-08-15"]]]
### CONTROL explicit window -> [[["2026-01-01","2026-01-31"]],[["2025-12-01","2025-12-31"]]]   (byte-identical)
### CONTROL bogus string threw: code=ANALYTICS_DATE_RANGE_UNRECOGNIZED status=400

runCompare is also registered as the fourth face in the shared conformance kit, as the card asks.

⚠️ One consequence worth reviewing

On this path an out-of-vocabulary dateRange STRING used to answer DATASET_INVALID; it now answers the ADR-0112 ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400 envelope the other four faces already raise. One condition, one envelope. The changeset states it as a consumer-facing note. The DATASET_INVALID "invalid date in dateRange" refusal for an ARRAY bound that is not a date is unchanged.

Pins

The card asks for a pin that asserts the preset lowers-and-shifts, not merely that the string changed. Five cases, driven end to end through DatasetExecutor.execute, expectations stated literally against the frozen clock and only cross-checked against the shared resolver — ⛔ never computed with shiftRange, which would restate the implementation and pass for any window at all:

  • a CALENDAR preset lowers with its exclusive end honoured (September stops on the 30th, not October 1st) and shifts a year back;
  • a ROLLING preset reaches NOW's own day and previousPeriod shifts by the window's own length;
  • five distinct presets select five DISTINCT comparison windows (the fallback's real signature was twelve of thirteen names collapsing onto one);
  • an unrecognised string is refused on the ENVELOPE, not on toThrow() — this face DID throw before the fix;
  • CONTROL: the caller's explicit window is still shifted bound for bound.

⛔ No existing test pinned the defect as setup, so none was repaired, skipped or weakened. The two fixtures that assert invalid date in dateRange (packages/rest/src/analytics-dataset-unlisted-refusal-envelope.test.ts:186 and packages/services/service-analytics/src/__tests__/unlisted-refusal-envelope.test.ts:264) both drive the ARRAY arm and are unaffected — measured, not assumed.

Reverse verification

The fix was committed first, then mutated back out on disk.

  • Mutation landed: anchor inclusiveUtcDayWindow(lowered) 1 to 0, degenerate [td.dateRange as string, td.dateRange as string] 0 to 1; blob e0f74df to 99f8cfb.
  • ⛔ Deliberately NOT rebuilt: the conformance test imports ../dataset-executor.js by relative specifier, so vitest reads SOURCE. Going red without a build is itself the proof of that resolution path — a dist-resolved test would have stayed falsely green.
  • Result: 4 failed, 32 passed, and the four are exactly the four wiring controls.
  • ⭐ The direction is the interesting half. The conformance-kit face registration stayed GREEN, because lowerDatasetCompareDateRange itself was untouched — which is precisely the "an exported helper conforms while the live path keeps its old fallback" gap, and precisely why the end-to-end block exists. The array-arm control and the arity pins also stayed green, as they must.
  • Restored: blob back to e0f74df (equal to the HEAD blob), git diff HEAD empty, git status --porcelain empty, anchors back to 1 and 0.

Clause-② re-derivation

Re-derived from the DELIVERED diff, not from the claim's prediction. lowerDatasetCompareDateRange is exported from its source module so the conformance runner can import it relatively, but index.ts re-exports a CLOSED named list from ./dataset-executor.js that does not include it.

read my symbol positive control shiftRange negative control lowerPreviewDateRange
in dist/index.d.ts export statement absent present absent
Symbol in await import(entry) false true false
grep in dist/index.js 2 hits 4 hits 2 hits

The third row is the point: the symbol IS in the bundle and is NOT reachable. A grep would have answered yes. The negative control is a symbol exported from its own source file, not re-exported by index.ts, and predating this diff — it proves the method distinguishes the two.

⇒ Clause-②: no, so the changeset is patch.

Verification

Gate denominator, reconciled against node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran, derived at 3a1d131fa:

Run reconciliation — 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 62 run,
  0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3).

Four of the 62 first returned exit 3, PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt) or timed out (check:query-options-erasure). ⛔ Not read as green: the full workspace closure was built (turbo run build, 72/72 successful) and all four re-run to a real exit 0.

Tests:

  • @objectstack/service-analytics full suite — 111 files, 2398 tests, 0 failures.
  • @objectstack/rest analytics door plus refusal envelope — 35 tests, 0 failures. Run because the behaviour change is observable through the REST door; the first attempt failed on an unbuilt dist, which is a resolution error and not a red gate, so service-analytics was built and it was re-run to a real verdict.
  • @objectstack/service-analytics typecheck — exit 0, and tsc --listFiles confirms the program really contains both the edited source and the edited test (1 and 1), with a negative control at 0.
  • Lint, as a PROVEN narrowing: eslint over the 2 changed source files, 0 errors 0 warnings, file count read from --format json. The invariance claim is read from eslint's own config — eslint.config.mjs sets no parserOptions.project and no typed rules, so type-aware linting is off and this diff cannot move the verdict on any untouched file. Repo-wide pnpm lint is CI's run.

Consumption-radius sweep

Swept by the RULE's consumers, not by the edited package. The changed rule is runCompare's string-arm reading and its refusal envelope; its call chain is DatasetExecutor.execute, reached through AnalyticsService.queryDataset.

  • packages/services/service-analytics — full suite green.
  • packages/rest — the only other package that drives the executor; both analytics fixtures green.
  • packages/client — SDK over a mocked transport, never runs the executor; no compareTo fixture.
  • examples/app-crm — asserts widget metadata shape only, with {current_quarter_start} filter tokens rather than a dateRange preset; no executor run.
  • driver-memory DST and timezone suites exercise the memory cube face, not this executor, and neither @objectstack/core nor driver-memory was touched.

Acceptance notes

  • DatasetExecutor's compare math is UTC-calendar throughout (parseUTC reads a bare day as UTC midnight, toISODate emits a UTC day). For a non-UTC selection.timezone a preset's resolved bounds are therefore projected onto UTC day boundaries, which can make the comparison window a day longer than the org's own calendar would say. Pre-existing for the array arm and strictly better than the 400 it replaces, so ⛔ not widened into this PR.

Generated by Claude Code

…d of refusing it

`DatasetExecutor.runCompare` read the STRING arm of `dateRange` as the
degenerate `[range, range]` fallback that #17015 removed from every other
analytics face, so `parseUTC` was handed the preset NAME and a declared,
honoured member of the closed vocabulary came back as

    DATASET_INVALID  400  invalid date in dateRange: "last_30_days"

The diagnostic is FALSE, not merely unhelpful: `last_30_days` is exactly what
the schema and the docs tell an author to write, so "invalid date" sends them
to a repair that does not exist.

Route both arms through one face lowering, `lowerDatasetCompareDateRange`,
which calls the shared `resolveAnalyticsDateRangeString` for the string arm and
`explicitDateRangeWindow` for the array arm, then project the resolved window
onto this module's UTC calendar with `inclusiveUtcDayWindow` — the per-face
calendar translation `shiftRange` and `alignedCompareBucketKey` need, with
`endExclusive` honoured so the ten calendar presets are not a day too long.

Out-of-vocabulary strings now raise the ADR-0112
`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope every sibling face raises,
instead of this face's own `DATASET_INVALID`.

The array arm is untouched, bound for bound.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
… conformance kit

The dataset executor's `compareTo` window is the fourth analytics face in this
package and the one #17015 never reached, so nothing measured it and nothing
noticed it had kept the `[range, range]` fallback.

Register it on `lowerDatasetCompareDateRange`, and add the end-to-end control
block that makes the registration mean something: driven through
`DatasetExecutor.execute`, a calendar preset lowers to the shared resolver's
window with its exclusive end honoured (September stops on the 30th, not on
October 1st) and shifts a year back; a rolling preset reaches NOW's own day and
`previousPeriod` shifts by the window's own length; five distinct presets
select five distinct comparison windows; an unrecognised string is refused on
the ADR-0112 envelope rather than on `toThrow()`; and the caller's explicit
window is still shifted bound for bound.

Expectations are stated literally against the frozen clock and cross-checked
against the shared resolver — never computed with `shiftRange`, which would
restate the implementation and pass for any window at all.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
Clause-② re-derived from the delivered diff as `no`: the new
`lowerDatasetCompareDateRange` is exported from its source module for the
conformance runner's relative import, but `index.ts` re-exports a closed named
list that does not include it, so it is absent from `dist/index.d.ts`'s export
statement and `'lowerDatasetCompareDateRange' in await import(entry)` is false.
Presence in the bundle is not reachability — it greps twice in `dist/index.js`.
⇒ patch, not minor.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 15, 2026
@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 5 documentable anchor(s).

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

  • content/docs/deployment/validating-metadata.mdx (via last_30_days (literal, a string literal on a changed line))
  • content/docs/ui/dashboards.mdx (via last_30_days (literal, a string literal on a changed line))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 9 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 b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 56f349fa5febcdd64287140ac61ec567bda4baad — the merge of head b3adf9db56d18a36059959c6cc97b1fcf7af5c65 into base b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b, 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 56f349fa5febcdd64287140ac61ec567bda4baad && git checkout 56f349fa5febcdd64287140ac61ec567bda4baad
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b b3adf9db56d18a36059959c6cc97b1fcf7af5c65 && git checkout -B drift-repro b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b && git merge --no-ff b3adf9db56d18a36059959c6cc97b1fcf7af5c65

node scripts/docs-audit/affected-docs.mjs --json b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b

⚠️ 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 b3b43b6ea0c30f35d861c9cc0fedc9c085027b7b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…non-UTC limitation

The changeset claimed `endExclusive` handling keeps "the ten calendar presets
not a day too long", unqualified. My own out-of-scope measurement falsifies
that outside UTC, and the changeset is the text an upgrading operator greps.

The two causes are independent, and the measured rows separate them: for
Asia/Shanghai the exclusive end was handled correctly (the window ends
2025-09-30, not 2025-10-01) and the extra day came from the START projecting
early; for America/New_York the start was right and the END projected late.
So `endExclusive` removes one cause of a day-too-long window and the UTC-day
projection supplies another at the opposite bound.

Narrow the clause to what the reading supports — the exclusive upper bound does
not itself add a day to the projected window — and scope the worked example to
the UTC calendar. Add the limitation as a third upgrade note with all three
measured rows, naming the shape and pointing at the card the seat filed.

Prose only: no code, test or behaviour change.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
@claude
claude Bot marked this pull request as ready for review September 15, 2026 03:28
@claude
claude Bot enabled auto-merge September 15, 2026 03:28
@claude
claude Bot added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit c81e7ff Sep 15, 2026
36 checks passed
@claude
claude Bot deleted the claude/issue-17973-analytics-compare-preset-lowering branch September 15, 2026 03:58
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…he reference calendar, not UTC (objectstack-ai#18596)

Fixes objectstack-ai#18245

Clause-②: no

A non-UTC calendar preset under `compareTo` produced a comparison window
one day too wide, silently, under an ordinary `200`.

## Mechanism

`analytics-date-range.ts` renders both bounds of the ten calendar
presets through `zonedDateStartToUtcMs`, so a lowered window is a pair
of **instants** that open and close at the *reference zone's* midnight.
`DatasetExecutor` then projected those instants onto **UTC** days,
through a local `parseUTC` / `toISODate` pair it carried itself.
Whenever the zone's midnight is not UTC's, the projection moves a
boundary — and it moves it in **opposite directions** either side of the
meridian, which is the signature of a day-boundary projection and not of
an off-by-one constant.

MEASURED through `DatasetExecutor.execute`, `this_month` + `compareTo: {
kind: 'previousYear' }`, frozen at `2026-09-09T12:00:00Z` (noon, so all
three zones read the same calendar day):

| timezone | before (`e0d05538c`) | after (`dfb909bd0`) |
|---|---|---|
| `UTC` | `['2025-09-01','2025-09-30']` — 30 days, the positive control
| `['2025-09-01','2025-09-30']` — 30 days, unchanged |
| `Asia/Shanghai` | `['2025-08-31','2025-09-30']` — 31 days, **starts a
day early** | `['2025-09-01','2025-09-30']` — 30 days |
| `America/New_York` | `['2025-09-01','2025-10-01']` — 31 days, **ends a
day late** | `['2025-09-01','2025-09-30']` — 30 days |

The UTC row is committed as a **lit positive control**, not a comment: a
fix that shifted a constant would green one non-UTC row, break UTC, and
read as a pass to any suite that measured a single zone. Two further
zones at the extremes of the offset range (`Pacific/Kiritimati` at +14,
`Pacific/Niue` at −11) are pinned in the same file.

Not a regression of objectstack-ai#18241. Before it this input was a hard
`DATASET_INVALID` / 400 — the preset arm could not produce a window at
all. What changed is **reachability**: a refusal became a
slightly-too-wide answer for non-UTC orgs and a correct one for UTC
orgs.

## The fix is a deletion, and `packages/core` takes zero edits

The local `parseUTC` / `toISODate` pair is **removed**. Nothing
timezone-aware is written in its place — the shared vocabulary already
exports both directions and this package already calls one of them one
file over (`analytics-service.ts:23` / `:1623`):

- **A bare `YYYY-MM-DD` is a calendar day, not an instant.** The year
shift, the `previousPeriod` length and the bucket ordinals are calendar
arithmetic that no zone changes, so they run on the **zone-free UTC
proxy** `zonedDateStartToUtcMs` yields for an unset zone — the pattern
`analytics-date-range.ts`'s own header prescribes ("anchors on the
reference timezone's calendar day and does its arithmetic on a UTC
proxy").
- **One seam reaches a reference zone**: turning the lowered window's
instants into days. It calls `@objectstack/core`'s `bucketDateKey` at
`'day'` — the same `Intl`-backed extraction the runtime's grouping
labels rows with, and the exact inverse of the `zonedDateStartToUtcMs`
that produced those bounds — threaded with the timezone `buildQuery`
already resolves the primary pass in.

Threading a zone into the *arithmetic* instead would put DST in the
middle of a year shift: `2026-03-09` is `04:00Z` in `America/New_York`
(EDT) and that same instant a year earlier reads `2025-03-08T23:00` EST
— a different day. That is why the zone stops at the seam.

Zero edits in `packages/core`, zero in `packages/spec`, and zero change
to this file's exported surface (18 `export` lines before, 18 after, no
diff) — hence `Clause-②: no`.

## Ablation — a green alone is not evidence

The fix was committed first, then the UTC projection was put back at
that one seam (the two `timezone` arguments dropped from
`inclusiveCalendarDayWindow`), proven on disk before the run (injected
spellings present 1/1, replaced spellings remaining 0/0, blob hash moved
`9bd0156…` to `aef7dd9…`), and restored afterwards to byte-identical
`9bd0156…` with `git diff HEAD` empty:

```
× Asia/Shanghai — does not start a day early (was 2025-08-31, 31 days)
  AssertionError: the zone is EAST of UTC, so its midnight is the PREVIOUS UTC day:
  expected '2025-08-31' to be '2025-09-01'
× America/New_York — does not end a day late (was 2025-10-01, 31 days)
  AssertionError: the zone is WEST of UTC, so its next-month midnight is the NEXT UTC day:
  expected '2025-10-01' to be '2025-09-30'
× every reference zone reports the same 30-day window
  AssertionError: expected { UTC: 30, 'Asia/Shanghai': 31, …(3) }
                  to deeply equal { UTC: 30, 'Asia/Shanghai': 30, …(3) }
Tests  3 failed | 1 passed (4)
```

The UTC control stayed green under ablation, which is the half that
distinguishes this defect from a constant. The test subject is imported
by a relative specifier (`../dataset-executor.js`), so vitest reads the
mutated source directly — there is no `dist` leg in this ablation's
resolution path, and the package declares no vitest alias.

## Verification

All at `dfb909bd0`, exit codes captured to disk before being read.

- `pnpm --filter '@objectstack/service-analytics^...' build` — exit 0.
- `pnpm --filter @objectstack/service-analytics test` — **112 files,
2403 tests passed**.
- `pnpm --filter @objectstack/service-analytics typecheck` — exit 0;
`tsc --noEmit --listFiles` confirms the new test file is in the program.
- `pnpm lint` (repo-wide `eslint . --no-inline-config`) — exit 0. Whole
population, no narrowing claimed.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`
derived **61** families; all 61 were run and reconciled with `--ran`
carrying each exit code. **57 green.** Three exited 3 (`PREREQUISITE NOT
MET`, i.e. NOT MEASURED, not a pass and not a finding):
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt` — each needs a whole-repo `dist`, which CI
builds.
- One exited 1, **pre-existing and not this diff's**:
`check:cross-package-test-inputs` flags
`packages/cli/test/init-created-files-summary.e2e.test.ts` descending
into `packages/spec/dist/`. Control: reverting all three of this PR's
paths to the merge base, leaving the same tree and the same on-disk
`packages/spec/dist`, reproduces the identical failure. It is a local
build-state artefact — the gate walks a gitignored directory — and a
sibling checkout with a different `packages/spec/dist` exits 0.

## Acceptance notes

Observed while in the file, deliberately **not** changed here:

- **The explicit-array arm's timestamp bounds are still read on the UTC
calendar.** `['2026-09-01T00:00:00Z', …]` bypasses the seam entirely and
reaches `shiftRange` unprojected, so a bound falling between the zone's
midnight and UTC's would shift the same way the preset arm did.
**Unmeasured** — and the arm's own contract states that a bare day
versus a full timestamp is a per-face calendar translation (objectstack-ai#3777 /
objectstack-ai#4042) that its arity rule does not touch, so it is not obviously a
defect rather than a declared boundary. Successor: the next card in the
analytics `dateRange` lane (objectstack-ai#17015 / objectstack-ai#17124 / objectstack-ai#17596) that opens
`date-range-array-arm.ts`.
- **`isoWeekKeyOfUtcMs` is a hand-copy of core's ISO-week rule.** It is
documented as deliberate (core's `isoWeekLabelFromCalendarDay` is
module-private) and held honest by a round-trip pin against the exported
`bucketKeyToCalendarRange`, so it is a recorded decision rather than
drift. No successor needed.

Neither is filed: the first is not measured, the second is declared.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01WmBwEiWPff9JZPd5BSGNeH)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant