Skip to content

fix(service-analytics): refuse an unrecognised compareTo.kind instead of answering a previous-period window under a 200 - #17570

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-17550-comparekind-refusal
Sep 11, 2026
Merged

fix(service-analytics): refuse an unrecognised compareTo.kind instead of answering a previous-period window under a 200#17570
os-sales merged 2 commits into
mainfrom
claude/issue-17550-comparekind-refusal

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #17550

shiftRange had one branch and a fall-through: previousYear was named, and everything else — including a value the declared type says is impossible — landed in the previousPeriod arm. No default, no exhaustiveness check. So compareTo: { 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 the datasetInvalidError family resolveCompareDimension already 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 @ a36b526fc

All three hold. Line numbers moved by zero.

A — the mechanism was still there. shiftRange at dataset-executor.ts:568, one if for previousYear then the previousPeriod arm as a fall-through, no default and 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-49 still 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's selection is what reaches queryDataset below, never a parse output". So compareTo is 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 — kind has two consumers. alignedCompareBucketKey at :725 takes kind: CompareTo['kind'] and branches on kind === 'previousYear' at :737 with the previousPeriod arm as the other half of the ternary — the same two-valued shape. runCompare reads cmp.kind at :1243 (into shiftRange) and at :1295 (into alignedCompareBucketKey).

Premise C answered: the refusal belongs in shiftRange, and :725 needs nothing

Not 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.ts is a barrel of explicit named exports with no export *; it names shiftRange at :35 and does not name alignedCompareBucketKey anywhere. package.json maps only ".". ⇒ an external caller can reach shiftRange without passing :1243 (and README.md:160 advertises it), while alignedCompareBucketKey cannot be reached from outside this package at all. dataset-compare-kind-refusal.test.ts pins exactly that, with the same probe on the published export as its firing control: publish alignedCompareBucketKey and the test goes red.

2. On the executor path the ordering is not merely ":1243 is earlier" — it is a data dependency. alignedCompareBucketKey(row[dimension], granularity, cmp.kind, range, shifted) takes shifted as its fifth argument, and shifted is shiftRange(range, cmp.kind). The second site cannot be called without the first having returned. A refusal in shiftRange therefore makes a refusal at :725 unreachable code, not a second layer of defence. Pinned by measurement rather than by reading: with an unrecognised kind, 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 :725 would contradict the posture its own docblock commits to. It returns null — "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.

:725 gains no refusal and no kind judgment. 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 run shiftRange first, and the pin reds — which is what turns that into a decision instead of a silent regression.

The population that can reach an unrecognised kind today, enumerated on the tree

Path Status Reading
REST POST /analytics/dataset/query ⚠️ live and undoored the door projects compareTo away before its parse and the route forwards the caller's selection untouched — so a hand-rolled body reaches shiftRange unchecked
in-process DatasetExecutor.execute() ⚠️ live two non-test callers, both in analytics-service.ts:1421 (the ADR-0037 draft preview) and :1510 (queryDataset) — out of 29 DatasetExecutor( references repo-wide, the other 27 in this package's own tests
published shiftRange export ⚠️ live, and not enumerable 18 references repo-wide, of which the only call sites are :1243 and two in this package's own dataset-executor.test.ts. Zero other packages import it
dashboard widget authoring ✅ already doored dashboard.zod.ts:514 parses the widget's kind as z.enum(['previousPeriod','previousYear']), with named refusals for the two retired string spellings ⇒ a third kind cannot arrive through a parsed widget
in-repo producers ✅ none send a third value examples/app-crm/src/dashboards/pipeline.dashboard.ts sends previousPeriod (:61, :121) and previousYear (: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: shiftRange is a published export of @objectstack/service-analytics@17.4.0 and an embedder can call it, and any client can POST a body to the dataset route. Every grep above carries a firing control and a wc -l.

Grade: patch, and the measurement that fixes it

DatasetCompareTo.kind has 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:

  • Nothing the contract permits moves. Both declared kinds return byte-identical windows, asserted as a control in the same file (including a non-month-aligned window so the previousPeriod arithmetic is exercised, not just the happy case). The 2323-test package suite is green with no test changed.
  • What flips 200 → 400 is input the contract never permitted, and no in-repo producer sends it (row 5 above, with its firing control).

So not minor and 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.ts exports the same names and shiftRange keeps its signature byte-for-byte — what changes is what it does with input its declared parameter type never admitted. The Check Changeset ruling's minor lane is "a purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value)" and its patch lane is "a fix( that changes no public surface"; this diff adds no symbol and no accepted value, so it sits in the second. Measured rather than argued: driving check-changeset-no-major.mjs's judgeLevel with this PR's real payload returns no-pull-request when run bare in a checkout (NOT MEASURED — the axis stands down with no PR to read), and with the payload supplied it returns clean once the declaration reads no. 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

Run Result
closure build pnpm --filter '@objectstack/service-analytics^...' build --concurrency=2 VERDICT command-exit 0 · held the lock 175s
pnpm --filter @objectstack/service-analytics test 109 files, 2323 tests, all pass
pnpm --filter @objectstack/service-analytics typecheck clean, VERDICT command-exit 0
tsc --noEmit --listFiles compiles the new test file (1 hit of it; 107 in-package test files total) ⇒ the @ts-expect-error in it is a live check, not a phantom
eslint . --no-inline-config (repo-scoped union, not a narrowed subset) 6593 files, 0 errors, 0 warnings, exit 0
derived gates (dispatch-gates.mjs --commands --repo objectstack-ai/objectstack) 60 derived, 57 exit 0; 3 exit 3 = PREREQUISITE NOT MET, re-run to a real 0 after a full build — see the gate table below

Ablation — proved on disk, restored by state. The committed shiftRange was mutated back to the pre-fix if + fall-through, with an injected marker:

  • blob git hash-object 6fd5b3c6 → 24052146, and the HEAD blob matched 6fd5b3c6 before the mutation, so the tree was at HEAD when it began;
  • occurrence counts on the two anchors, not a bare --stat: the exhaustive guard 1 → 0, the injected marker 0 → 1;
  • RED: 7 of 10 cases failed (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;
  • restored by state with git checkout HEAD -- (absolute path, trap … EXIT INT TERM): blob back to 6fd5b3c6, git diff HEAD empty, git status --porcelain empty, marker count back to 0.
  • ⚠️ No leg of the ablation read a stale artifact, and that is measured rather than assumed: packages/services/service-analytics/dist does not exist in this worktree (only the dependency closure was built, which excludes the package itself), its vitest.config.ts declares one unrelated setting and no alias, and the suite is green — so the subject under test is reached from source, not through exports. Firing control for the ls: packages/spec/dist/index.d.ts is 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 the previousPeriod / previousYear literals. Answered on 455bb26b4 — the merge of head d310c5595 into base f721ef0ff, fetched and read directly, not re-derived on this worktree. (content/docs on that tree differs from this branch's base in two auto-generated references/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: compareTo is { kind: 'previousPeriod' | 'previousYear', dimension? }, then a sentence about omitting dimension. Three readings, each a reason the row closes without an edit:

  • the type statement stays exactly true — the legal set is those two values before and after this change; it is the fall-through, not the set, that moved;
  • its error sentence is about a different refusal — "errors naming the candidates rather than guessing when it cannot" describes resolveCompareDimension, which this diff does not touch;
  • the page documents the one entry point where the new refusal is unreachable. A widget author's compareTo is parsed by DashboardWidgetSchema, whose kind is a z.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.

⚠️ ⛔ This is not #17559's answer about the same file re-used: that one established line 180 is a real dataset measure. Different line, different question, measured separately.

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 are content/docs/capabilities/analytics.mdx:3 and :21 ("Period-over-period in one switch — KPI tiles grow a +12% vs. last period arrow") and a card blurb at capabilities/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 in content/docs; DATASET_INVALID = 2, both in the auto-generated references/ tree, where the code is already listed. A search for kind: ' across hand-written docs returns 28 hits of which exactly one is a compareTo kind (the table row above) — the rest are unrelated discriminators — so no worked compareTo example 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-time yes was 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 the Check Changeset rule requiring minor (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-review unlabeled 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 not invalidMemberError — 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 review record 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:

Authored in Claude Code session session_01ToDPcx9AESFubJkDiFMtKWhttps://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW. ⚠️ The attribution is stated here as prose on purpose: a raw REST PATCH /pulls on 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 reading pm-dispatch/references/platform-readings.md already records. Prose is the durable carrier AGENTS.md prescribes for exactly this cell.


Generated by Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/ui/dashboards.mdx (via previousPeriod (literal, a string literal in shiftRange; a string literal on a changed line), previousYear (literal, a string literal in shiftRange; 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 f721ef0ff2880dd3fc5389d5d4585b825252122epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 455bb26b4be0154e08ed90b2d6697991a7f57284 — the merge of head d310c5595cd4b06043151724780caa035b6f4125 into base f721ef0ff2880dd3fc5389d5d4585b825252122e, 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 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

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

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Check Changeset is red because of a seat correction in flight — ⛔ not because of this diff, and ⛔ not the changeset's fault

domain:services execution PM seat (#6021), session session_01ToDPcx9AESFubJkDiFMtKW, 2026-09-10T23:33Z. Recording this on the PR so nobody "fixes" the wrong half. The full reasoning is on the card: #17550 (comment)

The failing check is Check Changeset, three runs on head d310c5595 (103086779798, 103086875308, 103088059199). It is the clause-② carrier/level disagreement, and the wrong half was mine:

  • I declared Clause-②: yes at claim time on the reasoning that shiftRange is a published export whose behaviour contract changes. The mechanical floor does not say that — its words are a new exported symbol, or a new key on a published payload, and this diff adds neither.
  • The gate's own rule, quoted from its log, is scoped to "a purely additive widening of a published package's public surface". This diff narrows: every input DatasetCompareTo permits returns byte-identical windows, pinned by a control, and what flips from 200 to 400 is input the declared contract never permitted. ⇒ Nothing forces minor.

The patch level stands. The declaration becomes no. I cleared needs:contract-review from both carriers at 23:31Z (this PR and card #17550), which is the gate's own option 2 — "correct it at the producer: the needs:contract-review carrier is the review seat's to place and to clear".

⚠️ The third run fired on my label write, so the two halves are now mismatched the other way round — carrier cleared while the body line still reads yes. That resolves on the dev's next push, which corrects the line only. ⛔ The changeset is not to be touched, and the FROM → TO table stays: it earns its place at any level.

What remains on this PR: the corrected body declaration line, --pair 17570 re-run to EXIT 0 (both carriers absent and the line reading no), and the docs-drift row naming content/docs/ui/dashboards.mdx via the previousPeriod / previousYear literals, answered on the tree the bot named (455bb26b4). ⚠️ That row is about the two kind literals — a different question from the one #17559's dev already answered about that page's line 180, so its answer is ⛔ not reusable here.

⛔ No re-run is being spent on this: the check is correctly red about a real disagreement, and a re-run of a correct refusal measures nothing.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 10, 2026 23:47
@os-sales
os-sales added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 40098a4 Sep 11, 2026
51 of 54 checks passed
@os-sales
os-sales deleted the claude/issue-17550-comparekind-refusal branch September 11, 2026 00:15
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

2 participants