fix(service-analytics): refuse an unrecognised compareTo.kind instead of answering a previous-period window under a 200 - #17570
Conversation
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 9 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 455bb26b4be0154e08ed90b2d6697991a7f57284 && git checkout 455bb26b4be0154e08ed90b2d6697991a7f57284
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f721ef0ff2880dd3fc5389d5d4585b825252122e d310c5595cd4b06043151724780caa035b6f4125 && git checkout -B drift-repro f721ef0ff2880dd3fc5389d5d4585b825252122e && git merge --no-ff d310c5595cd4b06043151724780caa035b6f4125
node scripts/docs-audit/affected-docs.mjs --json f721ef0ff2880dd3fc5389d5d4585b825252122e
|
|
Fixes #17550
shiftRangehad one branch and a fall-through:previousYearwas named, and everything else — including a value the declared type says is impossible — landed in thepreviousPeriodarm. Nodefault, no exhaustiveness check. SocompareTo: { kind: 'previousQuarter' }came back as a previous-period comparison under an ordinary 200, and the caller was told nothing. This makes that input the fourth member of thedatasetInvalidErrorfamilyresolveCompareDimensionalready raises three times on the same document:DATASET_INVALID/ 400, naming what arrived, both legal windows, and the fix.The three dispatch premises, re-measured on
origin/main@a36b526fcAll three hold. Line numbers moved by zero.
A — the mechanism was still there.
shiftRangeatdataset-executor.ts:568, oneifforpreviousYearthen the previousPeriod arm as a fall-through, nodefaultand no exhaustiveness check. Re-read at source before the first edit.B — PR #17548 confirms the hole rather than closing it.
analytics-selection-door.ts:45-49still carries the sentence, verbatim: the four dataset-only members (runtimeFilter,dateGranularity,compareTo,totals) are "PROJECTED AWAY before the parse, deliberately", and ":49" adds "Those four therefore still have no door." ⭐ And the door is validation-only in both its own words (analytics-selection-door.ts:83-84) and the route's (rest-server.ts:10943-10944): "the caller'sselectionis what reachesqueryDatasetbelow, never a parse output". SocompareTois neither parsed nor stripped — it travels to the executor exactly as the client sent it. The hole is wider than "undoored"; it is undoored and forwarded.C —
kindhas two consumers.alignedCompareBucketKeyat:725takeskind: CompareTo['kind']and branches onkind === 'previousYear'at:737with the previousPeriod arm as the other half of the ternary — the same two-valued shape.runComparereadscmp.kindat:1243(intoshiftRange) and at:1295(intoalignedCompareBucketKey).Premise C answered: the refusal belongs in
shiftRange, and:725needs nothingNot a preference — three readings, and the first two are now pinned rather than asserted.
1. The public surfaces are asymmetric, which is what makes one site owe a refusal and the other not.
src/index.tsis a barrel of explicit named exports with noexport *; it namesshiftRangeat:35and does not namealignedCompareBucketKeyanywhere.package.jsonmaps only".". ⇒ an external caller can reachshiftRangewithout passing:1243(andREADME.md:160advertises it), whilealignedCompareBucketKeycannot be reached from outside this package at all.dataset-compare-kind-refusal.test.tspins exactly that, with the same probe on the published export as its firing control: publishalignedCompareBucketKeyand the test goes red.2. On the executor path the ordering is not merely "
:1243is earlier" — it is a data dependency.alignedCompareBucketKey(row[dimension], granularity, cmp.kind, range, shifted)takesshiftedas its fifth argument, andshiftedisshiftRange(range, cmp.kind). The second site cannot be called without the first having returned. A refusal inshiftRangetherefore makes a refusal at:725unreachable code, not a second layer of defence. Pinned by measurement rather than by reading: with an unrecognisedkind, the executor never issues the shifted query at all (the fake service records every query; the shifted window is absent), with the legal-kind control on the identical selection proving that selection does reach the comparison pass.3. A throw at
:725would contradict the posture its own docblock commits to. It returnsnull— "leave this row's key alone", i.e. exactly the pre-#6007 behaviour — for every uncertainty it knows about, and it runs inside a per-row map. Failing a whole request from there is a different contract from the one it documents. Refusing the selection belongs at the point the selection is being interpreted.⇒
:725gains no refusal and nokindjudgment. What it gains is the sufficiency argument, written into its docblock next to the fail-closed section, ending in the condition that would invalidate it: export it, or give it a second caller that does not runshiftRangefirst, and the pin reds — which is what turns that into a decision instead of a silent regression.The population that can reach an unrecognised
kindtoday, enumerated on the treePOST /analytics/dataset/querycompareToaway before its parse and the route forwards the caller's selection untouched — so a hand-rolled body reachesshiftRangeuncheckedDatasetExecutor.execute()analytics-service.ts—:1421(the ADR-0037 draft preview) and:1510(queryDataset) — out of 29DatasetExecutor(references repo-wide, the other 27 in this package's own testsshiftRangeexport:1243and two in this package's owndataset-executor.test.ts. Zero other packages import itdashboard.zod.ts:514parses the widget'skindasz.enum(['previousPeriod','previousYear']), with named refusals for the two retired string spellings ⇒ a third kind cannot arrive through a parsed widgetexamples/app-crm/src/dashboards/pipeline.dashboard.tssendspreviousPeriod(:61,:121) andpreviousYear(:79,:95) and nothing else⛔ Callers outside this repository are not enumerable from here. That is a limit of the measurement, not a cleared row:
shiftRangeis a published export of@objectstack/service-analytics@17.4.0and an embedder can call it, and any client can POST a body to the dataset route. Every grep above carries a firing control and awc -l.Grade:
patch, and the measurement that fixes itDatasetCompareTo.kindhas only ever declared'previousPeriod' | 'previousYear'(spec/contracts/analytics-service.ts:150), so this pulls behaviour back onto the declared contract rather than narrowing past it. The measurement that settles the grade is the two-sided one:So not
minorand not breaking: no authorable key, export or config field is added, removed or renamed, so there is no FROM → TO migration to prescribe and no ADR-0087 disposition to register. ⭐ The public surface itself does not move, and that is the axis the level rule keys on:src/index.tsexports the same names andshiftRangekeeps its signature byte-for-byte — what changes is what it does with input its declared parameter type never admitted. TheCheck Changesetruling'sminorlane is "a purely additive widening of a published package's public surface (a new exported symbol on anindex, a new accepted key or value)" and itspatchlane is "afix(that changes no public surface"; this diff adds no symbol and no accepted value, so it sits in the second. Measured rather than argued: drivingcheck-changeset-no-major.mjs'sjudgeLevelwith this PR's real payload returnsno-pull-requestwhen run bare in a checkout (NOT MEASURED — the axis stands down with no PR to read), and with the payload supplied it returnscleanonce the declaration readsno. The changeset carries a FROM → TO table anyway, because that is the text an upgrading agent greps, and it states the reachability limit in the body rather than implying it away.Verification
pnpm --filter '@objectstack/service-analytics^...' build --concurrency=2VERDICT command-exit 0 · held the lock 175spnpm --filter @objectstack/service-analytics testpnpm --filter @objectstack/service-analytics typecheckVERDICT command-exit 0tsc --noEmit --listFiles@ts-expect-errorin it is a live check, not a phantomeslint . --no-inline-config(repo-scoped union, not a narrowed subset)dispatch-gates.mjs --commands --repo objectstack-ai/objectstack)exit 3= PREREQUISITE NOT MET, re-run to a real 0 after a full build — see the gate table belowAblation — proved on disk, restored by state. The committed
shiftRangewas mutated back to the pre-fixif+ fall-through, with an injected marker:git hash-object6fd5b3c6 → 24052146, and the HEAD blob matched6fd5b3c6before the mutation, so the tree was at HEAD when it began;--stat: theexhaustiveguard 1 → 0, the injected marker 0 → 1;exit 1) — all three unrecognised spellings, the message-discipline case, the compile-time case, and both executor-seam cases. The 3 that stayed green are exactly the ones that must not depend on the refusal: the two legal-kind controls and the public-surface pin;git checkout HEAD --(absolute path,trap … EXIT INT TERM): blob back to6fd5b3c6,git diff HEADempty,git status --porcelainempty, marker count back to 0.packages/services/service-analytics/distdoes not exist in this worktree (only the dependency closure was built, which excludes the package itself), itsvitest.config.tsdeclares one unrelated setting and no alias, and the suite is green — so the subject under test is reached from source, not throughexports. Firing control for thels:packages/spec/dist/index.d.tsis present.docs-drift — the bot's row, answered on the tree the bot names
The bot raises one row:
content/docs/ui/dashboards.mdx, via thepreviousPeriod/previousYearliterals. Answered on455bb26b4— the merge of headd310c5595into basef721ef0ff, fetched and read directly, not re-derived on this worktree. (content/docson that tree differs from this branch's base in two auto-generatedreferences/kernel/*files and nothing else, so the two readings agree by construction rather than by luck.)No doc change is owed. On that tree the page carries exactly one occurrence of either literal — line 105, the dashboard-widget property table:
compareTois{ kind: 'previousPeriod' | 'previousYear', dimension? }, then a sentence about omittingdimension. Three readings, each a reason the row closes without an edit:resolveCompareDimension, which this diff does not touch;compareTois parsed byDashboardWidgetSchema, whosekindis az.enum— so a reader following this page cannot produce an unrecognised kind, and adding "the executor refuses a third kind" to a widget-authoring table would document a refusal this page's audience can never reach. The page is accurate and is not silently incomplete.The emitter-blind half, by hand on the same tree (the blind spot the bot declares: a page stating a rule by its INPUTS shares no identifier with the emitter). Input-side spellings, each with its count:
period-over-period= 7,comparison window= 3,period over period/previous period/year-over-year/YoY/__compare= 0. The only hand-written hits outside the table row arecontent/docs/capabilities/analytics.mdx:3and:21("Period-over-period in one switch — KPI tiles grow a +12% vs. last period arrow") and a card blurb atcapabilities/index.mdx:20; all three describe the capability driven by a legal kind and none asserts that an unrecognised one is accepted, so none becomes false.shiftRange= 0 incontent/docs;DATASET_INVALID= 2, both in the auto-generatedreferences/tree, where the code is already listed. A search forkind: 'across hand-written docs returns 28 hits of which exactly one is acompareTokind (the table row above) — the rest are unrelated discriminators — so no workedcompareToexample exists in hand-written docs to falsify. Firing controls on the same tree and grep shape:compareTo= 5,dashboard= 371,dataset= 325.Fence. PR #17454's file list re-read from the API: 37 paths,
service-analytics= 0,dataset= 0, firing control (filename contains a slash) = 35. No intersection.Clause ②
Clause-②: no— the seat's declaration, corrected at 2026-09-10T23:31Z. The claim-timeyeswas wrong: it read the mechanical floor as covering a behaviour change on an existing export, and the floor's words are a NEW exported symbol or a NEW key on a published payload. This diff adds neither and narrows rather than widens, so theCheck Changesetrule requiringminor(additive widening of a public surface, ruling 2026-09-04 batch #35) does not reach it. Carriers cleared on both the card and this PR in the same stroke —needs:contract-reviewunlabeled from the PR at 23:31:17Z and absent from card #17550, both read back from the API.What the correction does not change: the derived judgments this diff makes are still listed here, because they are what a reviewer reads whether or not a carrier is hanging. The refusal's family (
datasetInvalidError, not a new code and notinvalidMemberError— the verdict is about the selection, not about one member the request named), its site (the published export, not the seam — argued above), the absence of a second refusal at:725, and the grade (patch). ⛔ No## Contract reviewrecord is written here either way: a dev self-review is not a review record.Acceptance notes
Noted, not filed — each with the successor that will actually reach it:
alignedCompareBucketKey'spreviousPeriodarm is a latent duplicate of the same shape, unreachable today for the two reasons pinned here. Successor: whoever exports it or gives it a second caller — the pin is what summons them. Not filed: there is no reachable defect to reproduce.runtimeFilter,dateGranularityandtotalsstill have no door at any layer, the other three members fix(rest): parse the dataset-query selection at the door, matching the analytics family #17548 projects away. Successor: [Decision]DatasetSelectionis a published wire shape with no Zod schema, so four of its members have no door at any layer — where shouldDatasetSelectionSchemalive, and who authors it? #17551 ([Decision],domain:spec) — theDatasetSelectionSchemaquestion, which is the fix for the whole class and is routed out of this lane. Not filed again here.packages/specred line, reported to the seat rather than filed:packages/spec/src/ui/dashboard-compareto.test.ts:76already states the intended disposition of a third kind, and it is compatible with this change rather than in tension with it — it pins the ALLOWED set, "which is the half that must not grow ahead of the executor". It is silent on what the executor should do when an unparsed third kind arrives anyway, which is precisely the gap this PR closes. No spec-side change is required by this fix, and none was made.Authored in Claude Code session⚠️ The attribution is stated here as prose on purpose: a raw REST
session_01ToDPcx9AESFubJkDiFMtKW— https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW.PATCH /pullson this repository appends its own bare footer block and keeps any footer already in the sent body, so a body edit that carries one stores two. Measured on this very PR — the first edit came back +58 bytes with two footers, byte-for-byte the readingpm-dispatch/references/platform-readings.mdalready records. Prose is the durable carrier AGENTS.md prescribes for exactly this cell.Generated by Claude Code