fix(analytics): a dateRange array that is not a two-bound window is refused once, instead of meaning three different things - #17593
Conversation
…o-bound window The array arm of `AnalyticsDateRangeSchema` is a bare `z.array(z.string())`, so `['2026-01-01']` is schema-valid and reaches the faces past the `/analytics/dataset/query` door, which does not Zod-parse its selection. The four faces in this package that read the arm answered it three different ways: the ObjectQL strategy degenerated it to a point window, the native-SQL strategy emitted no time clause at all and read all of history, the draft-preview evaluator wrote `String(undefined)` as the upper bound (which every ISO date sorts below), and the dataset executor's compare pass filled the missing bound in from the lower one. One rule, `explicitDateRangeWindow`, is now the single reading of the arm and all four faces call it; the three divergent fallbacks are deleted. An array that is not exactly two string bounds is refused with the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope, the same answer the contract already gives for a `dateRange` that does not denote a window. The two-element window is untouched on every face, bound for bound. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
…faces Drives every face that reads the dateRange array arm with the four shapes that are not a two-bound window and asserts one ADR-0112 envelope, plus the control that a two-element window still answers exactly as it did before on each face. 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): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
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 eb057ce616fc13ad53a50c434e4a2acc56d9a14c && git checkout eb057ce616fc13ad53a50c434e4a2acc56d9a14c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 29d00cc539c2822be79d4d728779312094b35525 c8ec0f631b7522b5612417fa0f214427910d2ca2 && git checkout -B drift-repro 29d00cc539c2822be79d4d728779312094b35525 && git merge --no-ff c8ec0f631b7522b5612417fa0f214427910d2ca2
node scripts/docs-audit/affected-docs.mjs --json 29d00cc539c2822be79d4d728779312094b35525
|
…in with the refusal `objectql-daterange.test.ts`'s 'narrows rather than vanishes on a one-entry dateRange array' pinned exactly the branch this card retires. It chose the narrower of two wrong answers because the alternative on the table was the native-SQL face's silent drop to all of history; a refusal satisfies #3650's intent strictly better, and the replacement keeps that card's own invariant by asserting no unfiltered query reaches the engine. The retirement is the one the neighbouring test declares deferred. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
The shared conformance kit had exactly one array-arm case — a two-element window — so the arity itself was governed nowhere and each face was free to invent a reading for `['2026-01-01']`, `[]`, `[a, b, c]` and `[null, null]`. Adds `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` and the case that holds every REGISTERED face to the rule PR #17593 already landed on the service-analytics faces: a non-two-bound array is refused with the ADR-0112 ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400 envelope. No existing case is weakened — the two-element window case is this one's control. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
Fixes #17124
Clause-②: no — no exported symbol is added (the new rule is an internal module, absent from
src/index.ts) and no key moves on a published payload; the change narrows rather than widens.needs:contract-reviewstays absent on both carriers.The probe came first, and it reproduced — on FOUR faces, not two
The card's own hard constraint was to probe before repairing. Driven before a production line was touched, with
['2026-01-01']over four rows (2020, 2026-01-01, 2026-06, 2099), onabc4b83ce:['2026-01-01']meantObjectQLStrategy.dateRangeBounds{$gte: '2026-01-01', $lte: '2026-01-01'}— the pointNativeSQLStrategy2026-01-01,2026-06and2099— unbounded aboveDatasetExecutor.runCompare['2025-12-31','2025-12-31']Control, in the same run: the two-element
['2026-01-01','2026-01-01']selected exactly that one day on all four faces. So the divergence is specific to the shape, not to the harness.native-sql-strategy.tswas named only inside a sibling's comment, anddataset-executor.tswas named nowhere. The native-SQL reading is the dangerous one and it is worse than the card states:if (range.length === 2)was false, so no predicate was emitted and the query read all of history.The arity, not the one element, is what the faces disagreed about — measured on the same run:
['2026-01-01'][]TypeError['2026-01-01','2026-01-31','2026-02-01'][null, null]>= 'null' AND <= 'null'TypeErrorTwo of those are a 500 for a malformed request, and
[null,null]on the SQL face resurrects the literal[range, range]nonsense-point shape #16322 abolished.The end chosen: refuse, in-lane — and why it is not an alignment
Triage's criterion is 一个操作两个实现且行为不一致 ⇒ 带治理的一侧胜出,另一侧改绑并删除. Applying it meant establishing what is governed rather than assuming it, and the answer is layered.
objectql-daterange.test.tscarried a pin,'narrows rather than vanishes on a one-entry dateRange array', asserting the point degeneration on the ObjectQL face — so that one reading did have a governance artifact, and this change turns it red. Stating it the other way round would have been the easy error, so here is what the artifact actually governs:Its own rationale is comparative, not semantic. Its comment: "
NativeSQLStrategydrops such a window — but 'drop the window' means 'plot all of history', the very failure analytics: ObjectQLStrategy 静默忽略 timeDimensions[].dateRange —— 恰好是 date-granularity 图表必走的那条路 #3650 is about." It chose the narrower of two wrong answers, because the alternative then on the table was the sibling face's silent drop. It does not assert that a one-element array means a point.Its own file schedules its retirement. The neighbouring test carries: "Retiring this test, together with the strategy's degeneration, belongs to Analytics drivers (memory, SQL) align
dateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322." Analytics drivers (memory, SQL) aligndateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322 deferred it ("reconciling the two is a divergence of its own, not this card's"), and triage named this card that deferral's successor.⇒ A refusal satisfies analytics: ObjectQLStrategy 静默忽略 timeDimensions[].dateRange —— 恰好是 date-granularity 图表必走的那条路 #3650's intent strictly better than the degeneration did. analytics: ObjectQLStrategy 静默忽略 timeDimensions[].dateRange —— 恰好是 date-granularity 图表必走的那条路 #3650's complaint was "no error, just every row ever recorded" — and a refusal is the error it wanted. The replacement pin keeps that card's own invariant explicitly: it asserts no unfiltered query reaches the engine (
expect(seen).toEqual([])), and the "vanishes" reading it was defending against is removed from the sibling face in the same change.The shared cross-package conformance kit (
analyticsDateRangeConformanceFindings, the instrument Analytics drivers (memory, SQL) aligndateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322 built so that "memory and SQL agree" is a measurement) has no arity case at all — its only array case isANALYTICS_DATE_RANGE_EXPLICIT_WINDOW, a two-element window. So the kit, which is the instrument that would bind all faces at once, is silent here, and each face was free to guess.preview-evaluator.ts:409-412claims only "the reading this face has always published" — continuity, not correctness — andnative-sql-strategy.ts's drop is asserted by nothing at all.What is governed is the CONTRACT, and it already excludes the shape:
analyticsDateRangeRefusalMessage— the one sentence the schema door answers with — says verbatim: "an explicit window is the two-element array [start, end] of ISO dates or {date-macro} tokens";dateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322 migration table (packages/spec/CHANGELOG.md,packages/runtime/CHANGELOG.md,.changeset/analytics-daterange-driver-alignment.md) tells authors to write a single day as['2026-01-20', '2026-01-20']— two bounds;dateRangethat resolves to no window is "an unresolvable window is a refusal, never a window". The refused-spellings list even carries'2026-01-20'annotated "the SQL single-day dialect, which is the ARRAY arm's job" — a single day as a string is already refused, on the ground that a window is what the array arm is for.⇒ The arm's arity is declared; only the Zod type is weaker than the contract the same file states. So the criterion resolves with the contract as the governed side: all four faces rebind to it and their fallbacks are deleted. Not "teach every face the same guess", which the criterion forbids — one rule,
explicitDateRangeWindow, four call sites, three divergent fallbacks removed.Why refusal beats either alignment:
['2026-01-01']gets a number either way and cannot tell which of three meanings they got; the refusal is the only end where they learn the document is malformed.dateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322 repaired.['2026-01-01','2026-01-01']already selects exactly that day on all four faces (the control above), so the refusal costs a second bound and buys a document that means one thing — and it is the spelling the shipped migration table already prescribes.dateRangearray literal is two-element, and objectui's dashboard-leveldateRangeis an object, not this array.The schema question, answered — and it forced NO route change
Stated plainly, as triage asked: this refuses at the service seam, not at the schema.
AnalyticsDateRangeSchema(packages/spec/src/data/analytics.zod.ts:326) is untouched — that file is thedomain:specseat's and is not in this diff. The refusal lives where it is reachable:POST /analytics/dataset/querytypes its selection fromAnalyticsQueryand never Zod-parses it, so the schema door is behind these faces, which is the same seam an unrecognisedcompareTo.kindis refused at.domain:specdecision, reported to the dispatching seat, not filed from here.Scope: why the guard is arity-and-bound-type, not one element
explicitDateRangeWindowrequires exactly two non-empty string bounds. That is the contract's sentence in full ("the two-element array [start, end] of ISO dates or {date-macro} tokens"), and it is one predicate on the one line each face already had; refusing only the one-element case would have left[],[a,b,c]and[null,null]splitting three ways on the same line, and[null,null]/[]throwing bareTypeErrors (500) throughparseUTC. It deliberately stops short of validating bound FORMAT — a bare day versus a full timestamp is a per-face calendar translation (#3777 / #4042) and{date-macro}tokens are expanded upstream, neither of which an arity rule touches. An unparseable bound keeps its ownDATASET_INVALIDrefusal (#5716): a different condition, so a different envelope.One envelope for this condition across all four faces, asserted in the pin:
ANALYTICS_DATE_RANGE_UNRECOGNIZED/ 400, built from the shared constructor so thecode+statuspair the conformance kit reads has a single origin. Only the sentence is this condition's own — the shared wording judges a bare string against the preset vocabulary and ends "Refused at the schema", and for an array refused past the schema door by a face both of those are false.Verification
Pin —
packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts, 22 assertions: the refusal on all four faces × four shapes, asserted on the ENVELOPE (code+status), never ontoThrow()—[null,null]'sTypeErrorsatisfied a baretoThrow; one-envelope-across-the-four; the message discipline; and four CONTROLS that the two-element window answers exactly as before on each face, including the #3777 half-open widening and the #16179 inclusive upper reading. Without those controls every assertion above is satisfiable by a face that refuses every array.Ablation, on the committed state, with the pre-#17124 guess reinstated in the shared rule:
The restore ran under a
trap … EXIT INT TERMwith absolute paths,git checkout HEAD -- <abs path>(never the bare form, which restores from the polluted index), and isevidenced by state — emptygit diff HEADand emptygit status --porcelainplus the hash equality — not by an exit code. An empty hash was handled as FAILURE.⭐ Measured, not assumed: no leg resolves through
exportsintodist/. The package'svitest.config.tsdeclares noalias(it exists fordisableConsoleInterceptalone) and there is no root vitest config, and the pin reaches all four faces through relative specifiers (../analytics-service.js,../preview-evaluator.js,../dataset-executor.js), so they resolve tosrc/. The empirical proof is the ablation itself: the RED leg flipped with no rebuild between mutation and run, which only asrc/-resolved import can do. The oneexports-resolved import is@objectstack/core(for the shared refusal constructor), which is not a mutation target.Gates
Derived from the ACTUAL diff with
dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(8 paths vs merge baseabc4b83ce), all run, reconciled with--ran:Three of the 60 first came back exit 3 = PREREQUISITE NOT MET = NOT MEASURED, and were driven to a real 0 rather than rounded up:
check:dual-build-cjs-loadsandcheck:lean-entry-closureneeded a built tree (turbo run build --filter='./packages/*' --filter='./packages/*/*'→VERDICT command-exit 0, 72/72 tasks), andcheck:type-check-debtthen OOMed because my ownNODE_OPTIONS=--max-old-space-size=4096was below the 6144 MB CI-shaped ceiling the gate itself pins; re-run at its own ceiling it reportsOK — 5 ledger entr(ies) re-measured in 69.4s, 55 raw tsc error(s) total, none above its recorded number.Also run, because
dispatch-gatesscores themsilenton a roster that lives in a directory one of my paths is in — where silence is evidence in neither direction:check-changeset-fixed(exit 0),check:authz-resolver(0),check:error-code-casing(0 — "no unlisted lowercase error codes in 6051 scanned file(s)"; this change adds no code, it reuses a registered one),check:filter-alias-parity(0),check:engine-double-contract(0).Package suite and typecheck
tsc --noEmitclean, and measured to actually cover the new tests rather than assumed:--listFilesputs 134 files in the program, including both__tests__/date-range-array-arm-arity.test.tsand__tests__/objectql-daterange.test.tsand all five sources.Lint — a proven narrowing, not a skip
pnpm lint's repo-wideeslint . --no-inline-configis CI's run. The narrowed run is a measurement, with all three pieces of evidence:ESLint#isPathIgnoredovergit ls-files, not a guess): 6594 tracked files carry a lintable extension and the config ignores none of them ⇒ population 6594.--format json: 7 files linted (every.tsin the diff),errorCount0,warningCount0, exit 0.parserOptions.project, no typed@typescript-eslintrules — stated ineslint.config.mjs's own header and measured there with a positive control. ⇒ No edit in this diff can move the verdict on a file it did not touch.All figures from the final commit,
c8ec0f631.docs-drift
Every row the bot names, answered:
content/docs/releases/v14.mdxdateRangementions — reached by one of the 7 symbol anchors, not by the date-range rule. Not stale. Release-owned; not edited.content/docs/releases/v17/17-0.mdx:1377timeDimensions[].dateRangeis applied" (the #3650 fix). Still true — a two-element window is applied bound for bound; the line says nothing about arity. Not stale. Release-owned; not edited.Emitter-blind hand search, for the page that states a date-range rule by its inputs and so shares no identifier with these faces: 5 non-release docs mention
dateRange(control: 2 mentiontimeDimensions, so the instrument fires).references/api/analytics.mdx:101andreferences/data/analytics.mdx:113already document the arm as "an explicit[start, end]array of ISO dates /{date-macro}tokens" — two bounds. This change enforces exactly what they already say, so they become more true, and both areAUTO-GENERATED — DO NOT EDITfrom the Zod schema, which is unchanged.ui/dashboards.mdx,references/ui/dashboard.mdx,deployment/validating-metadata.mdxare the dashboard-leveldateRangeobject ({ field, defaultRange, allowCustomRange }) — a different key, not this array. Unaffected.Fences
Re-verified, each with a firing control: PR #17454's path set is 37 paths with 0 under
service-analytics(control: 31 of the 37 are underpackages/, so the instrument fires). The nearest hold in this lane, #8345 (pm:on-hold), names onlyanalytics-service.ts— not in this diff, which does not widen into it, so no intersection.🤖 Generated with Claude Code
https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
Generated by Claude Code