Skip to content

fix(analytics)!: a query window outside the non-negative integers is refused at the door, and an offset with no limit runs on SQLite - #21399

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21365-analytics-window
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21365-analytics-window

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Part of #21365
Clause-②: yes (narrowing)

What this changes

Two halves of one family: an analytics query window that had no single answer across drivers and faces.

1. The contract (packages/spec). AnalyticsQuerySchema.limit and .offset were a bare z.number(). They are z.number().int().nonnegative() now (packages/spec/src/data/analytics.zod.ts), each with a .describe().

  • DatasetSelectionSchema reads the same two declarations off AnalyticsQuerySchema.shape, and AnalyticsQueryRequestSchema extends the query. So all three doors hold one accept set, and there is no second declaration. A spec pin asserts that the dataset selection's two members are the same instances.
  • The doors already parse with these schemas: POST /analytics/query and POST /analytics/sql (packages/runtime/src/domains/analytics.ts) and POST /analytics/dataset/query (packages/rest/src/analytics-selection-door.ts). A negative or fractional value now answers 400 VALIDATION_FAILED, with details.fields[].field naming limit or offset (selection.limit / selection.offset at the dataset door), before any engine runs. There is no new refusal code and no runtime code change.
  • limit: 0 stays legal (LIMIT 0, no rows). An offset with no limit stays legal.
  • The TypeScript types are unchanged (number). Only the parse narrows.

