Repository navigation
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>
…each refusal origin gets a true sentence `AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with no length constraint, while the refusal sentence in the same file said "the two-element array [start, end]" and #16322's shipped migration table told an author to write a single day as `['2026-01-20', '2026-01-20']`. So `['2026-01-01']`, `[]` and `[a, b, c]` passed the contract door and were refused by every reader behind it (#17593 aligned all four analytics faces on "not exactly two bounds is a refusal"). Only the TYPE was weaker than the prose beside it. The arm is now `z.tuple([z.string(), z.string()])`, so the type states the arity to the author's compiler before any parse runs. `analyticsDateRangeRefusalMessage` takes the refusal ORIGIN as a required parameter. "Refused at the schema" is the one clause no input can supply, and it was asserted unconditionally — false for every refusal raised past the schema door, which is why `service-analytics` had to overwrite the message rather than reuse it. The `received …` clause now names the arity and the bad bound separately instead of calling every refused array "an array with a non-string bound", which is false when every bound is a string. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
… ARITY case in the conformance kit, and the cube face stops dropping the window (objectstack-ai#17694) * feat(core): hold every analytics face to the dateRange array ARITY 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 objectstack-ai#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> * fix(driver-memory): refuse a dateRange array that is not a two-bound window MEASURED end to end on `49cd71548`, four rows spanning 2020…2099: the cube face emitted NO time predicate at all for `['2026-01-01']`, `[]` and `['2026-01-01','2026-01-31','2026-02-01']` — a pipeline byte-identical to one with no `dateRange` — so the query read ALL of history, and `[null, null]` compared against the string `'null'` and selected nothing. The array arm is now judged at the discriminator and yields two bounds or throws the shared ADR-0112 ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400 envelope, which is the rule the kit's new ARITY case holds every registered face to. The `if (range.length === 2)` guard — the line that dropped the window — is gone, and `ResolvedDateRange.bounds` is a tuple so it cannot come back. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com> * chore(changeset): dateRange array arity — one reading on every face Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com> * refactor(core): the ARITY finding names which half of the contract broke Two bounds is necessary, not sufficient: calling `[null, null]` an arity problem sends the next reader to the wrong line. Surfaced by this change's own ablation, where a real window smuggled into the not-a-window population was reported as "every other arity". Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com> * chore(changeset): grade @objectstack/core `minor` — it gains an export A purely additive widening of a published package's public surface takes at least `minor` (maintainer ruling 2026-09-04, decision batch objectstack-ai#35, on objectstack-ai#15294): `ANALYTICS_DATE_RANGE_NOT_A_WINDOW` is absent on `origin/main`, exported from `analytics-date-range-conformance.ts` and re-exported by the package entry. `@objectstack/driver-memory` stays `patch` and the changeset now says why: its public surface is byte-unchanged, and the behaviour it stops producing was a defect the contract never admitted. Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
… each refusal origin gets a true sentence (objectstack-ai#18230) Fixes objectstack-ai#17598 Ruling A (comment 5642584462, decision batch objectstack-ai#117 item 3, 2026-09-12; re-affirmed 5651068700 on 2026-09-13). Both items of the card land here — see "Why item 2 lands here" below. ## 1. The array arm refuses anything that is not exactly two string bounds `AnalyticsDateRangeSchema`'s array arm was `z.array(z.string())` with no length constraint; it is now `z.tuple([z.string(), z.string()])`. A tuple rather than `z.array(z.string()).length(2)` on purpose: the arity is then stated to the author's compiler before any parse runs, which is the ruling's "the type says what the prose says". The union's `error` mapper is unchanged in kind, so an arity refusal is still ONE prescriptive `invalid_union` issue at `timeDimensions.N.dateRange` and still carries the structural handle `isAnalyticsDateRangeRefusalIssue` lifts into `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 — not the tuple arm's own `too_big` / `too_small` text. Pinned in `packages/spec/src/data/analytics-date-range-two-bound-window.test.ts`: `[]`, `['2026-01-01']`, `['2026-01-01','2026-01-31','2026-02-28']` and `['{7_days_ago}']` are each refused with one issue at the field's own path, through `AnalyticsQuerySchema` and through the `POST /analytics/query` body schema; the two-bound windows and every preset name still parse; and the authored TYPE narrows too (a `@ts-expect-error` case). ## 2. Each refusal ORIGIN gets a true sentence, with the origin as a PARAMETER `analyticsDateRangeRefusalMessage(input)` becomes `analyticsDateRangeRefusalMessage(input, origin)`, `origin` being `'schema'` or `'runtime'`, required, with deliberately no default — a defaulted origin makes the same false assertion silently for every caller who does not think about it. Two clauses were false for an array refused past the schema door: - "Refused at the schema" — now `'runtime'` answers "Refused past the schema door, by the analytics reader that received it", and the two sentences are otherwise byte-identical (pinned by a replace-and-compare assertion, so the objectstack-ai#5240 one-condition-one-wording convention is mechanical rather than asserted). - "received an array with a non-string bound" — a new `describeRefusedDateRange` names the ARITY and the bad bound separately and claims neither when it is not true: `['2026-01-01']` now reads "received a 1-element array, not the two bounds [start, end]", while `['2026-01-01', 3]` keeps exactly the description it had, because that one was true. The ruling's acceptance criterion — the wording is true BOTH BEFORE AND AFTER the arm narrows — is itself a test: `['2026-01-01']` is described correctly at both origins, and answering item 1 did not falsify item 2's wording. ## 3. Changeset and ADR-0087 entry `.changeset/17598-analytics-date-range-two-bound-window.md` declares **`minor`**, not the `major` the ruling's execution note 1 names, and says so in its own body. `scripts/check-changeset-no-major.mjs` refuses a `major` outright: every publishable package is in the Changesets `fixed` group, so one `major` promotes the whole stack, and during the launch window this repo ships breaking changes as `minor`. The information carriers the guard names in its place are both present — the **BREAKING** banner and the ADR-0087 disposition marker. Measured: `check-changeset-no-major.mjs --base origin/main` exit 0, `check-adr-0087-registration.mjs --base origin/main` exit 0. The ADR-0087 semantic entry is `packages/spec/src/migrations/entries/semantic/18.analytics-date-range-array-two-bounds-required.ts`. The `18.` prefix is the TARGET PROTOCOL MAJOR, not a sequence number: `packages/spec` is at `17.4.0`, the sibling `18.analytics-time-dimension-date-range-vocabulary-closed.ts` (objectstack-ai#16322, the same schema's string arm) is filed the same way, and the generated table lands it in `MIGRATIONS_BY_MAJOR[18].semantic` — `check:migration-registry`, `check:spec-changes` and `check:upgrade-guide` all green, so the projections agree. Conversions, per the ruling: one element becomes the same day twice; the empty array and 3+ bounds get the structured TODO and no conversion, because there is nothing to derive a pair FROM. No D2 conversion and no stored-metadata rewrite — rewriting `['2026-01-01']` at load would be the platform deciding silently that the author meant one day rather than a window whose end they forgot. ## Scope: the two out-of-declaration edits, measured and then decided The claim declared `packages/spec/src/data/analytics.zod.ts` plus its tests, one ADR-0087 entry and a changeset. Two edits reach outside it. Both were MEASURED before being kept, by ablation with on-disk mutation proof and a restore proof. ### A. `packages/core/src/utils/analytics-date-range.ts` — REQUIRED CONSEQUENCE, kept It is **not** one of the four analytics faces PR objectstack-ai#17593 landed. Measured from that PR's own file list: all 8 files are under `packages/services/service-analytics/` plus its changeset; `packages/core` is not among them. This file is the shared ADR-0112 envelope constructor `analyticsDateRangeUnrecognizedError` that those faces — and driver-memory, and the REST selection door — all call, and it predates objectstack-ai#17593. It is the only non-test call site of `analyticsDateRangeRefusalMessage` in the monorepo (grep over `packages/**`: the other five files name only the constructor, whose signature is unchanged). Making `origin` required is item 2 of the ruling, so this call site MUST state the origin it alone knows. Ablation, one file reverted to the merge base, nothing else: ``` revert packages/core/src/utils/analytics-date-range.ts to 1bdbf82 on-disk proof: two-arg call sites 1 -> 0; blob 50509de -> 4f6d482 pnpm --filter @objectstack/core exec tsc --noEmit :: exit 1 src/utils/analytics-date-range.ts(222,25): error TS2554: Expected 2 arguments, but got 1. restore (git checkout HEAD -- path; blob back to 50509de, git diff HEAD empty) pnpm --filter @objectstack/core exec tsc --noEmit :: exit 0 ``` The narrowing cannot land without it: `TypeScript Type Check` is one of the six required contexts. ### B. `packages/spec/scripts/lib/format-type.ts` — REQUIRED CONSEQUENCE, kept The reference-docs renderer read only `items`. Draft-2020-12 spells `z.tuple([...])` as `prefixItems` and leaves `items` ABSENT, so the array branch rendered the element type of nothing and printed `any[]`. Measured directly, both renderer versions run over the REAL generated schema for this field (`packages/spec/json-schema/data/AnalyticsQuery.json`, `properties.timeDimensions.items.properties.dateRange`): ``` origin/main arm z.array(z.string()) + base renderer -> string[] NARROWED arm z.tuple([string, string]) + base renderer -> any[] ** THE REGRESSION ** NARROWED arm z.tuple([string, string]) + this renderer -> [string, string] ``` So landing item 1 WITHOUT this fix does not fail the docs build — it silently rewrites `content/docs/references/data/analytics.mdx` and `api/analytics.mdx` from `string[]` to `any[]`, i.e. this card's narrowing would make the published reference page say the two bounds may be anything. A regenerated page that is wrong is the criterion, and this is it. Confirmed the other way too: with the branch reverted, `check:docs` exits 1 and names all four pages, and `scripts/format-type.test.ts` goes 1 failed / 89 passed. Collateral, and unavoidable: the branch is one branch in one renderer and cannot be scoped to `dateRange` alone, so two pre-existing tuples stop reading `any[]` in the same regeneration — `FilterCondition.$between` (`[number | string, number | string]`) and `ListView.map.center` (`[number, number]`). Both are corrections in the same direction, on generated pages, with no source change behind them. They are called out here rather than filed, because there is no PR in which they could land separately. ### C. `packages/services/service-analytics/src/__tests__/objectql-daterange.test.ts` — a THIRD required consequence, found this round Not in the inherited diff. Narrowing the authored type breaks three sites in this suite, measured: ``` HEAD (narrowed) pnpm --filter @objectstack/service-analytics exec tsc --noEmit 39 errors total, 3 in objectql-daterange.test.ts (220, 499, 503) arm reverted + spec rebuilt 36 errors total, 0 in objectql-daterange.test.ts HEAD + this commit 36 errors total, 0 in objectql-daterange.test.ts ``` (The 36 that remain are 24 TS2307 plus their implicit-any cascade, from `@objectstack/core` having no `dist` in this worktree. They are identical in all three runs and CI builds the closure.) Line 220 is a deliberate one-element window: the value is KEPT and gains `@ts-expect-error`, because the refusal this test pins is the FACE's, raised past a door `POST /analytics/dataset/query` never opens — deleting the case would delete objectstack-ai#17593's own coverage. Lines 499/503 are an ordinary two-bound window whose `const` had no annotation, so TypeScript widened the literal to `string[]`; it is annotated `AnalyticsQuery`. Neither touches any face's behaviour. No open PR touches `packages/services/service-analytics`. A repo-wide sweep for the same two shapes found no other site: exactly two `dateRange` array literals in the tree have arity other than 2 (this one, and this card's own `@ts-expect-error` case), and every remaining literal sits in a directly contextually-typed position. ## Premise checks **Two line numbers in the ruling and the card are stale.** The ruling cites the array arm at `analytics.zod.ts:326` and `analyticsDateRangeRefusalMessage` at `:284`. On `origin/main` `1bdbf82cb5` those lines carry unrelated text; the live ones before this diff were **`:418`** and **`:376`** (they are `:496` and `:425` after it). Both symbols are unique by name, so the ruling's subject is unambiguous — only the citations moved. **Why item 2 lands here: `objectstack-ai#17918` does not exist.** The 2026-09-13 re-affirmation routes item 2 to a split card, `objectstack-ai#17918`, and permits "one PR or objectstack-ai#17918 first". `GET /repos/objectstack-ai/issues/17918` returns **HTTP 404** on a credential that answers 200 for objectstack-ai#17593, objectstack-ai#17596, objectstack-ai#17124 and objectstack-ai#16322 on the same call shape, so the 404 is that card's own and not a permissions artefact. Item 2 therefore has no other carrier, and the ruling's own permission to land both in one PR is what is being used here. This PR does not name that number in any closing keyword. **`Clause-②: no` holds against the mechanical check.** `scripts/pm/check-widening-tells.mjs --declaration no --diff` over this branch's full diff: **exit 0**, no widening tell. 3 files judged against a declared surface (the schema source, the migrations registry and the new entry), 11 NOT MEASURED — the ADR-0087 registration did not fire T4. The declaration stands as the ruling wrote it and is not flipped. ## Verification Control, at the tip: ``` pnpm --filter @objectstack/spec exec vitest run --project local src/data/analytics-date-range-two-bound-window.test.ts src/data/analytics-date-range-closed-vocabulary.test.ts scripts/format-type.test.ts :: exit 0 — 3 files, 113 tests passed pnpm --filter @objectstack/core exec vitest run --project local src/utils/analytics-date-range.test.ts :: exit 0 — 45 tests passed pnpm --filter @objectstack/spec typecheck :: exit 0 pnpm --filter @objectstack/core exec tsc --noEmit :: exit 0 pnpm --filter @objectstack/spec check:generated :: exit 0 — all 15 generated artifacts up to date pnpm lint :: exit 0 (full repo scan, at 5db9a5d) pnpm check:nul-bytes :: exit 0 ``` Negative control for item 1, the whole point of the new suite: with `analytics.zod.ts` reverted to the merge base (on-disk proof: tuple-arm occurrences 1 -> 0; blob 69e3ff5 -> 8c2cce9), the two spec suites go **10 failed / 13 passed** — every `refuses ...` case and every origin case. Restored from `HEAD`, `git diff HEAD` empty. Honest reading of the sibling suite: `analytics-date-range-closed-vocabulary.test.ts` PASSES under that ablation. Its only edits are the now-required `'schema'` argument (ignored by the one-parameter function) and one renamed `it` title — "keeps the array arm exactly as it was" became "still accepts every two-bound array window", because the old title is false after this change. **No assertion in it was weakened, deleted, loosened or skipped**; its three windows all had two bounds already, and the diff is 3 changed lines. 60 gate families run in total, 58 exit 0. Two did not measure anything and are recorded as such, not as passes: `check:type-check-debt` exits **3** = `PREREQUISITE NOT MET` (29 workspace dependencies have no built type entry point in this worktree) and `check:skill-examples` exits 1 on the same prerequisite (`packages/client-react/dist` holds no declarations). Both build the closure in CI. The remaining derived families are repo-wide scans left to CI, per `scripts/pm/dispatch-gates.mjs --ran` (115 derived, 30 recorded in the first reconciliation pass, 30 more run after it). ## Acceptance notes Noted, not filed — each names who would meet it: - `packages/services/service-analytics/src/date-range-array-arm.ts` overwrites the shared refusal sentence and documents why: "the shared wording ... ends with 'Refused at the schema', and neither is true of an array refused past the schema door by a face". After this PR that justification is gone — `analyticsDateRangeRefusalMessage(dateRange, 'runtime')` now says something true — so the repo keeps one condition with two wordings for no remaining reason. That file is fenced to PR objectstack-ai#17593's landing for this round. Successor: whoever next touches that file, or a follow-up that finishes the objectstack-ai#5240 convergence item 2 opened the door for. - The same file's header states "`AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no length constraint" and "Tightening `AnalyticsDateRangeSchema` itself is `packages/spec`'s call and is deliberately NOT done here". Both sentences are true of the tree this PR starts from and false of the tree it produces. Same fence, same successor. - `content/docs/references/data/filter.mdx` and `ui/view.mdx` gain corrected tuple cells with no source change behind them, as set out in scope decision B. Successor: none needed — they are generated, and the regeneration is in this PR. --- ## Fix-up round 2 — F1 + F2 + F4 Scope from the at-tier review record (PR comment `5672172206`) and the seat's rulings (card comment `5672187836`): **F1 + F2 + F4, nothing else.** Run in `session_01KB5PFtxuy1x3dcR5gxudx6`. Head `ae337c01a9`. The LEVEL of the changeset is untouched — `minor` is ruled. ### Verdict on the rescue commit `1974dd56dc` — right to act, wrong on the fact; superseded That commit (seat-made, marked INCOMPLETE AND UNREVIEWED) rewrote the justification to read "the string-arm closing on this same schema (objectstack-ai#16322) declared `"@objectstack/spec": patch`". It was right that the original sentence ("the same grade every other accept-set narrowing on this schema has taken") sourced nothing. **Its replacement attribution does not hold, measured on this tree:** - `.changeset/analytics-daterange-driver-alignment.md` (objectstack-ai#16322) is still in `.changeset/`, unreleased, and describes its own spec entry as **"a `PROVENANCE_WAIVERS` row only"** — the driver half of objectstack-ai#16041, not an accept-set narrowing at the schema. - The accept-set narrowing on this schema is **objectstack-ai#16041**, and it shipped `"@objectstack/spec": minor` — `packages/spec/CHANGELOG.md` 17.4.0, commit `233222e`, under `### Minor Changes` (heading at :5, entry at :60, no intervening heading). So the review's F4 comparison was drawn against the wrong entry, and repeating it would have compiled a second inaccurate justification into the shipped CHANGELOG — the exact defect F4 names. The sentence now names objectstack-ai#16041, its grade and where to read it, and disposes of objectstack-ai#16322 explicitly so the next reader does not re-litigate it from the same mis-reading. The ruling is executed (the justification is corrected), not reopened (the level is not touched). ### F1 — a sentence that ships to npm was false **Door measurement taken here, not adopted from the review.** `datasetSelectionRefusal` driven directly over the built dist: | `dateRange` | status | code | message ends | |:--|--:|:--|:--| | `['2026-01-01']` | 400 | `ANALYTICS_DATE_RANGE_UNRECOGNIZED` | `… received a 1-element array, not the two bounds [start, end]. Refused at the schema (…)` | | `[]` | 400 | same | `… received an empty array … Refused at the schema (…)` | | `['a','b','c']` | 400 | same | `… received a 3-element array … Refused at the schema (…)` | | `['2026-01-01','2026-01-31']` | — | — | passes (`undefined`) | Mechanism confirmed in source: `analytics-selection-door.ts` parses the selection's shared members — `timeDimensions` included — against `AnalyticsQuerySchema.pick(…)`, and `rest-server.ts:10981` calls it before `queryDataset`. ⇒ the one route the TSDoc named as a "past the door" origin is a **schema-origin door**. Corrected at four sites, all naming the in-process `AnalyticsService.query` / `queryDataset` / dataset-executor path as the runtime origin and stating that every REST analytics route is a schema-origin door: 1. `packages/spec/src/data/analytics.zod.ts` — the shipped one (`files[]` carries `src/**/*.zod.ts` literally). 2. `packages/core/src/utils/analytics-date-range.ts` Reachability — pre-existing on the base, carried per the ruling. 3. `packages/core/src/utils/analytics-date-range.ts` "Every refusal raised here is raised PAST the schema door" — this PR's own new prose, which the review flagged as untrue of the REST dataset door. Narrowed to refusals whose MESSAGE leaves the constructor, with the one caller that keeps only `.code`/`.status` named. 4. the `service-analytics` `objectql-daterange.test.ts` comment. ### F2 — one condition, two wordings on the wire **Premise check first (falsifiable, and it holds):** on the base the arm was `z.array(z.string())` with no length constraint, so a 1-element array was not refused by the schema at all. No schema refusal, no second entry ⇒ this PR introduces the second wording, and it is this PR's to collapse. **Wire measurement, before:** on the dataset door, `['2026-01-01']` served `code: ANALYTICS_DATE_RANGE_UNRECOGNIZED` with a message ending `… Refused at the schema (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400).; selection.timeDimensions.0.dateRange: Too small: expected array to have >=2 items` — the prescription, then zod's restatement of the very arity it prescribes. A body wrong in more than the dateRange carried the same pair as two `fields[]` entries, so the defect was not confined to the lifted path. **Wire measurement, after** (same probe, same tree): `Too small` / `Too big` occurrences = 0 for `['2026-01-01']`, `[]` and `['a','b','c']`; prescription occurrences = 1; the legal window and the preset still pass; and the mixed body still carries `selection.measures` beside exactly one `selection.timeDimensions.0.dateRange` entry. **Where the collapse lives, and why there.** In `fieldsFromZodIssues` (`packages/types/src/validation-failure.ts`), keyed on the exported structural recogniser `isAnalyticsDateRangeRefusalIssue`. Both REST doors report through that one mapper — the runtime `/analytics/query` door already did; the dataset door now does too, instead of reading the raw D3 `zodIssuesToFields` — so the rule has ONE implementation. The alternatives were each worse: a copy at both doors is the two-copies-of-one-policy shape objectstack-ai#8124/objectstack-ai#8318 exist to end; the selection policy in `shared/union-branch-policy.ts` is shared with the terminal renderer and pinned against it by a parity test, so a rule added there would have to be schema-agnostic; and a schema-agnostic rule ("drop branch issues at the union's own path") silences the `objectstack-ai#4001` unknown-key prescriptions, which are raised at exactly that position. A new exported helper was rejected too: T3 of `check-widening-tells.mjs` reads a new `api-surface` row as a widening tell, and this PR's claim declares `Clause-②: no`. **What is deliberately NOT collapsed** — this narrows a restatement, never a diagnosis: - a branch issue naming a DEEPER position is kept. `['2026-01-01', 3]` still reports `selection.timeDimensions.0.dateRange.1` — WHICH bound is not a string is a location the prescription does not carry. - every other union expands exactly as before. ⛔ Union-branch expansion is not suppressed wholesale, so the stop condition in the ruling was not reached. **Pinned on the wire, in both directions** (the review recorded that nothing pinned it either way): 4 cases at the dataset door, 4 at `/analytics/query`, 5 in the shared mapper's own suite — including a CONTROL that a branch issue at its own branch root (`unrecognized_keys`, the same structural position) still reaches the wire for every other key. `@objectstack/types` and `@objectstack/rest` join the changeset: both publish a changed wire. ### Verification on `ae337c01a9` - Derived gate union (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived on the final head): **113 of 115 exit 0.** - The other 2 are `PREREQUISITE NOT MET`, exit 3, printing "⛔ This is NOT a pass: nothing was measured" — `check:dual-build-cjs-loads` and `check:type-check-debt`, both of which need the whole monorepo built. **NOT MEASURED**, not red; CI builds fresh. `check:skill-examples` refused the same way at first and was cleared by building `client-react` / `client`: 258 examples over 3 surfaces, exit 0. - `pnpm lint` (repo-wide, `eslint . --no-inline-config`): exit 0 — the whole population, so no narrowing to justify. - Full suites, all exit 0: spec 13624 · runtime 3644 · rest 3201 (+1 skipped) · service-analytics 2392 · core 1316 · types 671. - `typecheck` exit 0: spec, core, types, rest, runtime; `service-analytics tsc --noEmit` exit 0 with 0 errors (the review saw 38 there, all from unbuilt workspace deps in its worktree). - `check:type-source-resolution` exit 0 (135 tsc programs, 80 packages) — the new `@objectstack/spec/data` import in `packages/types` is a fifth subpath on a dependency that package already declares. - `dispatch-gates` prints a STALE TREE warning: its one stale input is `scripts/pm/check-harness-current.mjs`, added on `main` after this branch, whose own header says "⛔ not a CI gate" — so it adds no family here. ### Acceptance notes (noted, not filed) - `packages/services/service-analytics/src/__tests__/analytics-date-range-conformance.test.ts:32` carries the same "which types its selection from `AnalyticsQuery` and NEVER Zod-parses it" sentence about `POST /analytics/dataset/query`. It is pre-existing (not in this diff) and the ruling scoped this round to F1 + F2 + F4 naming three sites, so it is left alone. Not shipped prose — a test comment. Carrier: the next PR in this family, or objectstack-ai#18232. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…commits that decided them (objectstack-ai#20729) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the eighth stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/services/service-analytics/src/**` and nothing else. By the seat's census at the claim (`5899485578`), it is the largest package in the lane that no in-flight work holds. Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), by the method of stages 1 to 7 (PR objectstack-ai#20609 as `422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as `9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`). That is **76 sites on 76 lines in 22 files, covering 14 numbers**: - 42 census sites (every census site this package has); - 34 sites in test comments, which the census defers. The raw scan found no dead site the gate's grammar cannot see (see Acceptance notes), so there is no third class this time. Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and says in its own words what was decided: **13 distinct shas**. No number in this package has an ADR or ruling record of its own in the repository (a grep of `docs/adr/` for all 14 finds none, and a grep of the rest of `docs/` finds none either), so every anchor is a commit, per ruling C's order. No number was dropped. Only comments changed. Every touched source file keeps its line count (78 lines out, 78 in, over 22 files), so no line citation into these files moves. 2 of those 78 lines hold no dead citation: they are reflow lines, listed under Wordings below. No code token moves (see the guard below). **No citation number is added.** Every tracker number on an added line was already on the line it replaces: `objectstack-ai#10861` (5 lines), `objectstack-ai#12776` (3), `objectstack-ai#10413` (2), `objectstack-ai#16750` (2), and `objectstack-ai#10759`, `objectstack-ai#11152`, `objectstack-ai#5716` and the decision-batch ordinal `objectstack-ai#59` once each. Each tracker number among them resolves. Over the whole diff, added minus removed is 0 or negative for every number, and no number is new to the diff. No PR number is the citation on an added line: the two `PR #N` spellings in scope became their pull request's squash commit, and `objectstack-ai#16750` stays only as the convenience link beside `ed7243d52`, on the line it already stood on. Eight dead sites are left on purpose, all of them test strings (see the list below). One more file: a `patch` changeset for `@objectstack/service-analytics`, because the rewritten docblocks and inline comments ship (see Changeset below). The `AnalyticsResultWithDrill` type and its four sidecar members are not touched: its docblocks carry no dead number (`objectstack-ai#20644`, `objectstack-ai#3214` and `objectstack-ai#1752` all resolve). ## Census: `service-analytics`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only and unchanged. The count below is its `allocated-but-absent` findings under `packages/services/service-analytics/`. Each run counts as a reading only because its board frontier equals the newest issue number, read by a separate request just before and just after the run. | reading | tree | board | whole-repo `allocated-but-absent` | service-analytics sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `cbaf04c1f`, run 2026-09-29T21:41:53Z to 21:45:05Z | enumerated, 186 pages, frontier objectstack-ai#20721 (newest objectstack-ai#20721 before and after), 18,548 numbers | 1,161 | **42** | 42 | 10 | 10 | | after | head `967d73531`, run 21:55:23Z to 21:58:36Z | enumerated, 186 pages, frontier objectstack-ai#20723 (newest objectstack-ai#20723 before and after), 18,550 numbers | 1,119 | **0** | 0 | 0 | 0 | The before count matches the seat's census at the claim and A1 (42 sites): the two comments PR objectstack-ai#20712 rewrote in `analytics-service.ts` did not move it. The whole-repo drop is 42, exactly this diff's census sites. The `resolves` tally is 32,991 in both runs, and `resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did not move either. The after run was taken on `967d73531`; the head `82d2b40b2` adds only the changeset. No run was truncated or discarded: both enumerations read 186 pages at the newest frontier. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes test comments. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `namesThisRepository` over every `.ts` file under `service-analytics/src` (162 files). It takes its verdicts from the before census's own board reading rather than from a second enumeration: a number is dead when that census reported it `allocated-but-absent`, and alive when that census judged it on this board anywhere (its `--list` extraction) and did not report it. The 21 numbers the census never saw, because they stand only in test files or strings here, were read one by one on the issues endpoint: 17 answer 200, and `objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#16918` and `objectstack-ai#17125` answer 404. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `cbaf04c1f` | 3,514 | **84** | 42 | 34 | 0 | 8 | | after, `967d73531` | 3,438 | **8** | 0 | 0 | 0 | 8 | Its src-comment column equals the census's 42, which is the control on the second instrument. The 3,410 live citations and the 20 cross-repo citations are the same in both readings, and the drop of 76 citations is exactly the rewritten sites. A third, raw reading (every `#` followed by 2 to 6 digits, whatever surrounds it) finds 3,598 occurrences and 84 dead before, 3,522 and 8 after; its residue equals the gate's residue site for site, and it sees no dead site beyond the gate. ## Per-number table Sites and files count every dead occurrence in scope at the base (comments and strings, tests included). `rewritten / left` counts the sites rewritten and the sites left. Each anchor was read in its message and diff, not only its subject, and `git blame` at the base puts every rewritten line in its anchor commit or in a later commit that descends from it (`merge-base --is-ancestor` exit 0 for each pair). | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#11461` | 20/2 | 19/1 | `399ecad58`: a cross-object leaf in one measure's own `filter` (the third producer, lowered onto `aggregations[].filter`) is refused on both ObjectQL doors with `INVALID_FIELD` / 400 naming the measure, folded into the one member view, with insertion order keeping every earlier refusal's message. The last line of its message names `objectstack-ai#11461` as the card it settles. New to the sweep | | `objectstack-ai#17130` | 17/5 | 13/4 | `54b3d1d4a` (PR objectstack-ai#17336): the row-scope resolution refusals carry `READ_SCOPE_COMPILE_FAILED` / 500 through one constructor, so `queryDataset`'s catch re-throws them instead of reading their words, every message byte-unchanged; plus the source-derived wording-collision guard. Named in its diff only (18 added lines carry the tag). New to the sweep | | `objectstack-ai#17124` | 12/8 | 10/2 | `86c505286` (PR objectstack-ai#17593): `explicitDateRangeWindow` is the one reading of `dateRange`'s array arm on all four faces, and an array that is not two string bounds is refused with `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. Named in its diff only (its changeset file is `17124-daterange-array-arm-arity.md`). New to the sweep | | `objectstack-ai#12209` | 10/5 | 10/0 | `017130a09` (PR objectstack-ai#12318): a custom-SQL measure is refused on the ObjectQL aggregate path with `INVALID_FIELD` / 400, keyed on the `EXPRESSION_METRIC_TYPES` partition shared with `NativeSQLStrategy`. Its message records the two failure modes the lines describe (`driver-sql` blaming a `function` key, the in-memory evaluator answering `null` per bucket). Named in its diff only. New to the sweep | | `objectstack-ai#16778` | 5/1 | 4/1 | `357f4992b`: the compile-leg refusal of an aggregate a datetime measure's field type cannot carry, scoped to temporal source fields. The squash commit of the pull request that was `objectstack-ai#16778`; its subject carries the number. New to the sweep | | `objectstack-ai#12940` | 4/2 | 4/0 | `aa16721b6` (PR objectstack-ai#13361): this package's consumer-local `executeAggregate` config mirrors (the plugin options and `AnalyticsServiceConfig`) narrow `aggregations[].method` to `AggregationFunction`, after `objectstack-ai#12776` narrowed the contract. Named in its diff only. New to the sweep | | `objectstack-ai#17015` | 4/2 | 4/0 | `0da638cd9`: the closed `dateRange` preset vocabulary is lowered once and the rest refused, the `[range, range]` fallback is removed from the faces it reached, and the shared conformance kit holds them. The squash commit of the pull request that was `objectstack-ai#17015`. New to the sweep | | `objectstack-ai#16860` | 3/1 | 3/0 | `041d9fdc6`: the object-level read grant is asked at the analytics door, and its bridge to the `security` service resolves an explicit three-way (absent admits; throwing or method-less denies at `error`, finding F3 in its message). The squash commit of the pull request that was `objectstack-ai#16860`. New to the sweep | | `objectstack-ai#12248` | 2/1 | 2/0 | `8425c17cc`: the five ruled engine members, `getDriverForObject?` and `resolveEffectiveDatasource` among them, adopted onto `IDataEngine`, and `getObject` typed. Its subject names it. Stage 5's and the spec stage's anchor | | `objectstack-ai#16685` | 2/2 | 2/0 | `ed7243d52` (PR objectstack-ai#16750): `boolean` / `toggle` accepted for `sum` / `avg` / `min` / `max` in the aggregate × field-type table, holding maintainer ruling `objectstack-ai#11152`. Its subject names it. The spec stage's anchor | | `objectstack-ai#17125` | 2/2 | 2/0 | `5d12b16e7`: the row-scope bridge tells an absent security service from a broken one, so a broken one refuses the query. The squash commit of the pull request that was `objectstack-ai#17125` (404 on the pulls endpoint too). New to the sweep | | `objectstack-ai#16918` | 1/1 | 1/0 | `5d12b16e7`: the same commit. Its changeset's headline names `objectstack-ai#16918` as the card it answers, and its diff writes the line (`admission-bridge-resolution.test.ts:120`) | | `objectstack-ai#6123` | 1/1 | 1/0 | `59d1933f9`: `err.code` lands at `error.code`, not `error.details.code`; the commit that wrote this very line. The `runtime` stage's anchor | | `objectstack-ai#13279` | 1/1 | 1/0 | `6a180e42d`: permission-store read failures fail loud, and the same commit renames `metadata/src/utils/schema-sync-errors.ts` to `packages/types/src/driver-error-classification.ts`, the move the line describes. The anchor of stages 2, 5 and 6, and of the `types`, `rest` and `runtime` stages | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each of the 13), and every one is an ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 13; control leg: stage 1's landing `422db788a` exit 0; the history is complete, `--is-shallow-repository` false, 15,135 commits). Each of the 14 numbers answers 404 on the issues endpoint, read one by one; `objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#17015` and `objectstack-ai#17125` answer 404 on the pulls endpoint too. ## Wordings to check - **Bracket tags.** `[#N]` became `[commit SHA]`, as in stage 7; `[objectstack-ai#10861 / objectstack-ai#11461]` and `[objectstack-ai#10861, objectstack-ai#11461]` keep the live `objectstack-ai#10861` beside the new sha. - **The boolean rows, `measure-result-type.ts:115-116` and `aggregate-datetime-measure-refusal.test.ts:65-66`.** 「objectstack-ai#16685 ruled A, landed as objectstack-ai#16750」 and 「objectstack-ai#16685 was ruled A and objectstack-ai#16750 added」 became 「commit ed7243d (objectstack-ai#16750) added those rows」 and 「commit ed7243d (objectstack-ai#16750) added」. 「ruled A」 named an option on the dead card; `ed7243d52`'s message records the decision itself. Line 116 of the first file and line 66 of the second are the 2 reflow lines: each keeps the `objectstack-ai#16750` it already carried. - **PR numbers, `read-scope-resolution-envelope.test.ts:25` and `refusal-wording-collision.test.ts:21`.** 「PR objectstack-ai#17125's refusal」 became 「Commit 5d12b16's refusal」, the pull request's squash commit. - **`read-scope-refusal.ts:29`.** 「objectstack-ai#17130 exists to remove it」 became 「commit 54b3d1d was made to remove it」, the form stage 6 used. - **`refusal-wording-collision.test.ts:49`.** 「the exact move objectstack-ai#17130 forbids」 became 「the exact move commit 54b3d1d ruled out」; its message says the fix is the declaration, not a luckier string. - **`read-scope-resolution-envelope.test.ts:161`.** The verb after the number moved from present to past tense with the sha. - **`measure-expression-both-strategies.test.ts:45` and `:166`.** 「deleting the objectstack-ai#12209 arm in」 became 「deleting the arm commit 017130a added in」, and 「every objectstack-ai#12209 refusal」 became 「every custom-SQL refusal (commit 017130a)」. - **`dataset-executor.ts:609`.** 「objectstack-ai#17015's kit」 became 「commit 0da638c's kit」, the conformance kit that commit built. - **`plugin.ts:116`.** 「and in objectstack-ai#12209:」 became 「and in commit 017130a:」, whose message records the two ways the engine failed. - **`analytics-service.ts:238`.** 「objectstack-ai#13279 moved it there」 became 「commit 6a180e4 moved it there」; that commit's diff is the rename. ## The 8 sites left - **Test strings, 8 sites**, left as stages 1 to 7 left theirs, all `describe` / `it` titles: - `crossobject-conjunct-refusal.test.ts:589` (`objectstack-ai#11461`); - `aggregate-nontemporal-measure-refusal.test.ts:243` (`objectstack-ai#16778`); - `date-range-array-arm-arity.test.ts:213` and `:294` (`objectstack-ai#17124`); - `read-scope-resolution-envelope.test.ts:155`, `:199` and `:226`, and `refusal-wording-collision.test.ts:336` (`objectstack-ai#17130`). - There is no operator string, generated file or quoted ruling carrying a dead number in this package. It has no generated file at all. ## Mechanical guard: no code token moves The guard compares the TypeScript parser's leaf nodes, with comments as trivia and JSDoc nodes never visited, base `cbaf04c1f` against head. Template literals are therefore read in context. It ran over all 22 touched `.ts` files. - Real run: 26,705 base leaf tokens, **0 files with a token change** (exit 0). - Comment control in `plugin.ts` (「refusal buys is in」 to 「refusal earns is in」): 0 files changed, as expected (exit 0). - Positive control, a code token added in `plugin.ts` (`field: a.field,` given `as string`): DIFFER (exit 1). - Positive control, one digit changed inside a kept test title (`date-range-array-arm-arity.test.ts:213`): DIFFER (exit 1). Every mutation went through `scripts/ablation-replace.mjs`, and each landed (anchor 1 to 0, blob changed). Each restore was proven byte-identical to the HEAD blob (`ad3dc9fff4d3`, `a606ffbb6ead`), with `git diff HEAD` empty and a clean tree afterwards. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/service-analytics` (`.changeset/20596-service-analytics-provenance-anchors.md`) is included. Its body is stage 7's, word for word, with the package name changed. Measured on the built package (A3): `files[]` is `dist`, `README.md` and `CHANGELOG.md`. After the build (a cache miss for this package, so `dist` is this head's source), the rewritten comments reach `dist`: `399ecad58` 6 times in each of `dist/index.js`, `index.cjs`, `index.d.ts` and `index.d.cts`; `86c505286` twice in each JS file and once in each declaration file; `54b3d1d4a` once in all four; `aa16721b6` once in each JS file and twice in each declaration file; `017130a09` once in each JS file. Positive controls: the unchanged line 「none of the coverage: a compiled measure's own」, in the same docblock as the shipped rewrite at `objectql-strategy.ts:744`, is found once in each of the four files, and the unchanged line 「back into line. Widening it here again would not be a local matter」 beside the shipped rewrite at `analytics-service.ts:559` once in each declaration file. A never-written negative phrase appears nowhere in `dist`. None of the 14 dead numbers is left anywhere in `dist`. ## Gates (head `82d2b40b2`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` (self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0: the diff-scoped run judged 11 citations across 10 files; 10 resolve and 1 resolves as a pull request (`objectstack-ai#16750`, the convenience link that already stood on its line). - **Doc authoring:** `pnpm check:doc-authoring` exits 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `82d2b40b2` derived 62 commands: all 56 derived at dispatch, plus `check:engine-double-contract`, `check:objectql-double-limit`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt` and `check:where-matcher`. Each ran with its exit code captured before any pipe, and all 62 exit 0. `--ran`, fed each command with its exit code, reports 62 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A full `turbo run build` of `./packages/*` and `./packages/*/*` ran first under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an unbuilt workspace. - **Roster families the derivation lists outside its commands** (their rosters sit in directories this diff touches): `node scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit 0. - **Tests and typecheck, under the verify lock:** - `pnpm --filter @objectstack/service-analytics test`: 137 files pass and 3,216 tests pass. That is every test file in the package, the 12 touched ones included. - `pnpm --filter @objectstack/service-analytics typecheck` exits 0 (`tsc --noEmit` on `tsconfig.json`). `--listFiles`: the program holds all 162 files under `src/`, the 137 test files and all 22 touched files included. - **Lint, as a proven narrowing:** `eslint --no-inline-config --format json` over the 22 touched `.ts` files gives 22 files, 0 errors and 0 warnings. All 22 are in eslint's own population (`isPathIgnored` is false for each; a `dist` file, as the control, is ignored). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own lines 327-328 state), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 23 changed files for control bytes finds none. ## Acceptance notes - **The gate-invisible spellings, grepped as the claim asked.** `CITATION_RE` refuses a hyphen after the digits and a `/` before the `#` (objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word 「option」. In this package: - `#N-word`: 8 lines by a plain grep, and 7 once a hyphen before the `#` is excluded too, which is the claim's 7. The eighth is 「pre-objectstack-ai#10413-phase-2」 (`execution-context-bridge.test.ts:223`). The numbers, `objectstack-ai#10413`, `objectstack-ai#5298`, `objectstack-ai#13570` and `objectstack-ai#13640`, all resolve. - `#A/#B`: 29 lines, the claim's 29, over 28 distinct numbers. All resolve; `objectstack-ai#2149`, which the census never judged, was read on its own. - `option #N`: none. So nothing here needed a rewrite beyond the gate, and the raw scan agrees. - **「This card」 phrases are left.** 113 lines in 39 files of this package speak of 「this card」, 「that card」 or 「the card」. They carry no number, neither instrument sees them, and most sit in blocks whose numbers still resolve. Stage 7 rewrote two such lines as lost referents; here none is changed, because the phrase runs through the whole package and rewriting a subset would be arbitrary. - **Prose that names `queryDataset`'s catch, not changed.** Nine comment lines say `queryDataset`'s catch. Since `10c36cc43` that catch sits in the private `answerDataset`, whose docblock calls it the body of `queryDataset`, so the lines still hold at the level of the public method. This is not a dead citation, so it is outside this stage. - **The census instrument did not truncate in this stage.** Both enumerations read 186 pages at the newest frontier. - **Anchors the next stages can reuse**, each checked here: `objectstack-ai#11461` → `399ecad58`; `objectstack-ai#17130` → `54b3d1d4a`; `objectstack-ai#17124` → `86c505286`; `objectstack-ai#12209` → `017130a09`; `objectstack-ai#16778` → `357f4992b`; `objectstack-ai#12940` → `aa16721b6`; `objectstack-ai#17015` → `0da638cd9`; `objectstack-ai#16860` → `041d9fdc6`; `objectstack-ai#17125` and `objectstack-ai#16918` → `5d12b16e7`. - **Base.** The branch is on `main` at `cbaf04c1f`. `main` has since moved four commits (`3711e0b76`, `61455de27`, `6afccda5a`, `671d4c164`). They touch `packages/spec`, `packages/metadata/package.json`, `pnpm-lock.yaml`, docs and changesets, and no file under `service-analytics` or in this diff, so no merge was taken; the merge queue rebuilds on the merged generation. One of them, `671d4c164`, declares the four drill-through sidecars on `AnalyticsResult` in the spec. This diff leaves the local `AnalyticsResultWithDrill` untouched, as the claim requires. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- 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