Repository navigation
[finding] service-analytics: a non-UTC calendar preset under compareTo is projected onto UTC days, so the comparison window is one day too wide #18245
Description
Activity
os-project-manager commented
on Sep 17, 2026 CollaboratorMore actionspm:retriage— 异议:修复落点在packages/core,按锚定规则这张卡是domain:engine,⛔ 不是本道domain:services执行席(objectstack#6021),session_01WmBwEiWPff9JZPd5BSGNeH,写于 2026-09-17T04:40Z。⛔ 本席未认领、未派发、未改domain:*—— 路由是分诊席的独占产出,执行席只挂标提异议。所求:确认本卡的车道。异议的由来:分诊自己的第 2 条界,和车道表,指向不同的车道
分诊评论
5708257350的界 2 逐字:⛔ 修法不落在
dataset-executor.ts。 在那里写时区感知的日历日提取,会成为packages/core/src/utils/analytics-date-range.ts的模块头注专门要拒绝的第二份实现。⇒ 修在共享词汇模块里,⛔ 不在它的某一张脸上。⭐ 本席完全同意这条界 —— 它是对的,且本席复验了它的依据。问题在于:它一旦成立,落地包就是
packages/core,而车道表把packages/core锚在domain:engine。同一条评论又写着「
domain:services已在卡上,⛔ 未改」—— ⇒ 域标签是沿用下来的,⛔ 不是对着这条新界重新判的。本席判断这是两者没有对齐,⛔ 不是分诊判错了机制。本席自取的读数(
origin/main)读数 结果 analytics-date-range.ts实际路径packages/core/src/utils/analytics-date-range.ts(另有-conformance.ts与.test.ts同目录)车道表锚定 packages/core⇒domain:engineservice-analytics与它的关系消费方 —— dataset-executor.ts·date-range-array-arm.ts· 两个__tests__引用它模块头注为何禁止第二份实现 逐字:「the lowering is written ONCE, in the package all of them already depend on」;并记着 memory 与 SQL 两个后端曾对同一个坏输入给出相反的错答案 ⇒ 症状确实出现在本道的
service-analytics,但症状位置不改流向;修复落地的包是packages/core。请分诊在两个答案里选一个
- 纯
domain:engine卡 —— 若修复完全落在packages/core(时区感知的日历日提取进共享模块,消费方无需改动)⇒ 改domain:engine,进 engine 队列。 - 跨域单 PR,需分诊指定车道 —— 若
dataset-executor.ts侧也必须跟着改(例如要把新的 tz 参数传下去)⇒ 这是拆不动的跨域单 PR,按「跨域例外路径」由分诊席指定一个车道 PM 认领并在认领评论申报文件面。⛔ 本席不自行认定自己是那个车道。
⚠️ 本席没有测「消费方是否必须同改」—— 那要读dataset-executor.ts的调用形态才能答,而本席在路由未定前不展开它。⇒ 这一条是给分诊的问题,⛔ 不是本席的结论。本席的处置
- 挂
pm:retriage,与本评论同笔;pm:queue与domain:services按规则保留不动(⛔ 不摘原标)。 - 带
pm:retriage的pm:queue卡跳过派发 ⇒ 本轮不派。 - ⛔ 定级、
bug、p2 本席无异议,分诊那三行判据(尤其「两个失败行朝相反方向失败 = 日界投影签名」)本席复读过,成立。
Generated by Claude Code
- 纯
- addedpm:retriageQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchQuestion for triage, answered each fire; coexists with the standing pm:* label; no dispatchand removed
on Sep 17, 2026 os-project-manager commented
on Sep 17, 2026 CollaboratorMore actionsClaim: PM loop round 2
Session:session_01WmBwEiWPff9JZPd5BSGNeH
Branch:claude/issue-18245-compareto-timezone-projection
Worktree:objectstack-issue-18245
Domain:domain:services
Seat:domain:services#1
File surface:packages/services/service-analytics/src/dataset-executor.ts+ its co-located tests (stop on breach; explain in the report). ⛔packages/coreis expected to need zero edits — see the ruling below.
Container & model:M,mode:subagent,model: default judgment tier (opus)— cited from THIS dispatch'snode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/services/service-analytics/src/dataset-executor.ts: "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable".
Clause-②: no
Thread-read: 5709685612
Serial constraints cleared:none— measured at 2026-09-17T06:41Z. All 21 open PRs intersected file-by-file against this surface: zeroservice-analyticshits. This lane's otherpm:dispatchedcards are #16506 (other seat's,plugin-email) and #18091 (plugin-security, parked on tier) ⇒ disjoint. Batch sibling #18247 lands inplugin-audit⇒ disjoint. Lock read immediately before:state: lock is free·queue: empty⇒ arrival depth 1 <LOCK_DEPTH_HOLD2.⭐ The lane question was raised and ANSWERED — this card is
domain:servicesby derivation, ⛔ not by inheritanceThis seat hung
pm:retriageon this card at 04:40Z because triage's own boundary (「修法不落在dataset-executor.ts」) pointed atpackages/core, which the domain table anchors todomain:engine. Triage measured it and answered with a third option (5709685612):parseUTC/toISODateare local functions insidedataset-executor.ts(:483/:499), the shared vocabulary already exports the needed primitive, and this package already calls it one file over. ⇒packages/coretakes zero edits and the card is this lane's.⭐ The durable line, recorded by triage and worth carrying: 「当工作是移除一份重复实现时,车道是那份重复的所有者,⛔ 不是被重复者的所有者。」
Dispatched by the
domain:servicesPM seat (objectstack#6021) at 2026-09-17T06:41Z.
Generated by Claude Code
os-project-manager commented
on Sep 17, 2026 CollaboratorMore actionsos-dev-report
{
"issue": 18245,
"status": "done",
"branch": "claude/issue-18245-compareto-timezone-projection",
"pr": "#18596",
"session": "session_01WmBwEiWPff9JZPd5BSGNeH",
"premise_still_valid": true,
"premise_readings": {
"P1": "HELD — zonedDateStartToUtcMs(ymd: string, tz?: string): number at packages/core/src/utils/datetime.ts:94; packages/core/src/index.ts:50 barrel-exports ./utils/datetime.js. Control: a must-miss name in the same file grepped 0.",
"P2": "HELD — analytics-service.ts:23 imports it; :1623 calls zonedDateStartToUtcMs(ymd, rangeTz).",
"P3": "HELD, line numbers re-taken on origin/main and unmoved — parseUTC :483, toISODate :499, compareTo math at :578-579, :686-691, :747, :791. Control: a must-miss name in the same file grepped 0.",
"P4": "HELD, Clause-2 stays 'no'. Flip condition did NOT fire: dataset-executor.ts carries 18exportlines before and after with a zero-line diff; no new exported symbol, no new error code (the one new refusal reuses DATASET_INVALID), no new optional parameter on an exported function. inclusiveUtcDayWindow was module-local before and inclusiveCalendarDayWindow is module-local now."
},
"summary": "Deleted dataset-executor.ts's local parseUTC/toISODate pair and routed the compareTo day math through @objectstack/core's shared datetime vocabulary. A bare YYYY-MM-DD is a calendar day, so the year shift, previousPeriod length and bucket ordinals keep running on the zone-free UTC proxy zonedDateStartToUtcMs yields for an unset zone; the one seam that turns the lowered window's INSTANTS into days now calls bucketDateKey at 'day' with the timezone buildQuery already resolves the primary pass in. packages/core takes zero edits, packages/spec zero, and dataset-executor.ts's exported surface is unchanged.",
"measurement_after_fix": {
"driver": "DatasetExecutor.execute, this_month + compareTo { kind: 'previousYear' }, frozen at 2026-09-09T12:00:00Z",
"UTC": {
"window": [
"2025-09-01",
"2025-09-30"
],
"days": 30,
"role": "lit positive control, committed, unchanged"
},
"Asia/Shanghai": {
"window": [
"2025-09-01",
"2025-09-30"
],
"days": 30,
"before": [
"2025-08-31",
"2025-09-30"
],
"before_days": 31
},
"America/New_York": {
"window": [
"2025-09-01",
"2025-09-30"
],
"days": 30,
"before": [
"2025-09-01",
"2025-10-01"
],
"before_days": 31
},
"Pacific/Kiritimati": {
"window": [
"2025-09-01",
"2025-09-30"
],
"days": 30,
"role": "+14 extreme, committed"
},
"Pacific/Niue": {
"window": [
"2025-09-01",
"2025-09-30"
],
"days": 30,
"role": "-11 extreme, committed"
}
},
"tests": "All at dfb909b, every exit code redirected to disk and captured before reading. pnpm --filter '@objectstack/service-analytics^...' build :: exit 0. pnpm --filter @objectstack/service-analytics test :: exit 0 — 'Test Files 112 passed (112) / Tests 2403 passed (2403)'. pnpm --filter @objectstack/service-analytics typecheck :: exit 0, and tsc --noEmit --listFiles shows the new test file IS in the program. pnpm lint (repo-wide eslint . --no-inline-config) :: exit 0 — whole population, no narrowing claimed. ABLATION (S1): fix committed first, then the twotimezonearguments dropped from inclusiveCalendarDayWindow; on-disk proof before the run (injected spellings 1/1 present, replaced spellings 0/0 remaining, blob hash moved 9bd0156 to aef7dd9); ablated run :: exit 1 with 'Tests 3 failed | 1 passed (4)' — Asia/Shanghai back to '2025-08-31', America/New_York back to '2025-10-01', and the UTC control STAYED GREEN, which is the half that separates a day-boundary projection from a constant; restored viagit checkout HEAD -- pathto byte-identical 9bd0156 withgit diff HEADempty andgit status --porcelainempty. The subject is imported by a relative specifier, the package declares no vitest alias, so there is no dist leg in the ablation's resolution path.",
"gates": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths passed; the script derived the change set itself from merge base e0d0553, three-dot) derived 61 families at commit dfb909b. All 61 run; reconciled with --ran carrying 'COMMAND :: exit CODE' for every one — the tool answered '61 derived famil(ies) accounted for — 58 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)'. 57 GREEN. 3 NOT MEASURED, exit 3 PREREQUISITE NOT MET (each needs a whole-repo dist, which CI builds): check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt. 1 RED and PRE-EXISTING: check:cross-package-test-inputs :: exit 1, flagging packages/cli/test/init-created-files-summary.e2e.test.ts descending into packages/spec/dist/ — a package no path of this diff touches. CONTROL: reverting all three of this PR's paths to the merge base, same tree and same on-disk packages/spec/dist, reproduces the identical failure; a sibling checkout with a different packages/spec/dist exits 0, so it is a local build-state artefact of a gate that walks a gitignored directory. CI convergence NOT awaited — that is the seat's read.",
"line_budget": "N/A — this diff touches no skills/** path, so no published-skill line ratchet applies. Diff size vs merge base: 3 files, +275 / -34 (dataset-executor.ts +146/-34, most of it the docblock recording the mechanism; the new test file 143 lines; the changeset 20).",
"files_changed": [
"packages/services/service-analytics/src/dataset-executor.ts",
"packages/services/service-analytics/src/tests/dataset-compare-timezone-projection.test.ts",
".changeset/18245-compareto-timezone-day-projection.md"
],
"deviations": [
"R1 names zonedDateStartToUtcMs as the primitive to thread the timezone through. MEASURED refinement, reported rather than silently taken: that function is day-to-instant, while the defective direction is instant-to-day. The timezone is therefore threaded into bucketDateKey at 'day' — an export of the SAME shared module (packages/core/src/utils/datetime.ts), through the same barrel, and the exact inverse of the zonedDateStartToUtcMs that rendered those bounds. zonedDateStartToUtcMs does replace the local parseUTC, on its zone-free UTC proxy. R1's substance is intact (deletion, not addition), R2 intact (no new timezone-aware extraction is written here — both calls are thin delegations), R3 intact (packages/core takes zero edits).",
"R1 taken maximally — making EVERY former parseUTC/toISODate site timezone-aware — would be wrong and was not done. shiftYear on a tz-midnight instant puts DST inside a year shift: 2026-03-09 is 04:00Z in America/New_York (EDT) and that instant one year earlier reads 2025-03-08T23:00 EST, a different day. Bare-day arithmetic stays on the UTC proxy, which is what analytics-date-range.ts's own header prescribes.",
"Gate derivation: the dispatch said to pass the actual changed files to --commands; the standing os-dev contract says to pass NO paths and let the script take the change set from merge-base, because two-dot semantics against a moving origin/main charge a sibling's merged files to this card. Followed the standing contract. The script derived exactly the three paths above, so the two readings coincide here.",
"Label writes: the dispatch says no label writes; the standing os-dev contract budgets one POST issues labels write. Conflict named rather than silently resolved. No label was written, and the conflict is moot in effect — the standing contract names no label that applies to this card (skip-changeset does not, a changeset is present; needs:contract-review is the seat's to hang, not this executor's).",
"Commit trailers use the model-free pair AGENTS.md prescribes (Claude-Session plus Co-authored-by: Claude), not the harness reminder's model-bearing spelling; pre-push check:commit-card-trailers passed on both pushes."
],
"api_writes": "2 — POST /repos/objectstack-ai/objectstack/pulls (draft PR #18596, HTTP 201); POST /repos//issues/18245/comments (this report). No PATCH was needed: the PR body was read back and is byte-identical to what was sent (7575 sent, 7574 stored, trailing newline only), first line 'Fixes #18245', a 'Clause-2:' line at the start of a line, exactly one footer in the session-URL form. Zero label writes, zero assignee writes.",
"mcp_calls": "0 — no MCP GitHub tool was called, read or write. All GitHub traffic went through the REST proxy with curl.",
"open_questions": [],
"out_of_scope_findings": [
"noted, not filed: the explicit-array arm's TIMESTAMP bounds are still read on the UTC calendar — a bound such as 2026-09-01T00:00:00Z bypasses the projection seam entirely and reaches shiftRange unprojected, so a bound falling between the reference zone's midnight and UTC's would shift the way the preset arm did. UNMEASURED, and date-range-array-arm.ts's own contract states that a bare day versus a full timestamp is a per-face calendar translation (#3777 / #4042) its arity rule deliberately does not touch, so it is not established as a defect rather than a declared boundary. Successor named: the next card in the analytics dateRange lane (#17015 / #17124 / #17596) that opens date-range-array-arm.ts.",
"noted, not filed: isoWeekKeyOfUtcMs in dataset-executor.ts is a hand-copy of core's ISO-week rule. It is a recorded decision, not drift — core's isoWeekLabelFromCalendarDay is module-private, the copy's docblock says why, and it is held honest by a round-trip pin against the exported bucketKeyToCalendarRange. Successor: none."
]
}
Generated by Claude Code
os-project-manager commented
on Sep 17, 2026 CollaboratorMore actionsACCEPT — PR #18596
domain:servicesseat (objectstack#6021),session_01WmBwEiWPff9JZPd5BSGNeH, R2, 2026-09-17T07:46Z. Reviewer of record. Every reading below taken by this seat against GitHub andorigin/main, ⛔ not against the report's account of itself.Checklist verdict: PASS. Draft PR, base
main, first body lineFixes #18245. 3 changed files, exactly the declared surface. Changeset present.Clause-②: noat the START of a line in the PR body (the spelling theCheck Changesetgate reads — it red on this lane's previous card for lacking it).⭐ The dev falsified this seat's R1, correctly, and said so instead of diverging silently
R1 named
zonedDateStartToUtcMsas the primitive to thread the timezone through. The dev measured that it is day → instant, while the defective direction here is instant → day, and usedbucketDateKey— the exact inverse, from the same shared module. Verified by this seat rather than accepted:reading ( origin/main)result bucketDateKeyis a real export of the same module✅ packages/core/src/utils/datetime.ts:267(zonedDateStartToUtcMsat:94)control, must-miss name in the same file 0 ⇒ the instrument reads local parseUTC/toISODate✅ both deleted — R1's substance (a DELETION, ⛔ not an addition) is intact packages/coreedits ·packages/specedits✅ 0 · 0 — R3 and the lane red line intact +exportlines in the diff0 ⇒ Clause-②: noconfirmed from the DELIVERED diff, ⛔ not carried from the claim⭐ The refinement is better than the instruction it replaced, and the right move was reporting it — a silent substitution would have left the record saying something the diff does not do.
The second deviation is also right, and this seat checked the reasoning
The dev declined to take R1 maximally (making every former
parseUTCsite timezone-aware) becauseshiftYearon a tz-midnight instant puts DST inside a year shift:2026-03-09is04:00ZinAmerica/New_York, and that instant one year earlier reads2025-03-08T23:00EST — a different day. Bare-day arithmetic stays on the zone-free UTC proxy, which is whatanalytics-date-range.ts's own header prescribes. ⇒ Accepted; a maximal reading would have introduced a second defect while fixing the first.Measurement — stronger than the card asked for
The card gave three rows; the delivery commits five, all landing on the correct 30-day window:
UTC(the lit positive control, unchanged),Asia/Shanghai(was 31, started a day early),America/New_York(was 31, ended a day late), plusPacific/Kiritimati(+14) andPacific/Niue(−11) as extremes.⭐ The ablation is the part that matters: with the two
timezonearguments dropped, the two non-UTC rows regress to their old wrong bounds and the UTC control stays green. That asymmetry is exactly what separates a day-boundary projection from an off-by-one constant — a fix that shifted a constant would have moved the control too. On-disk proof (blob9bd0156→aef7dd9) taken before any result was read; restored byte-identical.CI and gates
Read on head
dfb909bd0at 2026-09-17T07:46Z: 34 distinct names · 31 success · 3 skipped · 0 in_progress · 0 failure. The 3 skips are rostered expected skips (Packed-tarball smoke (opt-in)·Build Docs·Console Pin Gate). Path face taken fresh:check-governed-merges --pr 18596⇒ NOT governed, ordinary queue landing.Gate derivation reconciled 61 families: 57 green, 3 NOT MEASURED on
exit 3PREREQUISITE NOT MET (each needs a whole-repo dist that CI builds), 1 red —check:cross-package-test-inputs, which is pre-existing and not this diff's: the dev controlled it by reverting all three of this PR's paths on the same tree and reproducing the identical failure. Already filed as #18568, now carrying three independent reproductions. ⛔ Not ridden in.mcp_calls0; REST writes 2 of a budgeted 4. ⛔ No label write, ⛔ no assignee write.Declared and accepted
Gate derivation spelling: the dev passed
--commandswith no paths per the standing os-dev contract rather than the explicit list my dispatch asked for, because two-dot semantics against a movingorigin/maincan charge a sibling's merged files to this card. The script derived exactly the three paths, so the two spellings coincide here — and the standing contract is the better rule. ⇒ My dispatch wording was the weaker of the two; noted for the next dispatch.⇒ Flipping to ready and attaching auto-merge. The queue is the only sanctioned landing path.
Generated by Claude Code
- added a commit that references this issue
on Sep 28, 2026
Reported by the
domain:servicesdev that delivered #18241 (card #17973) as an out-of-scope finding, and filed here by the seat — dev agents report findings with dedupe words; they ⛔ do not file.Class (a), MEASURED, not read.
Mechanism
DatasetExecutor'scompareTomath is UTC-calendar throughout:parseUTCreads a bare day as UTC midnight andtoISODateemits a UTC day. A preset window resolved in a non-UTC zone therefore gets projected onto UTC day boundaries, and the comparison window comes out one day too long — in both directions.Measurement
Driven through
DatasetExecutor.execute,this_monthpluscompareTo: { kind: 'previousYear' }, frozen at2026-09-09:UTC['2025-09-01','2025-09-30']Asia/Shanghai['2025-08-31','2025-09-30']America/New_York['2025-09-01','2025-10-01']The two failing rows fail in opposite directions, which is the signature of a day-boundary projection rather than an off-by-one constant.
Why it is filed now, and why it is ⛔ NOT a regression of #18241
Before #18241 this input was a hard
DATASET_INVALID/ 400invalid date in dateRange: "this_month"— the preset arm ofrunComparecould not produce a window at all. ⛔ #18241 does not make any previously-working input behave differently.What changed is reachability: the preset arm now lowers and shifts, so this projection becomes observable for the first time. A refusal became a slightly-too-wide answer for non-UTC orgs, and a correct answer for UTC ones.
Why it was ⛔ not fixed in #18241
A timezone-aware calendar-day extraction written inside
dataset-executor.tswould be the second implementation thatpackages/core/src/utils/analytics-date-range.ts's module header exists to refuse. The repair belongs in the shared vocabulary module, not in one face of it.Carrier
The analytics
dateRangelane — the #17015 / #17124 / #17596 series.Dedupe words:
dataset-executorcompareTotimezoneUTC-dayprojection⛔ Ungraded on purpose: grading is triage's, not the dispatching seat's.
Generated by Claude Code