2. The offset-only window on the native face (service-analytics). NativeSQLStrategy.assembleStatement wrote OFFSET n with no LIMIT in front of it, which SQLite cannot parse. The window clause is now windowClauseSql(limit, offset, dialect) in native-sql-strategy.ts, with the dialect read off the existing sqlDialect hook (sqlDialectFor, filled by the plugin from the executing driver's dialectName). An offset with no limit takes the dialect's no-limit spelling:

dialect offset-only window evidence
sqlite LIMIT -1 OFFSET n executed (better-sqlite3)
postgres OFFSET n (bytes unchanged) executed (PostgreSQL 16.14)
unknown (no hook wired) LIMIT 9223372036854775807 OFFSET n executed on both engines above
mysql LIMIT 18446744073709551615 OFFSET n NOT MEASURED, text only (no MySQL server here)

The named-dialect cells are the spelling the driver's own query compiler emits for the same window (knex 3.3.0: limit -1 in the sqlite3 compiler, limit 18446744073709551615 in the mysql one, nothing in the base compiler PostgreSQL uses). unknown is everything the hook could not name, SQLite included, so its cell is the largest LIMIT every LIMIT dialect parses. That follows the residue-arm reasoning text-match-sql.ts already records for the same hook. The door does not refuse the offset-only window.

The echoed sql and POST /analytics/sql on the native face are the statement that ran, byte for byte (pinned).

Measured before and after, at the route

Real POST /api/v1/analytics/query and /analytics/sql (createDispatcherPlugin), with AnalyticsServicePlugin over a real ObjectQL engine and SqlDriver. Two compositions: the default one (native face) and one narrowed to the engine aggregate (ObjectQL face). Four groups by note (w, x, y, z), order { note: 'asc' }, SQLite (better-sqlite3) and a private PostgreSQL 16.14 server.

window main ee75aae1a: native SQLite native PostgreSQL ObjectQL face, both drivers this branch, every cell
limit: -1 200, every row 500 200, w x y 400 VALIDATION_FAILED (limit)
limit: 1.5 500 200, w x 200, w 400 VALIDATION_FAILED (limit)
offset: -1 500 500 200, every row 400 VALIDATION_FAILED (offset)
offset: 1, no limit 500 (near "OFFSET": syntax error) 200, x y z 200, x y z 200, x y z
CONTROL limit: 2, offset: 1 x y x y x y x y

On main, /analytics/sql answered 200 for every row, rendering the statement. On this branch it answers the same 400 for rows 1 to 3. The dataset door's selection check (datasetSelectionRefusal) answers 400 VALIDATION_FAILED naming selection.limit / selection.offset for the same four refused values, and passes { offset: 1 } and { limit: 2, offset: 1 }. That was measured at the helper, not over HTTP.

Pins

  • packages/spec/src/data/analytics-query-window-integer.test.ts: on each of the three schemas, a negative limit, a fractional limit, a negative offset and a fractional offset are refused at the member's own path, with zod's issue code (never its message). Integer windows, limit: 0, offset: 0, an offset with no limit and no window are each a whole-safeParse success. Plus the identity pin on the dataset selection's two members.
  • packages/services/service-analytics/src/__tests__/native-sql-offset-only-window.test.ts (SQLite always, PostgreSQL where OS_TEST_POSTGRES_URL is set):
    • the card's row 4 answers the same rows on both faces;
    • the native statement ends with the dialect's spelling, and its echo and generateSql equal the statement that ran;
    • offset 0 and an offset past every row;
    • the dataset door's pushed-down offset-only page on both faces;
    • the unknown arm (a NativeSQLStrategy whose context wires no sqlDialect) runs and answers the same rows;
    • CONTROL: an integer window keeps its bytes;
    • windowClauseSql per dialect, with the MySQL cell as text only.
  • packages/runtime/src/analytics-query-window-validity.test.ts (route level, SQLite always, PostgreSQL where OS_TEST_POSTGRES_URL is set): rows 1 to 3 plus offset: 1.5 answer 400 VALIDATION_FAILED naming the member at both routes, on both faces, and the engine sees no raw statement and no aggregate. Row 4 answers x y z on both faces, and the native echo and /analytics/sql equal the statement that ran. CONTROLs: limit 2, offset 1 and limit 0.

No CI step provisions OS_TEST_POSTGRES_URL for these two packages, so the PostgreSQL cells are red-capable and un-run in CI. The numbers below include them, run against a private local PostgreSQL 16.14.

Ablations

Each leg ran on the committed tree at b1a8ea073 through scripts/ablation-replace.mjs. The anchor was proven to land (count and blob hash), and the restore was proven by blob hash equal to HEAD and an empty git diff HEAD. The spec and service-analytics pins import from src/, and the runtime suite aliases @objectstack/spec to src/, so none of these legs goes through a dist/.

leg spec pin (28) route pin (16, both drivers) service pin (14, both drivers)
.int() dropped (both members) 6 red: the fractional rows on all three schemas 4 red: limit: 1.5 and offset: 1.5, both cells. Native PostgreSQL answered 200 with LIMIT 1.5 and OFFSET 1.5 not run
.nonnegative() dropped 6 red: the negative rows 4 red: limit: -1 and offset: -1, both cells not run
no-limit spelling removed (windowClauseSql writes nothing before OFFSET) not run not run 7 red: the per-dialect text pin; on SQLite row 4, the echo, offset 0 and past, the dataset page and the unknown arm; on PostgreSQL only the unknown arm. 7 green: both CONTROLs and the PostgreSQL cells that run a bare OFFSET

Each leg turned red in the direction expected beforehand. The route pin's row 4 reads service-analytics from dist/ (KNOWN_UNALIASED_TEST_IMPORTS), so the no-limit leg was run on the service pin, which imports from src/.

ADR-0087 disposition

The changeset is declared breaking and carries the disposition registered analytics-query-window-non-negative-integer. That is a new D3 semantic entry, packages/spec/src/migrations/entries/semantic/18.analytics-query-window-non-negative-integer.ts, and registry.ts was regenerated by gen:migration-registry. The gate's other categories do not apply: the package is published, nothing was registered before, the changeset carries a FROM → TO prescription (so no-migration-prescription is refused), and this is a metadata parse, not a runtime interface or a type surface. No D2 conversion: the window is a query-time request field, not a stored sys_metadata shape, and the refused values meant different windows per backend. spec-changes.json and the upgrade guide fold majors up to 17, so a major-18 entry leaves both byte-identical. check:spec-changes and check:upgrade-guide pass.

Acceptance notes

  • The ObjectQL face's echo still renders an offset-only window as OFFSET n with no LIMIT on SQLite. objectql-strategy.ts generateSql, the two window lines. The face's rows are right. Only the echoed text cannot run on SQLite. Measured on this branch: a month-bucketed order + offset: 1 (which lands on that face on every composition) echoes … ORDER BY "closed_on" ASC OFFSET 1 on both drivers. That echo also renders date_trunc(…), which SQLite cannot run either. A non-bucketed query on a composition narrowed to the engine aggregate echoes … ORDER BY "note" ASC OFFSET 1. Rendering it with windowClauseSql is a two-line change, but objectql-strategy.ts is outside this claim's file surface and held by another claim (domain:engine#1's #5930 step 4 (domain:engine): the engine-fed faces delete their hand-copied filter meaning (driver-sql, turso remote, memory query, mongodb, formula, having); the memory reference matcher retires (D6) #20822 group 4, prose only), so it is not changed here. It is raised in the report.
  • MySQL. The mysql cell is text only. Separately, and NOT MEASURED: the native statement quotes identifiers with ANSI double quotes, which MySQL without ANSI_QUOTES reads as string literals (the CI workflow's own comment says this repo's MySQL runs without it). So the native face may not run on MySQL at all, whatever its window. This is an inference from reading the code, not a measurement, and is noted only.
  • In-process callers. AnalyticsService.query does not parse its input, so a host that builds a query in code is not refused by this narrowing. The changeset and the semantic entry say so. The census found no producer of a negative or fractional window: none in examples, fixtures, docs or published skills, and the one stored producer that lowers into a selection, a dashboard widget's limit, is already z.number().int().positive(). The sibling console repository was not checked out here, so its census is NOT MEASURED.
  • Integers above Number.MAX_SAFE_INTEGER are refused too (zod's .int()). That is stated in the changeset.
  • Docs. The generated references (content/docs/references/{api,data}/analytics.mdx) were regenerated by check:generated --fix, and it proved only check:docs stale. No hand-written page in content/docs/** (outside releases/) or skills/** says anything this change makes false.

Verification (head b1a8ea073, after merging main 6d487d209)

  • Full turbo run build (72 tasks) after the merge. Then:
    • pnpm --filter @objectstack/service-analytics test with live PostgreSQL: 168 files, 3883 tests passed;
    • pnpm --filter @objectstack/spec test: 599 files, 17557 passed, 1 todo;
    • @objectstack/runtime: the new route pin, 16 passed on both cells.
  • typecheck of @objectstack/spec, @objectstack/service-analytics and @objectstack/runtime: exit 0. Each new test file is in a compiled program (tsc --listFiles: the spec and runtime test configs, the service-analytics main config), and check:test-typecheck holds both debt ledgers unchanged.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 116 commands at b1a8ea073, and all 116 exited 0. --ran reconciles 116 derived, 116 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed to the 7 touched .ts files: eslint --no-inline-config --format json read 7 files, 0 errors, 0 warnings, none ignored. eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot change the verdict on an untouched file. The repo-wide pnpm lint is left to CI.
  • packages/cli and packages/rest are untouched. @objectstack/runtime changes by one test file only.

Generated by Claude Code

claude added 7 commits October 2, 2026 10:20
…der an offset-only window per dialect

AnalyticsQuerySchema's limit and offset become non-negative integers (the
dataset selection reads the same declarations). The native statement takes
the dialect's no-limit spelling in front of an OFFSET with no LIMIT.

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…et-only window per dialect

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
…ivers and both faces

Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-analytics, @objectstack/spec, touching 6 documentable anchor(s).

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

  • content/docs/releases/v17/17-4.mdx (via AnalyticsQuerySchema (symbol, a top-level const))
  • content/docs/releases/v17/17-6.mdx (via /api/v1/analytics/query (route, a path literal in reason; a path literal in semantic))

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
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 138 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 ecb6ca0258176466767588a6805363387c5777a6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 98ab58eb0af7648f63d169e0875e1da81c1b1b75 — the merge of head b1a8ea073b357d1b05e163e438aa30bd1977c833 into base ecb6ca0258176466767588a6805363387c5777a6, 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 98ab58eb0af7648f63d169e0875e1da81c1b1b75 && git checkout 98ab58eb0af7648f63d169e0875e1da81c1b1b75
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ecb6ca0258176466767588a6805363387c5777a6 b1a8ea073b357d1b05e163e438aa30bd1977c833 && git checkout -B drift-repro ecb6ca0258176466767588a6805363387c5777a6 && git merge --no-ff b1a8ea073b357d1b05e163e438aa30bd1977c833

node scripts/docs-audit/affected-docs.mjs --json ecb6ca0258176466767588a6805363387c5777a6

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 12:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 6d67ad5 Oct 2, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21365-analytics-window branch October 2, 2026 12:48
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ne measure with a dimension too — refuse two or more at values, and rename the check to checkDashboardWidgetChartMeasureArity (objectstack-ai#21293) (objectstack-ai#21425)

Fixes objectstack-ai#21293
Clause-②: yes (narrowing)

Extends objectstack-ai#20958's per-type measure-arity rule to the dimensioned arm, as
triage ruled on objectstack-ai/objectui#11417 (comment `5943580519`,
carried onto this card as `5944795015`): for `pie`, `donut`, `funnel`,
`treemap` and `sankey`, two or more measures are refused **whatever the
dimension**. It uses objectstack-ai#20958's mechanism and message shape: the same
check, one more arm, and one ADR-0087 semantic entry in objectstack-ai#20958's shape.
No second rule is added. `scatter` and `radar` with a dimension are
outside the ruling and parse as before.

## What changed

- **`packages/spec/src/ui/dashboard.zod.ts`**
- New module-private `SINGLE_SERIES_CHART_TYPES` (`:784`): `pie` /
`donut` / `funnel` / `treemap` / `sankey`, typed `as const satisfies
readonly ChartType[]`. It is private, like the metric family's list. The
check, its refusal text and the `values` doc string read it.
- The check gains a second arm, the **single-series arm**. When
`dimensions` is declared and non-empty, `values` has two or more
members, and `type` is one of the five, it emits ONE `custom` issue at
`values`. The issue names the widget `id`, the count and the type, and
says the type "draws ONE series whatever its `dimensions`". It steers to
`type: 'table'` (a column per measure), a bar-family type (one bar per
measure in each category), or one widget per measure.
- The dimensionless arm is unchanged, byte for byte, in both verdict and
message. The arms are exclusive on `dimensions`, so a dimensionless
`pie` still gets the dimensionless message, and only one issue.
- The metric family still returns early (its sibling check owns it), and
so do types outside `ChartTypeSchema`.
- **Renamed export:** `checkDashboardWidgetDimensionlessMeasureArity` is
now `checkDashboardWidgetChartMeasureArity` (`:878`, chained at
`:1461`). The reason is in "The rename" below.
- The `values` doc string now states the single-series rule. It reads
the private constant.
- **ADR-0087 entry**
`packages/spec/src/migrations/entries/semantic/18.dashboard-widget-single-series-multi-measure-refused.ts`,
plus the `gen:migration-registry` lap. Nothing between the markers was
hand-edited. Its acceptance criteria name the doors as measured (below),
state that the TODO cannot name per-document measures, and list what is
and is not refused. It also records the export rename (FROM → TO).
- **The objectstack-ai#20958 entry, amended in two sentences**
(`18.dashboard-widget-dimensionless-multi-measure-refused.ts`). It named
the old export, and it said "the same seven types WITH a dimension keep
accepting several measures exactly as before". This change makes both
false, and `os migrate meta` prints both entries in the same hop. They
now name the new export (noting the old name) and point the five at this
entry. This file is outside the claim's listed file surface; see
Deviations.
- **Tests**
- `dashboard.test.ts`: a new `[objectstack-ai#21293]` block, plus the fixture triage
below.
- `object-refinement-check-exports.test.ts`: the parity catalogue row is
renamed, and its fixtures are triaged.
- **Changeset**
`.changeset/21293-single-series-multi-measure-refused.md`: `minor`, a
**BREAKING** banner, the `(narrowing)` arm, and exactly one ADR-0087
marker (`registered
dashboard-widget-single-series-multi-measure-refused`). It carries a
FROM → TO table that includes the export rename.
- **Regenerated:**
- `api-surface/ui.json` and `export-origins/ui.json`: one row renamed in
each, `-1 / +1`.
  - The `values` row of `content/docs/references/ui/dashboard.mdx`.
  - `src/migrations/registry.ts`.

## The rename, and what objectui's mirror chains today

The check now judges widgets WITH a dimension, so
`...DimensionlessMeasureArity` names a boundary that no longer exists. A
mirror deciding which export to chain reasons from that name first. This
is the cheapest moment there will ever be to change it, because nothing
chains it yet:

- At the `.objectui-sha` pin `89cad75d55702cc4f267bead5bf267de575d5842`,
`git grep "DimensionlessMeasureArity\|MULTI_MEASURE_TYPES"` returns **0
hits**. The control, `checkDashboardWidgetMetricMeasureArity` in
`packages/types/src/zod/complex.zod.ts`, returns 2. objectui's
`DashboardWidgetSchema` mirror chains `checkDashboardWidgetStageOrder`
and `checkDashboardWidgetMetricMeasureArity` only
(`complex.zod.ts:1304`, and the `attached` row in
`spec-object-refinements-7715.test.ts:135`).
- On objectui `origin/main` `5988b6b53` the readings are the same: 0
hits, control 2.
- In this repository the old name had no consumer outside
`packages/spec`'s own tests (`git grep`).
- The spec object's check COUNT is unchanged at three, so objectui's
census test sees no count change at the bump.

A rename is a removal plus an addition, so the changeset carries the
import FROM → TO, and `Clause-②` is `yes`.

## Measured before building (the dispatch's four hypotheses)

The branch base is `4b20c84748`. It is newer than the dispatch's
`1d0600bf66`, and `dashboard.zod.ts` did not change between the two.

- **H1, holds.** The check returned early on any non-empty `dimensions`
(old `:834`), and `DASHBOARD_WIDGET_MULTI_MEASURE_TYPES` listed the
eight multi-measure types. At the base I built `dist` and ran
`DashboardWidgetSchema.safeParse` on `{ id, type, dataset, dimensions:
['stage'], values: ['revenue', 'cost'] }`. It returned **ACCEPT** for
`pie`, `donut`, `funnel`, `treemap`, `sankey`, `scatter`, `radar`, `bar`
and `table`. One measure returned ACCEPT on all of them. The lit
control, the same body plus `bogusProp`, returned REFUSE
`unrecognized_keys` on every type.
- **H2, holds.** objectui's renderer at the pin
(`packages/plugin-charts/src/AdvancedChartImpl.tsx`) binds the first
series on exactly these arms:
- `pie` / `donut` at `:2095` (`const pieDataKey = series[0]?.dataKey ||
'value'`);
- `funnel` at `:2161`, `treemap` at `:2243` and `sankey` at `:2278`
(`const dataKey = series[0]?.dataKey || 'value'`).
  - `radar` maps every series (`:2406` onward, `series.map(...)`).
- objectui's `DatasetWidget.tsx` at the pin already names the same five
as `SINGLE_SERIES_CHART_FAMILIES` (`:387`–`:413`), which is the objectstack-ai#11417
diagnostic. It says `scatter` "refuses a second series out loud".
- **H3, partly falsified.** There is no declared "order":
`build-migration-registry.ts` derives the order by sorting entry ids
("Order is DERIVED, never declared"). The serial constraint did hold. PR
objectstack-ai#21399 (`analytics-query-window-non-negative-integer`) landed on `main`
first, so this branch merged `origin/main` `db3fee3dc9` through
`scripts/pm/os-regen-merge.sh` (merge commit `b3b3c516f9`).
  - The registry text-merged clean.
- `gen:migration-registry` on the merged tree rewrote it byte-identical:
346 semantic entries, `git status` empty.
- All three sibling entries are present at HEAD. Quoted-exact-name `git
grep`, HEAD vs `origin/main`:
`analytics-query-window-non-negative-integer` 1/1,
`dashboard-widget-dimensionless-multi-measure-refused` 1/1, and
`dashboard-widget-single-series-multi-measure-refused` 1 at HEAD, 0 on
main.
- **H4, census: no authored hit.** I scanned brace-local literals
carrying `values: [...]` over every tracked
`.ts/.tsx/.js/.mjs/.cjs/.json/.md/.mdx/.yml` file.
- objectstack at `4b20c84748`: 496 literals, 33 on the seven types, 1
dimensioned multi-measure on the five. That one is
`packages/spec/src/ui/object-refinement-check-exports.test.ts:365`, the
fixture that pinned the old acceptance, triaged below.
- Lit control: 9 dimensioned multi-measure literals on other types
(`table` / `combo` widgets in `examples/app-showcase`, and others).
- objectui at the pin: 480 literals, 13 on the seven, and 1 hit on the
five. That hit is the prose example in
`.changeset/11417-dashboard-single-series-dropped-measure.md:10`, not
metadata.
- `examples/**`, the showcase and CRM apps, and
`packages/platform-objects` dashboards carry zero instances.
- Dynamically built fixtures do not show up in a static scan. I
inspected each downstream test that loops over chart types or builds
widgets (`lint` `validate-widget-bindings`, `runtime-gate`,
`validate-dashboard-widget-options`, and `metadata-protocol`'s two donut
fixtures). All carry one measure.

## Doors after the change (built `dist`, one-shot probe, deleted
afterwards, `git status` clean)

| body (`dimensions: ['stage']`) | `DashboardSchema` |
`getMetadataTypeSchema('dashboard')` | `ObjectStackDefinitionSchema` |
`defineStack` |
|---|---|---|---|---|
| `pie`, 2 measures | REFUSE `widgets.0.values:custom` | REFUSE
`widgets.0.values:custom` | REFUSE
`dashboards.0.widgets.0.values:custom` | throws `defineStack validation
failed (1 issue)`, naming the widget |
| `sankey`, 2 measures | REFUSE | REFUSE | REFUSE | throws |
| `pie`, 1 measure (control) | ACCEPT | ACCEPT | ACCEPT | returns |
| `radar`, 2 measures (control) | ACCEPT | ACCEPT | ACCEPT | returns |
| `bar`, 2 measures (control) | ACCEPT | ACCEPT | ACCEPT | returns |

I did not re-measure `os validate` or the metadata save path. The entry
does not claim them. They parse through the same schema at the same
attachment point as objectstack-ai#20958, which measured both.

## Fixture triage (pins whose premise this change makes false)

- **`object-refinement-check-exports.test.ts`.** The row "two measures
on a `pie` WITH a dimension — out of this rule" (`refusesAt: []`) is
this arm's subject now. It becomes five refusing rows, one per
single-series type. Accepting rows are added for `scatter` / `radar`
with a dimension and for a one-measure dimensioned `pie`.
- **`dashboard.test.ts`, the objectstack-ai#20958 block.** "accepts `%s` with ONE
dimension and two measures" iterated all seven types. It keeps `scatter`
and `radar`, and the five move to the new block as refusals.
- **`dashboard.test.ts`, the objectstack-ai#17779 block.** "leaves `%s` — every
NON-metric type — accepting three measures" ran on `WIDGET_BASE`, which
carries a dimension. Its own claim is that the METRIC check leaves
non-metric types alone, so that claim is now read off the metric export
directly for all 15 types. The parse leg keeps the 10 types no check
refuses.
- **objectstack-ai#20958's dimensionless pins are unchanged.** That covers the
refusals, the explicit `dimensions: []` refusals, the message pin and
the metric-family one-issue pin.

## New pins (`[objectstack-ai#21293]` block in `dashboard.test.ts`)

- Each of the five, with a dimension and two measures: REFUSE, `custom`
at `values`, message naming the type.
- The control: one measure on each of the five with a dimension: ACCEPT.
- Each of the five with TWO dimensions: REFUSE.
- `scatter` / `radar` with a dimension and two measures: ACCEPT.
- Every multi-measure type with a dimension and three measures: ACCEPT.
- **Taxonomy read off the door:** with a dimension, the set of
`ChartTypeSchema` types refused at two measures is exactly the metric
family plus the five.
- A dimensionless widget of each of the five keeps the dimensionless
message, word for word and as ONE issue. The parse leg equals the direct
call.
- The metric family with a dimension keeps its own refusal, and the
chart export stays silent on it.
- Direct-call legs: a dimensioned `pie` is refused; a wider-enum type
(`custom`) and a dimensioned `scatter` are not.
- `DashboardSchema.widgets[]` travel, and the `values` doc string.
- The old export name is absent from `@objectstack/spec/ui`. The new
export is the lit control.

## Verification

Every reading below is at HEAD `b3b3c516f9` unless it names another
commit.

**Reverse verification**, at committed `221df98d72`, through
`scripts/ablation-replace.mjs`:

- The mutation turned the single-series arm's type guard into an
unconditional `return`. The subject is imported from `src` by relative
path, so no `dist` is involved.
- The prediction was "turns red", and that is the direction observed.

```
anchor x1 -> x0 · marker x0 -> x1 · blob 9a8c228 -> cfdc26ae3cbf (mutation landed)
RED   vitest exit=1   19 failed | 342 passed (361)   (dashboard.test.ts + object-refinement-check-exports.test.ts)
restored blob 9a8c228 == HEAD blob, git diff HEAD empty; marker count after restore 0
```

The 19 red tests:

- 14 in the new block: 5 refusals, 5 two-dimension refusals, the
taxonomy pin, the message pin, the direct-call pin and the travel pin;
- 5 parity rows in the exports catalogue, one per dimensioned
single-series fixture.

**Tests.** Each is the package's own vitest run. Downstream packages
were chosen by direction: the dependents of `@objectstack/spec` that
import or parse the dashboard schemas (`git grep`). This is not the full
`...@objectstack/spec` sweep.

| package | result |
|---|---|
| `@objectstack/spec` (`--project local`) | 600 files, 17666 passed, 1
todo, exit 0 |
| `@objectstack/spec` typecheck (tsc + scripts + test layer) | exit 0
(and at `221df98d72` before the merge). The test-layer ledger held at 52
files / 246 errors / 135 signatures; `tsc -p tsconfig.test.json
--listFiles` lists both touched test files and the new entry. |
| `@objectstack/lint` | 119 files, 5575 tests, exit 0 |
| `@objectstack/sdui-parser` | 13 files, 218 tests, exit 0 |
| `@objectstack/platform-objects` | 59 files, 949 tests, exit 0 |
| `@objectstack/metadata-protocol` | 201 passed, 3 skipped (204 files);
2983 passed, 19 skipped; exit 0 |
| `@objectstack/metadata` | 56 files, 836 tests, exit 0 |
| `@objectstack/service-analytics` | 168 files; 3794 passed, 89 skipped;
exit 0 |
| `@objectstack/cli` (`--project unit`) | 247 files, 3538 tests, exit 0.
The integration tier is declared to CI: no spawn entry or integration
file is touched. |
| `@objectstack/objectql` (`--project local`) | 364 files, 7360 tests,
exit 0 |

**Generated artefacts.** `pnpm --filter @objectstack/spec
check:generated` reports "All 15 generated artifacts are up to date" on
the merged tree, after a rebuild. `check:migration-registry` reports
`registry.ts is current (346 semantic, 243 retired-key, 212
retired-def)`.

**Gates.**

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` at `b3b3c516f9` derives 115 commands. The 10 changed paths
are measured against merge base `db3fee3dc`; 671 changed lines.
- All 115 ran. `--ran` reconciliation: "115 derived, 115 run, 0
NOT-MEASURED, 0 UNRUN (a DERIVED zero)".
- Six first answered `PREREQUISITE NOT MET` (exit 3) and were re-run to
a real exit 0 after a workspace build:
- `check:doc-formula-expressions` and `check:doc-security-posture`
(`lint` / `formula` not built);
  - `check:skill-examples` (`client-react` not built);
  - `check:docs-transcript-drift` (`lint` not built);
  - `check:dual-build-cjs-loads` (86 packages had no `dist`);
  - `check:lean-entry-closure` (`objectql` not built).
- Compared with the seat's dispatch-time list (109), the re-derivation
adds six: the changeset-driven `check-empty-changeset` pair,
`release-rehearsal-clone --self-test`, `release-pending-publish
--self-test`, `check:objectui-changeset` and
`check:pm-changeset-deadline-census`. All six ran.
- Verdict lines:
- `check-adr-0087-registration --base origin/main`:
`[BREAKING+bang+clause-②-narrowing] registered
dashboard-widget-single-series-multi-measure-refused (new here:
dashboard-widget-single-series-multi-measure-refused)`.
- `check:api-surface`: "public API surface + factory signatures
unchanged ✓", against the regenerated snapshot.
- `check:doc-authoring`: no internal issue-id in the 17250
customer-facing strings. The refusal text carries none.
  - `check:nul-bytes`: OK.

**`Clause-②`, measured.** `node scripts/pm/check-widening-tells.mjs
--declaration no --diff` on `git diff origin/main...HEAD` exits 4:

- It reports five T2 tells, `dashboard.zod.ts:785`–`:789`. These are the
members of the module-private `SINGLE_SERIES_CHART_TYPES` `as const`
array. The array narrows the accept set, so these tells are false as
widenings.
- T3 does not fire, because the `api-surface/ui.json` hunk removes the
old row beside the new one.
- The export listing does gain a row:
`checkDashboardWidgetChartMeasureArity (function)`, the renamed export.
- `--declaration yes` exits 0.

So the measured arm is `Clause-②: yes (narrowing)`. The `yes` holds
because a renamed check function is exported for objectui's `.shape`
mirror to chain, which is the dispatch's own example. The `(narrowing)`
holds because the accept set shrinks.

**Lint**, a measured narrowing rather than the repo-wide run (that run
belongs to CI):

- I ran `eslint --no-inline-config --format json` over the 6 touched
`.ts` files. The JSON reports 6 files, 0 errors and 0 warnings.
- Population: `eslint --print-config` returns a rule set for each file,
so none is ignored.
- Invariance: `parserOptions.project` and `projectService` are null for
every file, and `eslint.config.mjs:327` states that it never enables
type-aware linting. This diff therefore cannot move a verdict on any
untouched file.

## Deviations

- **One file outside the claim's listed surface:** the objectstack-ai#20958 entry. Two
of its sentences, one naming the export and one saying the dimensioned
arm keeps accepting, became false in this change. `os migrate meta`
prints both entries in one hop, so leaving them would print a
contradiction. The standing dev definition requires fixing released text
that a change makes false. The claim's "stop on breach" reads the other
way, so the conflict is named here rather than settled silently. The
edit is two sentences, and the registry region is regenerated, not
hand-edited.

## Acceptance notes

None of these is filed. None is a reproducible defect, a contract
violation or an authoring trap.

- **`check-widening-tells` T2 fires on a module-private `as const` set
whose members narrow.** The declaration is `yes` for the export row
anyway, so the tells decide nothing here. The matcher was not touched.
The precedent (`SINGLE_MEASURE_WIDGET_TYPES`) predates the gate.
Carrier: none.
- **The single-series set is not exported.** objectui's dataset widget
keeps its own renderer-derived copy (`SINGLE_SERIES_CHART_FAMILIES`,
"Kept here, once, because the renderer has no declaration of it to
read"). No import of a spec list is measured anywhere. If objectui's
mirror or diagnostic later wants it, exporting it is a free widening.
Carrier: none.
- **No `STEP18_RATIONALE` fragment**, following objectstack-ai#20958's precedent.

## Downstream: objectui's mirror (not in this PR;
objectstack-ai/objectui#11417 carries it)

`@object-ui/types` builds `DashboardWidgetSchema` from a `.shape`
spread, so it runs only the checks it chains, and today it chains
neither arm. At the pin bump that carries this change:

- Import `checkDashboardWidgetChartMeasureArity` from
`@objectstack/spec/ui`.
- Chain it in `packages/types/src/zod/complex.zod.ts` after
`.superRefine(checkDashboardWidgetMetricMeasureArity)`.
- Add it to the `attached` list of the `DashboardWidgetSchema
(complex.zod.ts)` row in `spec-object-refinements-7715.test.ts`.
- Refusal shape: ONE `custom` issue at `path: ['values']`. With a
dimension, the message starts "Widget `ID` declares N measures on `type:
'T'`, a single-series type (...) that draws ONE series whatever its
`dimensions`". Without one, it keeps objectstack-ai#20958's "declares N measures with
no `dimensions`" text.

objectstack-ai/objectui#11417 remains open: this PR carries only the
spec half.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…mit as a statement the dialect runs (objectstack-ai#21440)

Fixes objectstack-ai#21365
Clause-②: no

## What this changes

This is the remainder of objectstack-ai#21365 after PR objectstack-ai#21399 (`6d67ad5ec`, which read
`Part of`). That PR narrowed the window contract and made the native
face render an offset-only window with the dialect's no-limit spelling.
The ObjectQL face's echo still wrote its own window.

`ObjectQLStrategy.generateSql`
(`packages/services/service-analytics/src/strategies/objectql-strategy.ts`)
wrote `LIMIT n` when a limit was set, then `OFFSET n` when an offset
was. An `offset` with no `limit` therefore echoed a bare `OFFSET`, and
SQLite's grammar has no bare `OFFSET`. Those two lines are now one call:

- `windowClauseSql(query.limit, query.offset, sqlDialectFor(ctx,
tableName))`
- `windowClauseSql` is the function the native face runs, already
exported from `native-sql-strategy.ts`. That file is not edited, and
there is no second spelling table.

The ObjectQL face's echoed `sql` and the `POST /api/v1/analytics/sql`
body (both are `generateSql`) now carry the same window clause the
native face executes on the same driver.

## Measured at the real route

The harness is a scratch copy of
`packages/runtime/src/analytics-query-window-validity.test.ts`. It uses
the real `dispatcher-plugin` mount over `AnalyticsServicePlugin` and a
real `ObjectQL` engine with `SqlDriver` (better-sqlite3). It boots the
default composition and one narrowed to the engine aggregate (the
ObjectQL face). The query is `order: { note: 'asc' }, offset: 1`, with
no limit. Each echoed statement was then run on the same SQLite database
through knex, to read SQLite's own diagnostic. The harness was deleted
and never committed.

| | `main` `bdd3654f2` | this branch |
|:--|:--|:--|
| ObjectQL face, `/query` rows | x y z | x y z |
| ObjectQL face, echoed `sql` = `/sql` body | `… ORDER BY "note" ASC
OFFSET 1` | `… ORDER BY "note" ASC LIMIT -1 OFFSET 1` |
| that statement on SQLite | `near "OFFSET": syntax error` | x y z |
| native face, executed = echoed = `/sql` | `… ASC LIMIT -1 OFFSET 1`, x
y z | unchanged |
| CONTROL `limit: 2, offset: 1`, ObjectQL echo | `… LIMIT 2 OFFSET 1`,
runs | unchanged |

On this query the ObjectQL echo now equals, byte for byte, the statement
the native face runs on SQLite.

## The dialect source (dispatch, Zone 2 item 2)

`generateSql` already has the strategy context in scope, and it already
reads `sqlDialectFor(ctx, tableName)` for its read-scope compile. That
is the same `sqlDialect` hook, read the same way, that
`NativeSQLStrategy.generateSql` uses with `sqlDialectFor(ctx,
this.extractObjectName(cube))`. `tableName` here is
`this.extractObjectName(cube)` too. `AnalyticsServicePlugin` wires that
hook from the data engine's driver whatever the query capabilities are,
so the narrowed composition above reads `sqlite`. No new hook.

## Pin


`packages/services/service-analytics/src/__tests__/objectql-echo-offset-only-window.test.ts`
has three tests per cell:

- **The card's row.** The ObjectQL face runs no raw statement and does
run the engine aggregate. It answers x y z. Its echo ends with the
dialect's window. `generateSql` (the `/analytics/sql` body) equals the
echo, with no params. From `ORDER BY` on, the echo equals the statement
the native face executes on the same driver. Run through the engine's
raw-SQL bridge, the echo answers the face's rows.
- **The `unknown` arm.** An `ObjectQLStrategy` whose context wires no
`sqlDialect` hook echoes `LIMIT 9223372036854775807 OFFSET 1`, and that
statement runs.
- **CONTROL.** `limit: 2, offset: 1` keeps its bytes, and the echo runs.

The cells:

- **sqlite**: executed on every run.
- **live postgres**: executed where `OS_TEST_POSTGRES_URL` is set, and a
named skip otherwise. No CI step provisions that variable for this
package, so CI runs only the SQLite cell. I ran it locally against
PostgreSQL 16.14. There the echo keeps `OFFSET 1` alone, byte-identical
to before. The comparison with the native statement starts at `ORDER BY`
because on PostgreSQL the native face also casts the summed column.

**Ablation** at committed `2cfcc44ee`, through `node
scripts/ablation-replace.mjs`. The anchor hit once, and the blob changed
`9ed31b50c50b` to `e8b910c3c13f`. The mutation restored the two old
window lines. The pin reads `src/` with no `dist/` leg (objectql is
aliased to source, and the strategy is imported from `src`). Result,
with live PostgreSQL: **3 failed, 3 passed**.

- Red: the SQLite card row (the echo ends with a bare `OFFSET 1`), and
the `unknown` arm on both engines.
- Green: both controls, and the PostgreSQL card row, whose bytes this
change does not move.

I predicted that direction before the run. Restore was proven: the blob
after restore equals HEAD `9ed31b50c50b`, and `git diff HEAD` is empty.

## Tests and gates

All of these ran at head `b9456bff3`, which is this branch with
`origin/main` `8b123c0ae` merged in. That merge brought PR objectstack-ai#21424's
`native-sql-strategy.ts` change. `windowClauseSql` is unchanged by it.

- `pnpm --filter @objectstack/service-analytics test`, with
`OS_TEST_POSTGRES_URL` set to a local PostgreSQL 16.14: **Test Files 170
passed (170), Tests 3957 passed (3957)**. `os-verify-lock` printed
`VERDICT command-exit 0`.
- `pnpm --filter @objectstack/service-analytics typecheck`: `VERDICT
command-exit 0`. `tsc --noEmit --listFiles` lists the new pin once. The
package `tsconfig` includes `src` and does not exclude tests.
- Gates: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths) derived 63 commands.
All 63 ran at `b9456bff3`. 62 exited 0 on the first run. `pnpm
check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit
3), because the workspace was not fully built. After a full `turbo run
build` (72 tasks, exit 0), it exited 0. `--ran` reconciles **63 derived,
63 run, 0 NOT-MEASURED, 0 UNRUN**. The list includes `check:nul-bytes`,
`check:changeset-gate-self-tests`, `check:adr-0087-registration`,
`check:changeset-no-major` and `check:test-source-alias`. No gate
touched `AGENTS.md`, and the tree stayed clean.
- eslint: `eslint --no-inline-config --format json` on the 2 touched
`.ts` files read 2 files, 0 errors, 0 warnings, none ignored.
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so an untouched file's verdict cannot move.
The repo-wide `pnpm lint` is left to CI.

## Docs

I grepped `content/docs/**` outside `releases/` for the analytics echo,
`/analytics/sql` and window rendering. `api/data-api.mdx` describes
`/analytics/sql` as a dry run returning `{ sql, params }`, and
`api/client-sdk.mdx` shows `client.analytics.explain`. Neither says how
a window renders. No sentence is made false, so there is no docs edit.

## Acceptance notes

- **The bucketed echo is a separate truth (dispatch, Zone 2 item 3), not
fixed here.** A month-bucketed dimension echoes `date_trunc('month',
closed_on)`. That holds on both faces, and in the default composition
too, because the native face declines granularity. SQLite refuses it
with `no such function: date_trunc`, measured on this branch after the
window fix. On `main` the same statement failed earlier, at the bare
`OFFSET`. The driver runs `strftime('%Y-%m', …)` there (`driver-sql`
`sql-driver.ts`). The comment over `dimExpr` says the echo renders "the
SQL shape the driver's own bucketing implements", and on SQLite that is
not so. This is `dimExpr`, not the two window lines, so it is reported
to the seat rather than fixed here.
- **`mysql`.** The window cell is `windowClauseSql`'s own (`LIMIT
18446744073709551615`). It is NOT MEASURED on either face here, because
no MySQL server is available. That is the same declared skip PR objectstack-ai#21399
carries.
- **Unknown dialect on PostgreSQL.** A host whose `sqlDialect` hook
names no dialect now echoes `LIMIT 9223372036854775807 OFFSET n` where
it echoed `OFFSET n`. The native face already runs that statement for
such a host. It answers the same rows on PostgreSQL and SQLite, as the
pin's `unknown` arm measures.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_

---------

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 protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants