Skip to content

fix(analytics): a dateRange array that is not a two-bound window is refused once, instead of meaning three different things - #17593

Merged
os-sales merged 4 commits into
mainfrom
claude/issue-17124-one-element-daterange
Sep 11, 2026
Merged

fix(analytics): a dateRange array that is not a two-bound window is refused once, instead of meaning three different things#17593
os-sales merged 4 commits into
mainfrom
claude/issue-17124-one-element-daterange

Conversation

@claude

@claude claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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-review stays 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), on abc4b83ce:

face what ['2026-01-01'] meant
ObjectQLStrategy.dateRangeBounds {$gte: '2026-01-01', $lte: '2026-01-01'} — the point
NativeSQLStrategy no time clause emitted at all — the whole dataset
the draft-preview evaluator (end to end) selected 2026-01-01, 2026-06 and 2099 — unbounded above
DatasetExecutor.runCompare primary pass kept the one-element array; compare pass got the point ['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.

⚠️ Two faces beyond the card's two. native-sql-strategy.ts was named only inside a sibling's comment, and dataset-executor.ts was 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:

shape ObjectQL native SQL preview executor
['2026-01-01'] point window dropped unbounded above point (compare)
[] dropped dropped selects nothing bare TypeError
['2026-01-01','2026-01-31','2026-02-01'] first two dropped first two passed through
[null, null] dropped >= 'null' AND <= 'null' selects nothing bare TypeError

Two 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.

⚠️ Correction to my own first reading. I initially recorded that none of the three readings was governed. That is false, and a full package test run is what falsified it: objectql-daterange.test.ts carried 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:

What is governed is the CONTRACT, and it already excludes the shape:

  • the spec's own single refusal wording, 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";
  • the shipped Analytics drivers (memory, SQL) align dateRange string 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;
  • and the kit's own rule for a dateRange that 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:

  1. Every alignment is silent. The author who wrote ['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.
  2. Aligning on the point reading would silently change the native-SQL face from "the whole dataset" to "one day" — a correctness change with no signal, on the face whose present answer is the dangerous one.
  3. The open-ended reading is argued against in-source: "drop the window" means "plot all of history", which is the very failure Analytics drivers (memory, SQL) align dateRange string handling to the closed preset vocabulary (driver half of #16041) #16322 repaired.
  4. ⭐ The author loses nothing. ['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.
  5. No shipped producer emits the refused shapes: every in-tree dateRange array literal is two-element, and objectui's dashboard-level dateRange is 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 the domain:spec seat's and is not in this diff. The refusal lives where it is reachable: POST /analytics/dataset/query types its selection from AnalyticsQuery and never Zod-parses it, so the schema door is behind these faces, which is the same seam an unrecognised compareTo.kind is refused at.

⚠️ Residual, reported rather than fixed: the Zod type still admits a one-element array, so after this change the schema is strictly looser than every face. Tightening it narrows a published accept set at the contract and is the maintainer floor — a domain:spec decision, reported to the dispatching seat, not filed from here.

Scope: why the guard is arity-and-bound-type, not one element

explicitDateRangeWindow requires 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 bare TypeErrors (500) through parseUTC. 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 own DATASET_INVALID refusal (#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 the code+status pair 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

Pinpackages/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 on toThrow()[null,null]'s TypeError satisfied a bare toThrow; 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.

Test Files  1 passed (1)
      Tests  22 passed (22)

Ablation, on the committed state, with the pre-#17124 guess reinstated in the shared rule:

hash BEFORE mutation: 6930191ce33e02adfcd2add42e287fb46bfcc2e7
occurrences of anchor BEFORE: 1
occurrences of anchor AFTER : 0
occurrences of ABLATION marker AFTER: 1
hash AFTER mutation : cb81ea34eced0b419c01fa2818dd224cb63e17b7
RED leg vitest exit: 1
 Test Files  1 failed (1)
      Tests  17 failed | 5 passed (22)
--- RESTORE leg ---
git diff HEAD (must be EMPTY):        <empty>
git status --porcelain (must be EMPTY): <empty>
hash AFTER restore : 6930191ce33e02adfcd2add42e287fb46bfcc2e7
hash at HEAD       : 6930191ce33e02adfcd2add42e287fb46bfcc2e7
RESTORE OK: bytes identical to HEAD

The restore ran under a trap … EXIT INT TERM with absolute paths, git checkout HEAD -- <abs path> (never the bare form, which restores from the polluted index), and isevidenced by state — empty git diff HEAD and empty git status --porcelain plus the hash equality — not by an exit code. An empty hash was handled as FAILURE.

Measured, not assumed: no leg resolves through exports into dist/. The package's vitest.config.ts declares no alias (it exists for disableConsoleIntercept alone) 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 to src/. The empirical proof is the ablation itself: the RED leg flipped with no rebuild between mutation and run, which only a src/-resolved import can do. The one exports-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 base abc4b83ce), all run, reconciled with --ran:

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

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-loads and check:lean-entry-closure needed a built tree (turbo run build --filter='./packages/*' --filter='./packages/*/*'VERDICT command-exit 0, 72/72 tasks), and check:type-check-debt then OOMed because my own NODE_OPTIONS=--max-old-space-size=4096 was below the 6144 MB CI-shaped ceiling the gate itself pins; re-run at its own ceiling it reports OK — 5 ledger entr(ies) re-measured in 69.4s, 55 raw tsc error(s) total, none above its recorded number.

Also run, because dispatch-gates scores them silent on 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

os-verify-lock: VERDICT command-exit 0 · held the lock 69s (1m09s) · waited 0s
 Test Files  111 passed (111)
      Tests  2390 passed (2390)

tsc --noEmit clean, and measured to actually cover the new tests rather than assumed: --listFiles puts 134 files in the program, including both __tests__/date-range-array-arm-arity.test.ts and __tests__/objectql-daterange.test.ts and all five sources.

Lint — a proven narrowing, not a skip

pnpm lint's repo-wide eslint . --no-inline-config is CI's run. The narrowed run is a measurement, with all three pieces of evidence:

  1. Population, read from eslint's own config (via ESLint#isPathIgnored over git ls-files, not a guess): 6594 tracked files carry a lintable extension and the config ignores none of them ⇒ population 6594.
  2. File count, read from --format json: 7 files linted (every .ts in the diff), errorCount 0, warningCount 0, exit 0.
  3. Invariance over the 6587 untouched files: the config never enables type-aware linting for ANY file — no parserOptions.project, no typed @typescript-eslint rules — stated in eslint.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:

row verdict
content/docs/releases/v14.mdx zero dateRange mentions — 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:1377 states "timeDimensions[].dateRange is 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 mention timeDimensions, so the instrument fires).

  • references/api/analytics.mdx:101 and references/data/analytics.mdx:113 already 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 are AUTO-GENERATED — DO NOT EDIT from the Zod schema, which is unchanged.
  • ui/dashboards.mdx, references/ui/dashboard.mdx, deployment/validating-metadata.mdx are the dashboard-level dateRange object ({ 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 under packages/, so the instrument fires). The nearest hold in this lane, #8345 (pm:on-hold), names only analytics-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

…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>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 11, 2026
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class NativeSQLStrategy))
  • content/docs/releases/v17/17-0.mdx (via ObjectQLStrategy (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 29d00cc539c2822be79d4d728779312094b35525packageMentionDocs.

Which tree this was computed on

This run read content/docs from eb057ce616fc13ad53a50c434e4a2acc56d9a14c — the merge of head c8ec0f631b7522b5612417fa0f214427910d2ca2 into base 29d00cc539c2822be79d4d728779312094b35525, 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 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

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

…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>
@os-sales
os-sales marked this pull request as ready for review September 11, 2026 01:53
@os-sales
os-sales enabled auto-merge September 11, 2026 01:53
@os-sales
os-sales added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 86c5052 Sep 11, 2026
41 checks passed
@os-sales
os-sales deleted the claude/issue-17124-one-element-daterange branch September 11, 2026 02:12
os-musk pushed a commit that referenced this pull request Sep 11, 2026
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>
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/l tests tooling

Projects

None yet

2 participants