From b6d13fa9af3d104f0938328b5fd8e171a819c1e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 06:33:16 +0000 Subject: [PATCH 1/3] fix(service-analytics): raise the ONE shared dateRange refusal wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `date-range-array-arm.ts` overwrote `err.message` with a second wording for `ANALYTICS_DATE_RANGE_UNRECOGNIZED`, on two stated grounds: the shared sentence judged a bare STRING against the preset vocabulary, and it ended with "Refused at the schema". Both grounds are gone — `analyticsDateRangeRefusalMessage` now describes a non-string by what is wrong with it and takes the origin as a required parameter — so the arm raises the shared sentence unchanged, which is what the one-condition-one-wording convention asks for. Also corrects three sentences the same file's header carried that the tightened schema arm and the REST dataset door had made false, and keeps `@objectstack/core`'s prose true about which callers still supply their own message. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude --- .../core/src/utils/analytics-date-range.ts | 30 +++-- .../date-range-array-arm-arity.test.ts | 56 +++++++-- .../src/date-range-array-arm.ts | 108 ++++++++++-------- 3 files changed, 128 insertions(+), 66 deletions(-) diff --git a/packages/core/src/utils/analytics-date-range.ts b/packages/core/src/utils/analytics-date-range.ts index 3da399635e..9bcc53dec3 100644 --- a/packages/core/src/utils/analytics-date-range.ts +++ b/packages/core/src/utils/analytics-date-range.ts @@ -213,15 +213,19 @@ export function resolveAnalyticsDateRangePreset( * every one of them, and sent an author to inspect a parse call that never * ran. The origin is a parameter precisely so this call site states the truth * it alone knows; ⛔ it is never omitted and there is no default to omit it to. - * Three of the four callers keep the `.code`/`.status` and supply their OWN + * TWO of the four callers keep the `.code`/`.status` and supply their OWN * message: the REST dataset door (`rest/src/analytics-selection-door.ts`), * which serves the schema's own prescription because its refusal IS the - * schema's, and the two face-side array arms - * (`service-analytics/src/date-range-array-arm.ts` and - * `driver-memory/src/memory-analytics.ts`), each of which names what its own - * face would otherwise have guessed. The sentence built here leaves only - * through this package's own string resolver below — reached in process, past - * every door — which is the caller the `'runtime'` origin describes. + * schema's, and `driver-memory`'s face-side array arm + * (`driver-memory/src/memory-analytics.ts`), which names what its own face + * would otherwise have guessed. ⚠️ `service-analytics`' array arm + * (`service-analytics/src/date-range-array-arm.ts`) was a third until #18232: + * it overwrote the message on two grounds — a sentence that judged a bare + * STRING, and "Refused at the schema" — that this parameter and + * `describeRefusedDateRange` removed, so it now raises the sentence built here + * unchanged. That sentence therefore leaves through this package's own string + * resolver below AND through that arm, which are the callers the `'runtime'` + * origin describes. * * ⚠️ The code is registered under `@objectstack/runtime` (the door that names * the wire vocabulary) and this package carries a recorded provenance waiver @@ -242,10 +246,14 @@ export function resolveAnalyticsDateRangePreset( * `z.tuple([z.string(), z.string()])` — it judges arity and bound TYPE, never a * bound's VALUE — so the residue it cannot refuse, a two-string tuple with an * empty bound such as `['', '']`, passes every door and reaches this - * constructor at each face (`date-range-array-arm.ts`, `memory-analytics.ts`), - * which then replaces the message. What does hold on a REST route is about the - * SENTENCE: each of the three callers a route can reach supplies its own, and a - * string that passed the preset enum cannot reach the resolver's throw below. + * constructor at each face (`date-range-array-arm.ts`, `memory-analytics.ts`) — + * `memory-analytics.ts` then replaces the message and, since #18232, + * `date-range-array-arm.ts` does not. What does hold on a REST route is about + * the SENTENCE: each of the three callers a route can reach answers with the + * sentence chosen for its own door — the schema's own prescription at the REST + * door, `driver-memory`'s at that face, and the one built here at + * `service-analytics`' — and a string that passed the preset enum cannot reach + * the resolver's throw below. * This is also the answer for the IN-PROCESS caller past those doors — * `AnalyticsService.query`, `queryDataset` and the dataset executor behind it, * and a driver's cube face called directly. diff --git a/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts b/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts index 30e70086e6..f1222b7c3c 100644 --- a/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts +++ b/packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts @@ -29,8 +29,18 @@ * throwing a bare `Error` would satisfy, and which `[null, null]`'s * `TypeError` did satisfy; * - ONE envelope across the four, because one condition gets one envelope; - * - the message discipline: what arrived, the two-element contract, the - * single-day spelling to write instead; + * - ⭐ the WORDING, byte-for-byte equal to the spec's own + * `analyticsDateRangeRefusalMessage(input, 'runtime')` on every face and + * every shape — one condition keeps one wording (#5240), and #18232 removed + * the second wording this package used to overwrite it with once #18230 had + * removed both grounds that wording gave for existing. ⛔ Pinned by + * IDENTITY, not by substrings of a sentence this package owns: a substring + * pin is satisfied by any private wording that quotes the contract, which is + * exactly what stood here before; + * - the discipline that identity alone does NOT buy — identity tracks the + * shared builder wherever it goes, so what arrived, the two-bound contract, + * the single-day spelling and the RUNTIME origin are asserted as well, with + * the schema-origin sentence as the negative control; * - ⭐ the CONTROL that the refusal did not widen — the two-element window * answers byte-for-byte as it did before on each face, including the #3777 * half-open bare-day widening on the SQL side and the inclusive upper @@ -48,6 +58,7 @@ import { DatasetSchema } from '@objectstack/spec/ui'; import type { Cube } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { AnalyticsQuery, AnalyticsResult, IAnalyticsService } from '@objectstack/spec/contracts'; +import { analyticsDateRangeRefusalMessage } from '@objectstack/spec/data'; import { AnalyticsService } from '../analytics-service.js'; import { evaluateAnalyticsQueryOverRows } from '../preview-evaluator.js'; import { compileDataset } from '../dataset-compiler.js'; @@ -220,15 +231,42 @@ describe('#17124 — an array arm that is not a two-bound window is REFUSED on e expect(envelopes.size, `raised ${envelopes.size} envelopes: ${[...envelopes].join(' | ')}`).toBe(1); }); - it('says what arrived, the two-element contract, and the single-day spelling to write', async () => { - const msg = String((await refusalFrom(() => objectqlBounds(['2026-01-01'])))?.message); - // ① what arrived — so the author can find it in the document they wrote. - expect(msg).toContain('["2026-01-01"]'); + it('every face raises the SHARED wording, byte-for-byte — one condition, one wording', async () => { + // ⭐ #18232. The `.code`/`.status` pin above is satisfied by a face that + // keeps the envelope and writes its own sentence — which is precisely what + // this package did until #18230 gave `analyticsDateRangeRefusalMessage` its + // `origin` parameter and removed the two grounds the second wording named. + for (const [faceName, drive] of FACES) { + for (const [shapeName, range] of NOT_A_WINDOW) { + const msg = String((await refusalFrom(() => drive(range)))?.message); + expect(msg, `${faceName} answered ${shapeName} with a wording of its own`) + .toBe(analyticsDateRangeRefusalMessage(range, 'runtime')); + } + } + }); + + it('that shared wording says what is wrong, the contract, the spelling and the ORIGIN', async () => { + // ⚠️ Identity above tracks the shared builder wherever it goes, so it + // would stay green if the builder itself dropped a clause. These four are + // the clauses an author needs, asserted against the sentence they receive. + const range: readonly string[] = ['2026-01-01']; + const msg = String((await refusalFrom(() => objectqlBounds(range)))?.message); + // ① what is WRONG with what arrived — the spec describes the shape rather + // than echoing the value (`describeRefusedDateRange`). expect(msg).toContain('1-element array'); // ② the contract, in the spec's own words. - expect(msg).toContain('TWO-element array [start, end]'); - // ③ what to do instead — the spelling every face already agrees on. - expect(msg).toContain('["2026-01-01", "2026-01-01"]'); + expect(msg).toContain('two-element array [start, end]'); + // ③ what to do instead — the single day written as both bounds. + expect(msg).toContain('BOTH bounds'); + // ④ ⭐ the ORIGIN clause #18230 made a parameter: this refusal happened + // PAST the schema door, so the schema-origin sentence is the one it must + // NOT be — the negative control for the identity pin above. + expect(msg).toContain('Refused past the schema door'); + expect(msg).not.toContain('Refused at the schema'); + expect(msg).not.toBe(analyticsDateRangeRefusalMessage(range, 'schema')); + // ⑤ ⛔ and no package-private prefix: the second wording announced itself + // with one, so its absence is checkable. + expect(msg).not.toContain('[service-analytics]'); }); }); diff --git a/packages/services/service-analytics/src/date-range-array-arm.ts b/packages/services/service-analytics/src/date-range-array-arm.ts index 6930191ce3..bc042a70ed 100644 --- a/packages/services/service-analytics/src/date-range-array-arm.ts +++ b/packages/services/service-analytics/src/date-range-array-arm.ts @@ -6,10 +6,10 @@ * * ## What was wrong * - * `AnalyticsDateRangeSchema`'s array arm is a bare `z.array(z.string())` with no - * length constraint, so `['2026-01-01']` is schema-valid, and the four faces in - * this package that read the arm answered it three different ways — MEASURED on - * `abc4b83ce`, one authored document over the same four rows: + * `AnalyticsDateRangeSchema`'s array arm WAS a bare `z.array(z.string())` with + * no length constraint, so `['2026-01-01']` was schema-valid, and the four + * faces in this package that read the arm answered it three different ways — + * MEASURED on `abc4b83ce`, one authored document over the same four rows: * * | face | `['2026-01-01']` meant | * |---|---| @@ -36,55 +36,73 @@ * * and by the shipped #16322 migration table, which tells an author to write a * single day as `['2026-01-20', '2026-01-20']` — TWO bounds. So the arm's arity - * is declared; only the Zod type is weaker than the contract the same file - * states. A one-element array is therefore not an under-specified shape needing - * a meaning invented for it: it is a shape the contract already excludes, and - * the kit's rule for a `dateRange` that does not denote a window is - * *"an unresolvable window is a refusal, never a window"*. + * was declared long before the Zod type said it; only that type was weaker than + * the contract the same file states. A one-element array is therefore not an + * under-specified shape needing a meaning invented for it: it is a shape the + * contract already excludes, and the kit's rule for a `dateRange` that does not + * denote a window is *"an unresolvable window is a refusal, never a window"*. * * ⭐ And the author loses nothing: `['2026-01-01', '2026-01-01']` selects exactly * that one day on all four faces today (measured as this change's control), so * the refusal costs a second bound and buys a document that means one thing. * - * ## Reachability — why a face-side refusal exists at all + * ## Reachability — why a face-side refusal is STILL needed (#18232) * - * `POST /analytics/dataset/query` types its selection from `AnalyticsQuery` and - * never Zod-parses it, so the schema door is BEHIND these faces. ⛔ Tightening - * `AnalyticsDateRangeSchema` itself is `packages/spec`'s call and is deliberately - * NOT done here; this is the in-process door past that one, the same seam an - * unrecognised `compareTo.kind` is refused at. + * ⚠️ Three sentences this section carried until #18232 were true when written + * and are false now, because `packages/spec` has since made the call they said + * it had not made. Re-measured on `origin/main`: + * + * - the array arm is `z.tuple([z.string(), z.string()])`, ⛔ no longer the + * bare `z.array(z.string())` the section above describes as history — + * maintainer ruling A on #17598 (decision batch #117 item 3), landed by + * PR #18230; + * - so tightening `AnalyticsDateRangeSchema` is ⛔ not "deliberately NOT done + * here" any more: it is done, upstream, where the contract lives; + * - and `POST /analytics/dataset/query` is ⛔ no longer a route that never + * Zod-parses its selection — since PR #17548 it parses the selection's + * shared members, `timeDimensions` among them, against + * `AnalyticsQuerySchema.pick(…)` ahead of the executor + * (`rest/src/analytics-selection-door.ts`, wired in `rest-server.ts`), so on + * THAT route the schema door is AHEAD of these faces, not behind them. + * + * ⛔ None of which retires this door. Two reasons, structural rather than + * historical: + * + * - **The in-process callers never parse.** `AnalyticsService.query`, + * `queryDataset` and the dataset executor behind them type their selection + * from `AnalyticsQuery` and Zod-parse nothing, so every shape in the table + * above still arrives at these faces exactly as it did. + * - **The tuple judges ARITY and bound TYPE, never a bound's VALUE.** The + * residue it cannot refuse — a two-string window with an empty bound, + * `['', '']` — satisfies the arm at every door and is refused here. */ import { analyticsDateRangeUnrecognizedError } from '@objectstack/core'; -/** - * Build the ADR-0112 refusal for an array arm that does not denote a window. - * - * ⭐ The ENVELOPE comes from the ONE shared constructor — the `code` + `status` - * pair is what the cross-package conformance kit reads, and it must have a - * single origin. Only the SENTENCE is this condition's own: the shared wording - * judges a bare STRING against the preset vocabulary and ends with - * "Refused at the schema", and neither is true of an array refused past the - * schema door by a face. ⛔ A message stating two falsehoods is not reuse. - */ -function arrayArmRefusal(dateRange: readonly unknown[], received: string): Error { - const err = analyticsDateRangeUnrecognizedError(dateRange); - err.message = - `[service-analytics] dateRange ${JSON.stringify(dateRange)} ${received}. An explicit ` - + 'window is the TWO-element array [start, end] of ISO dates or {date-macro} tokens — ' - + 'e.g. ["2026-01-01", "2026-01-31"] or ["{7_days_ago}", "{today}"]; for a single day ' - + 'write both bounds, ["2026-01-01", "2026-01-01"]. Refused ' - + '(ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400) rather than guessed: this package\'s four ' - + 'analytics faces read an odd-sized array three different ways — a point window, a ' - + 'window dropped to all of history, and an upper bound left unwritten — so any number ' - + 'computed from one would depend on which backend answered.'; - return err; -} - /** * The CALLER's explicit window, as the two bounds every face in this package * lowers — or the refusal. * + * ## ⭐ ONE condition, ONE wording — envelope AND sentence (#18232) + * + * The `code` + `status` pair has always come from the ONE shared constructor, + * because the cross-package conformance kit reads it and it must have a single + * origin. The MESSAGE used to be OVERWRITTEN here with a second wording for the + * same condition, on two stated grounds: that the shared sentence judged a bare + * STRING against the preset vocabulary, and that it ended with "Refused at the + * schema" — neither true of an array refused past the schema door by a face. + * ⛔ PR #18230 removed both grounds. `analyticsDateRangeRefusalMessage` now + * describes a non-string by what is WRONG with it (`describeRefusedDateRange`) + * and takes the ORIGIN as a required parameter, so the sentence + * `analyticsDateRangeUnrecognizedError` builds — origin `'runtime'`, *"Refused + * past the schema door, by the analytics reader that received it"* — is true of + * exactly this door. With its two grounds gone, the second wording was what the + * #5240 convention exists to prevent: one condition with two wordings. + * + * ⛔ The reason this package refuses rather than guesses is this module's + * header, ⛔ not the message — re-stating it in the sentence is how the second + * wording got here in the first place. + * * ⛔ Bound VALUES are not judged here: a bare `YYYY-MM-DD` versus a full * timestamp is a per-face calendar translation (#3777 / #4042) and a * `{date-macro}` token is expanded upstream, neither of which this arity rule @@ -93,20 +111,18 @@ function arrayArmRefusal(dateRange: readonly unknown[], received: string): Error * * @param dateRange - the array arm as it reached the face, unparsed. * @returns the two bounds, in the order the author wrote them. - * @throws the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope when - * the array is not exactly two string bounds. + * @throws the ADR-0112 `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 envelope, + * carrying the spec's own `'runtime'`-origin wording, when the array is not + * exactly two non-empty string bounds. */ export function explicitDateRangeWindow(dateRange: readonly unknown[]): [string, string] { if (dateRange.length !== 2) { - throw arrayArmRefusal(dateRange, `is a ${dateRange.length}-element array, not a window`); + throw analyticsDateRangeUnrecognizedError(dateRange); } const [start, end] = dateRange; for (const bound of [start, end]) { if (typeof bound !== 'string' || bound.length === 0) { - throw arrayArmRefusal( - dateRange, - `has a bound that is not a date string (${bound === null ? 'null' : typeof bound})`, - ); + throw analyticsDateRangeUnrecognizedError(dateRange); } } return [start as string, end as string]; From 18efc772bd9f1a541b3d7f611436e92025f7933f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 06:48:00 +0000 Subject: [PATCH 2/3] chore: changeset for the one-wording analytics dateRange refusal Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude --- .../18232-analytics-one-refusal-wording.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/18232-analytics-one-refusal-wording.md diff --git a/.changeset/18232-analytics-one-refusal-wording.md b/.changeset/18232-analytics-one-refusal-wording.md new file mode 100644 index 0000000000..adc6192a8b --- /dev/null +++ b/.changeset/18232-analytics-one-refusal-wording.md @@ -0,0 +1,24 @@ +--- +'@objectstack/service-analytics': patch +'@objectstack/core': patch +--- + +analytics `dateRange`: one condition, one refusal wording + +An array `dateRange` that is not a two-bound window is refused by the +`service-analytics` faces with the platform's ONE shared sentence +(`analyticsDateRangeRefusalMessage`, origin `runtime`) instead of a +package-private second wording. The envelope is unchanged — +`ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400 — so nothing that classifies on +`code`/`status` is affected; only the `message` text changes, and it now agrees +byte-for-byte with the sentence the schema door answers with for the same value. + +The second wording existed because the shared sentence used to judge a bare +string against the preset vocabulary and to end with "Refused at the schema", +neither of which is true of an array refused past the schema door. Both grounds +were removed when `analyticsDateRangeRefusalMessage` gained its required +`origin` parameter and began describing a non-string by what is wrong with it. + +⛔ If you match on the old text (`[service-analytics] dateRange …`), match on +`error.code === 'ANALYTICS_DATE_RANGE_UNRECOGNIZED'` instead — the message was +never the contract, the envelope is. From cf1d1ae493ed7b0c4ca6900e5e24c6c695f805f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 07:11:07 +0000 Subject: [PATCH 3/3] docs(changeset): say that the refusal no longer echoes an array dateRange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The envelope note covered classification; it did not cover the one thing a caller reading a 400 in a log will notice. The shared sentence describes a non-string by its shape, so every array shape this face refuses stops carrying the value that was sent — not just the degenerate ones. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude --- .changeset/18232-analytics-one-refusal-wording.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.changeset/18232-analytics-one-refusal-wording.md b/.changeset/18232-analytics-one-refusal-wording.md index adc6192a8b..d1b2520a78 100644 --- a/.changeset/18232-analytics-one-refusal-wording.md +++ b/.changeset/18232-analytics-one-refusal-wording.md @@ -19,6 +19,17 @@ neither of which is true of an array refused past the schema door. Both grounds were removed when `analyticsDateRangeRefusalMessage` gained its required `origin` parameter and began describing a non-string by what is wrong with it. +⚠️ **The message no longer echoes the value you sent.** For an ARRAY +`dateRange` the shared sentence DESCRIBES the shape instead: what used to read +`dateRange ["a","b","c"] is a 3-element array` now reads `received a 3-element +array, not the two bounds [start, end]`. That applies to EVERY array shape this +face refuses, not to unusual ones only — `[null, null]` now reads `received an +array with a non-string bound`, and `['', '']` is where the description carries +least, `received a two-element array`. A bare STRING `dateRange` is still quoted +back to you. So a log line that used to carry the offending array no longer +does: if you need the value at that site, read it from the request you already +have, ⛔ not from the message. + ⛔ If you match on the old text (`[service-analytics] dateRange …`), match on `error.code === 'ANALYTICS_DATE_RANGE_UNRECOGNIZED'` instead — the message was never the contract, the envelope is.