Skip to content

fix(service-analytics)!: the cube door asks the aggregate × field-type table for every measure (#21044) - #21128

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21044-cube-measure-type-table
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21044-cube-measure-type-table

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21044
Clause-②: no (narrowing)

What changed

A configured cube's max over a text column was served at the cube door (POST /api/v1/analytics/query) by the native-SQL strategy, with the column's text, while the same response's fields[] declared the measure number. The dataset door refuses that pair at compile by the spec table AGGREGATE_FIELD_TYPE_COMPATIBILITY, and the cube door consulted nothing. Triage's direction (5924500811) is carried out as ruled: the cube door asks the one table, and no second table exists.

The four measured questions of the dispatch

Triage's second sentence, "buildFieldMeta stops minting number for a non-numeric min / max", is delivered at the seam both strategies' results leave through rather than inside buildFieldMeta, which has no field types to read: a refused pair never reaches a descriptor, a temporal one is re-described time by the one rule, and a boolean one keeps number by that rule's own verdict.

Per-row readings, before and after

driver strategy measure pair before (2821e9f15b): status, value, fields[] type after (d85b700030)
SQLite native SQL config max_note max over note (text) 200, "y", number 400 INVALID_FIELD
SQLite native SQL config min_note min over note (text) 200, "x", number 400 INVALID_FIELD
SQLite native SQL config max_status max over status (select) 200, "won", number 400 INVALID_FIELD
SQLite native SQL config max_opened max over opened_at (datetime) 200, "2026-03-04T05:06:07.000Z", number 200, "2026-03-04T05:06:07.000Z", time
SQLite native SQL config min_due min over due_on (date) 200, "2026-01-15", number 200, "2026-01-15", time
SQLite native SQL config max_flag max over flag (boolean) 200, 1, number 200, 1, number
SQLite native SQL config max_amount max over amount (number) control 200, 32, number 200, 32, number
SQLite native SQL config sum_note sum over note (text) 200, 0, number 400 INVALID_FIELD
SQLite native SQL config avg_note avg over note (text) 200, 0, number 400 INVALID_FIELD
SQLite native SQL config cd_note count_distinct over note (text) control 200, 2, number 200, 2, number
SQLite native SQL inferred note_max max over note (text) 200, "y", number 400 INVALID_FIELD
SQLite native SQL inferred amount_max max over amount (number) control 200, 32, number 200, 32, number
SQLite native SQL inferred opened_at_max max over opened_at (datetime) 200, "2026-03-04T05:06:07.000Z", number 200, "2026-03-04T05:06:07.000Z", time
SQLite native SQL augmented note_max max over note (text) 200, "y", number 400 INVALID_FIELD
SQLite ObjectQL config max_note max over note (text) 400 INVALID_FIELD 400 INVALID_FIELD
SQLite ObjectQL config min_note min over note (text) 400 INVALID_FIELD 400 INVALID_FIELD
SQLite ObjectQL config max_status max over status (select) 400 INVALID_FIELD 400 INVALID_FIELD
SQLite ObjectQL config max_opened max over opened_at (datetime) 200, "2026-03-04T05:06:07.000Z", number 200, "2026-03-04T05:06:07.000Z", time
SQLite ObjectQL config min_due min over due_on (date) 200, "2026-01-15", number 200, "2026-01-15", time
SQLite ObjectQL config max_flag max over flag (boolean) 200, 1, number 200, 1, number
SQLite ObjectQL config max_amount max over amount (number) control 200, 32, number 200, 32, number
SQLite ObjectQL config sum_note sum over note (text) 200, 0, number 400 INVALID_FIELD
SQLite ObjectQL config avg_note avg over note (text) 400 INVALID_FIELD 400 INVALID_FIELD
SQLite ObjectQL config cd_note count_distinct over note (text) control 200, 2, number 200, 2, number
SQLite ObjectQL inferred note_max max over note (text) 400 INVALID_FIELD 400 INVALID_FIELD
SQLite ObjectQL inferred amount_max max over amount (number) control 200, 32, number 200, 32, number
SQLite ObjectQL inferred opened_at_max max over opened_at (datetime) 200, "2026-03-04T05:06:07.000Z", number 200, "2026-03-04T05:06:07.000Z", time
SQLite ObjectQL augmented note_max max over note (text) 400 INVALID_FIELD 400 INVALID_FIELD
PostgreSQL 16.13 native SQL config max_note max over note (text) 200, "y", number 400 INVALID_FIELD
PostgreSQL 16.13 native SQL config min_note min over note (text) 200, "x", number 400 INVALID_FIELD
PostgreSQL 16.13 native SQL config max_status max over status (select) 200, "won", number 400 INVALID_FIELD
PostgreSQL 16.13 native SQL config max_opened max over opened_at (datetime) 200, "2026-03-04T05:06:07.000Z", number 200, "2026-03-04T05:06:07.000Z", time
PostgreSQL 16.13 native SQL config min_due min over due_on (date) 200, "2026-01-15", number 200, "2026-01-15", time
PostgreSQL 16.13 native SQL config max_flag max over flag (boolean) 500 DATABASE_ERROR 500 DATABASE_ERROR
PostgreSQL 16.13 native SQL config max_amount max over amount (number) control 200, 32, number 200, 32, number
PostgreSQL 16.13 native SQL config sum_note sum over note (text) 500 DATABASE_ERROR 400 INVALID_FIELD
PostgreSQL 16.13 native SQL config avg_note avg over note (text) 500 DATABASE_ERROR 400 INVALID_FIELD
PostgreSQL 16.13 native SQL config cd_note count_distinct over note (text) control 200, 2, number 200, 2, number
PostgreSQL 16.13 native SQL inferred note_max max over note (text) 200, "y", number 400 INVALID_FIELD
PostgreSQL 16.13 native SQL inferred amount_max max over amount (number) control 200, 32, number 200, 32, number
PostgreSQL 16.13 native SQL inferred opened_at_max max over opened_at (datetime) 200, "2026-03-04T05:06:07.000Z", number 200, "2026-03-04T05:06:07.000Z", time
PostgreSQL 16.13 native SQL augmented note_max max over note (text) 200, "y", number 400 INVALID_FIELD
PostgreSQL 16.13 ObjectQL config max_note max over note (text) 400 INVALID_FIELD 400 INVALID_FIELD
PostgreSQL 16.13 ObjectQL config min_note min over note (text) 400 INVALID_FIELD 400 INVALID_FIELD
PostgreSQL 16.13 ObjectQL config max_status max over status (select) 400 INVALID_FIELD 400 INVALID_FIELD
PostgreSQL 16.13 ObjectQL config max_opened max over opened_at (datetime) 200, "2026-03-04T05:06:07.000Z", number 200, "2026-03-04T05:06:07.000Z", time
PostgreSQL 16.13 ObjectQL config min_due min over due_on (date) 200, "2026-01-15", number 200, "2026-01-15", time
PostgreSQL 16.13 ObjectQL config max_flag max over flag (boolean) 200, 1, number 200, 1, number
PostgreSQL 16.13 ObjectQL config max_amount max over amount (number) control 200, 32, number 200, 32, number
PostgreSQL 16.13 ObjectQL config sum_note sum over note (text) 500 DATABASE_ERROR 400 INVALID_FIELD
PostgreSQL 16.13 ObjectQL config avg_note avg over note (text) 400 INVALID_FIELD 400 INVALID_FIELD
PostgreSQL 16.13 ObjectQL config cd_note count_distinct over note (text) control 200, 2, number 200, 2, number
PostgreSQL 16.13 ObjectQL inferred note_max max over note (text) 400 INVALID_FIELD 400 INVALID_FIELD
PostgreSQL 16.13 ObjectQL inferred amount_max max over amount (number) control 200, 32, number 200, 32, number
PostgreSQL 16.13 ObjectQL inferred opened_at_max max over opened_at (datetime) 200, "2026-03-04T05:06:07.000Z", number 200, "2026-03-04T05:06:07.000Z", time
PostgreSQL 16.13 ObjectQL augmented note_max max over note (text) 400 INVALID_FIELD 400 INVALID_FIELD

"Before" is base 2821e9f15b; "after" is this branch's head d85b700030 (two merges of main in, the second carrying #21098's 401 for an anonymous analytics caller, so the probe signs its caller in). Both were read through the real dispatcher-plugin mount of POST /api/v1/analytics/query, over AnalyticsServicePlugin composed on a real ObjectQL engine and SqlDriver, by a scratch probe that was deleted after each run. Two rows of a ledger: note x / y (text), status open / won (select), two instants and two days, flag true / false, amount 10 / 32. "Inferred" is an unregistered cube name (the object's), "augmented" a suffix-inferred measure on the configured cube. The same 56 readings were taken again after the first merge of main (0abe2120fb): no row differs from the head's. Since #21103 landed (in the second merge) the engine's door also refuses sum over a refused type, so on the ObjectQL face the sum rows would answer 400 without this change too; this door answers first.

Pins (red first), the ablation

  • Red, on the tree committed as be5c1a69b2 (pins only, no fix; base 2821e9f15b), SQLite and a private PostgreSQL 16.13:
    • service-analytics src/__tests__/cube-measure-field-type-door.test.ts (new) and the flipped native-sql-measure-number-presentation.test.ts: 16 failed, 16 passed, 1 skipped. Failures: max_note must not be served: expected { rows: [ { max_note: 'y' } ], …(1) } to be undefined (native), max_note: expected undefined to be 'max_note' (ObjectQL: the engine's refusal carries no member), max_opened is described time: expected 'number' to be 'time', expected the query to be refused, but it resolved (dry run).
    • runtime src/analytics-cube-measure-field-type-door.test.ts (new): 6 failed, 6 passed. Native max_note answered 200; both faces described max_opened number. The ObjectQL face's 400 INVALID_FIELD and both faces' number controls were green already.
  • Fixture triage. native-sql-measure-number-presentation.test.ts ([finding] analytics: on PostgreSQL the native-SQL path answers a measure the response declares number as a string (count: "2"), where SQLite answers 2 — the class #20335 closed at the engine door #20889) read a configured cube's max over its code text column back as text, as the second half of its "keyed on the declared function, never on the value" control. That pin held exactly the served pair this card refuses, so it is flipped, not deleted: the case now asserts INVALID_FIELD / 400 with no statement run, keeps its text-dimension half, and the cube read above it no longer asks for max_code.
  • Green, at d85b700030: the same files 32 passed, 1 skipped (the PostgreSQL native max over boolean cell, a named skip: an accepted pair that is a 500 there, see Acceptance notes) and 12 passed.
  • Ablation (the cube door's table check removed), from committed code at 5d69394a60. Predicted before running, in progress.log: service-analytics 12 red (per dialect: the native refusal, the ObjectQL refusal, both inferred-measure cases, the dry run and the flipped [finding] analytics: on PostgreSQL the native-SQL path answers a measure the response declares number as a string (count: "2"), where SQLite answers 2 — the class #20335 closed at the engine door #20889 case), 20 green, 1 skipped; runtime 2 red (the native refusal on both dialects), 10 green, because the engine's door still answers the ObjectQL face's 400 INVALID_FIELD on the wire.
    • The mutation went through scripts/ablation-replace.mjs (WRAP mode, trap-restored, absolute path): if (isAggregateCompatibleWithFieldType(aggregate, declared)) continue; gained || String(aggregate) !== 'ABLATION-21044', so every pair passes. Anchor 1 to 0, marker 0 to 1, blob 6c7bdce48e43 to f49be3058c84. A second trap in the outer script restored by absolute path too.
    • service-analytics was rebuilt, and ablation-dist-preflight.mjs found the marker in 2 built files (dist/index.cjs, dist/index.js).
    • Observed: service-analytics 12 failed, 20 passed, 1 skipped; runtime 2 failed, 10 passed. Exactly as predicted.
    • Restore: blob after restore 6c7bdce48e43 equals HEAD, git diff HEAD empty, whole-tree porcelain 0 lines. Then rebuilt, and ablation-dist-preflight.mjs --absent: marker absent from all 6 built files and the tree clean. The pins are green again at both later heads.

Tests (at d85b700030, after pnpm install --frozen-lockfile and a full turbo run build, 73 tasks)

  • @objectstack/service-analytics, full suite with OS_TEST_POSTGRES_URL set (no PostgreSQL cell skipped): 156 files, 3558 passed, 1 skipped (the named cell above). typecheck exit 0; tsc --noEmit --listFiles lists both touched test files.
  • @objectstack/rest, src/analytics-* and rest-hook-refusal-message-parity, with PostgreSQL: 20 files, 279 passed.
  • @objectstack/runtime, src/analytics-*, src/dispatcher*, src/http-dispatcher*, src/domains/analytics* and the other analytics-route suites: 51 files, 843 passed. typecheck exit 0, check:test-typecheck holds its ledger (27 files, 190 errors, 68 signatures; the new file adds none).
  • Not run locally, declared to CI: the whole rest and runtime suites, and packages/qa/dogfood (Dogfood Regression Gate).

Gates (at d85b700030, as ONE sequential script under the shared verify lock, each exit code captured before any pipe)

  • Derived families: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack names 62. --ran reconciles: 62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3). All 62 exit 0. check:dual-build-cjs-loads and check:type-check-debt answered PREREQUISITE NOT MET (exit 3) on the first sweep at 0abe2120fb, which had built only the dependency closure; after a full turbo run build (73 tasks) both exit 0, and both are 0 in the sweep at this head.
  • The five roster families the derivation marks ⛔ (a roster in a directory this diff touches): node scripts/check-changeset-fixed.mjs, pnpm check:authz-resolver, pnpm check:error-code-casing, pnpm check:filter-alias-parity, pnpm check:route-ledger-census. All exit 0.
  • check:adr-0087-registration reads the changeset as [BREAKING+bang+clause-②-narrowing] not-required (already-registered). check-changeset-no-major: no major bump. check:nul-bytes: 9734 text files, no raw control bytes.

ESLint, a declared narrowing (the repo-wide pnpm lint is CI's)

  • Touched files: eslint --no-inline-config --format json over the 6 touched .ts files at 0abe2120fb (the two later commits are a merge of main and a test-only harness change of 7 lines). From the JSON: 6 files, 0 errors, 0 warnings, 0 ignored.
  • Population: eslint.config.mjs's files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus NEVER_LINTED, which contains all 6 (none came back ignored).
  • Invariance: the config enables no type-aware linting (no parserOptions.project or projectService in any block), and this diff does not touch the config, so no untouched file's verdict can move.

Beyond the claimed file surface

  • The route pin lives in packages/runtime/src/, not beside packages/rest/src/analytics-*.test.ts. The cube door, POST /api/v1/analytics/query, is mounted by @objectstack/runtime's dispatcher-plugin; RestServer mounts only /analytics/dataset/query, so a pin in rest cannot reach this route. The new file is test-only and sits beside the sibling analytics route pins there. rest's analytics suites were run as well (above).
  • sum / avg are judged by the same door. The claim priced the narrowing as min / max; the door asks the table for every row, as the dataset door has since decision batch 🔗 Broken links detected in documentation #127, because a min / max-only condition would be a second scope on top of the one table, the shape dataset-compiler.ts records retiring. Measured, those rows answered 0 on SQLite and 500 on PostgreSQL before (table above), and the changeset prices them. No shipped cube authors either.

Acceptance notes

  • NOT MEASURED: MySQL (no server here). The door runs before any driver, so its verdict cannot depend on the dialect; the time description reads metadata only.
  • NOT MEASURED locally: packages/qa/dogfood (Dogfood Regression Gate is CI's). The one shipped cube, examples/app-showcase's showcase_delivery, declares count over * and sum / avg over estimate_hours (Field.number): every pair accepted. Every other min / max / sum / avg in examples/** is a dataset measure, which the dataset door already judged.
  • NOT MEASURED: the console. A console widget that sends a suffix-inferred FIELD_max / FIELD_sum to /analytics/query over a refused field now gets 400 INVALID_FIELD; the sibling repository was not read (dispatch order).
  • Relationship-path measures are not judged here, measured at the head: a configured measure { type: 'max', sql: 'account.name' } over a related text field is still served on the native face (200, "zeta", fields[] number, SQLite and PostgreSQL), and { type: 'max', sql: 'account.revenue' } over a related number field answers the string "250.000000000000000000000000000000" on PostgreSQL's native face under fields[] number. The ObjectQL face refuses both as a cross-object measure (400 INVALID_FIELD). Judging them needs the declaration on the hop's object, which is the hop-object resolution this dispatch fences off (analytics: on an inferred cube, a dotted path through a lookup whose name differs from its target object is never served — the door admits (and refuses) the lookup's NAME as if it were an object #20986's sites); the door stands down on a dotted column rather than guess, the dataset door's tier. This is the second position the seat's comment 5924234751 names; reported to the seat, not fixed here.
  • PostgreSQL's native face answers 500 DATABASE_ERROR for max over a boolean column, a pair the table ACCEPTS (function max(boolean) does not exist); SQLite and the ObjectQL face on PostgreSQL answer 1. Unchanged by this PR (measured before and after); the boolean pin skips that one cell by name. Reported to the seat.
  • main advanced three commits after the second merge (a11faeecb3, 99398542b3, 53ed3d1093: objectql's aggregation-filter door, driver-mongodb and the showcase's security set). None touches service-analytics, the dispatcher or the spec table; CI and the merge queue read the merged generation.
  • The private PostgreSQL 16.13 cluster used for every live cell runs on 127.0.0.1 from /tmp; it is stopped and its directory removed with this delivery.

Generated by Claude Code

claude added 5 commits October 1, 2026 05:48
…d-type judgment (red)

Pins first, ahead of the fix: a configured cube measure whose aggregate the
aggregate x field-type table refuses for its column's declared type is refused
INVALID_FIELD / 400 before either strategy reads anything; max over a number
column is served typed number (the control); min / max over a temporal column
is described time. The #20889 control that read max over a text column back is
flipped to the refusal.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…e table for every measure

A configured or suffix-inferred cube measure whose aggregate
AGGREGATE_FIELD_TYPE_COMPATIBILITY refuses for its column's declared type is
refused INVALID_FIELD / 400 in ensureCube, ahead of both strategies, as the
dataset door refuses the pair at compile. count_distinct keeps its own door.
A min / max over a temporal column is described time in fields[] by the
dataset door's one rule, measureResultType, at the seam every strategy's
result leaves through.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
The analytics dispatcher faces now refuse an anonymous caller with 401 before
the analytics service is reached (ADR-0056 D2), so the route pin stubs the
`auth` slot the way the sibling analytics route pins do. The route, the
service and the engine stay real.

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

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 13 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/measure-result-type.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/natural-language-queries.mdx (via count_distinct (literal, a string literal in isJudgedAggregate))
  • content/docs/data-modeling/queries.mdx (via count_distinct (literal, a string literal in isJudgedAggregate))
  • content/docs/deployment/validating-metadata.mdx (via count_distinct (literal, a string literal in isJudgedAggregate))
  • content/docs/kernel/contracts/data-engine.mdx (via count_distinct (literal, a string literal in isJudgedAggregate))
  • content/docs/protocol/objectql/query-syntax.mdx (via count_distinct (literal, a string literal in isJudgedAggregate))
  • content/docs/ui/dashboards.mdx (via count_distinct (literal, a string literal in isJudgedAggregate))

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

  • content/docs/releases/v15.mdx (via count_distinct (literal, a string literal in isJudgedAggregate))
  • content/docs/releases/v17/17-0.mdx (via count_distinct (literal, a string literal in isJudgedAggregate))
  • content/docs/releases/v17/17-5.mdx (via AnalyticsService (symbol, a top-level class), count_distinct (literal, a string literal in isJudgedAggregate))

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
  • 1 changed file(s) yielded no anchor (packages/services/service-analytics/src/measure-result-type.ts) — pages documenting those are invisible to this run
  • 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 — 10 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 a11faeecb34eb14800d64769cb86a8e1140988f1 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d85b700030e079e1d3804c81f62e3ca208ce898f
Local-runs: none

Isolated review for the domain:services seat (#6021). Inputs: card #21044 (body and all six comments, triage's grade 5924500811 and the seat's ACCEPT 5927661478 included), PR #21128 (body, 7-file list, net diff 12fbb2fac8..d85b700030 against the merge base with main; the three main commits past that base touch no file this PR touches), and the 31 check-runs on the head. Not an input: the dispatch order's conclusions.

① Derived judgments

  • One table, read through the spec's own predicate — right. cube-measure-field-type-door.ts decides with isAggregateCompatibleWithFieldType alone and names the accepted set off AGGREGATE_FIELD_TYPE_COMPATIBILITY[aggregate]; the only literals in the door are the count_distinct exclusion and two sentences of reason prose, neither a row. packages/spec/** is untouched in the diff, so the table is read, never edited.
  • Ahead of strategy selection on every cube path — right. assertMeasureFieldTypes sits in ensureCube on all three arms (inferred, augmented, declared), after the [finding] analytics: a cube / dataset dimension on a json field, compiled by NativeSQLStrategy, answers one group per serialized document on SQLite and 500 on PostgreSQL; the engine door #20783 closes does not see it #20807 / [finding] analytics NativeSQL answers a multi-value dimension one group per serialized array on SQLite and 500 on PostgreSQL, and a count_distinct over a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912 door and before the where gate; ensureCube precedes callCtx in both queryIn (query(), and every DatasetExecutor pass, which calls this.service.query) and generateSql (the /analytics/sql dry run). The pins count zero raw statements and zero engine aggregates on a refusal.
  • Code and envelope — right. INVALID_FIELD / 400 through invalidMemberError with member, param: 'measures', cube, plus field and object, per dataset-refusal.ts's rule (a verdict about one member the request named; /analytics/query carries no dataset document), the code the three source-field gates, the [finding] analytics NativeSQL answers a multi-value dimension one group per serialized array on SQLite and 500 on PostgreSQL, and a count_distinct over a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912 door and the engine's aggregate door already answer. The dataset door keeps DATASET_INVALID at compile and never reaches this door for a pair it refuses; a pair compile accepts, this door accepts (same predicate over the same declaration).
  • Every row judged, not only min / max — right, and the seat's reading holds. dataset-compiler.ts's assertAggregateFieldTypeCompatible judges all six aggregates with no scope condition since decision batch 🔗 Broken links detected in documentation #127, so "as the dataset door does" is literal. No table-accepted pair can newly refuse: the door's only verdict is the predicate, and a column it cannot resolve, a type outside FieldType, a non-bare cube.sql, a dotted path or a host with no sourceFieldMeta stand down. The pairs that newly refuse beyond the card's min / max over strings (sum / avg over non-numeric, non-boolean types, sum over percent, every arithmetic or order aggregate over formula, the JSON-stored and file types) are all rows the table refuses, were measured on the base as 0 on SQLite / 500 on PostgreSQL, and are priced in the changeset.
  • Shipped cubes carry no refused pair — right. examples/app-showcase showcase_delivery: count over *, sum / avg over estimate_hours, declared Field.number (task.object.ts). examples/app-crm registers no cube. The packages/qa/downstream-contract dc_pipeline fixture: count over *. Every other arithmetic measure under examples/** is a dataset measure, already judged at compile.
  • count_distinct keeps its own door, no double judgment — right. isJudgedAggregate excludes it by name; the [finding] analytics NativeSQL answers a multi-value dimension one group per serialized array on SQLite and 500 on PostgreSQL, and a count_distinct over a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912 door runs immediately before on each ensureCube arm and asks the same row plus isMultiValueField.
  • The temporal fields[] description — the dataset door's one rule, unchanged; a published-shape change, priced. withMeasureResultTypes at queryIn's result seam asks measureResultType with the cube measure's aggregate and the base-object column's declared type. measure-result-type.ts changes by eight TSDoc lines only; the function body is byte-identical, and it is the same call the dataset door makes in its ADR-0021 enrichment, so a dataset pass through both seams is idempotent. On the wire a cube-door min / max over date / datetime / time now reads fields[].type: 'time' where it read number: a value change in an existing key (no new key, so Clause-② stays no), named in the changeset's third FROM → TO bullet and in "Who is affected". Boolean keeps number by the rule's own verdict. Both strategies' buildFieldMeta are untouched; triage's "buildFieldMeta stops minting number" is delivered at the one seam that holds both halves, which is the shape the dataset door chose — right.
  • Public surface — right. No index.ts and no spec file in the diff: the door module is internal, no export or published type moves. AggregationFunction moves from a type import to the Zod enum value (.options), which spec/data/query.zod.ts exports.
  • The flipped pin — right. native-sql-measure-number-presentation.test.ts held the exact served pair (max over the code text column) as a control; it now pins the refusal with no statement run and keeps its text-dimension half.
  • Check-runs on the head, read after the last one completed: 34 runs, 31 success, 3 skipped (Build Docs, Console Pin Gate, the opt-in packed-tarball smoke — path-filtered), 0 failures. All seven required contexts success: Lint & Repo Gates (the check:* gates, check:adr-0087-registration and check-changeset-no-major included), TypeScript Type Check (and its four sub-jobs), Test Core (6/6 shards), Dogfood Regression Gate (3/3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset, Check PR Size, Part-of PR must not also close its card and The card this PR closes must claim this branch are success too. These conclusions are the gate verdicts; nothing was re-run here.

② Semver level

  • .changeset/21044-cube-measure-field-type-table.md: @objectstack/service-analytics: minor, BREAKING banner, FROM → TO with the one-line fix, Clause-②: no (narrowing), matching the PR body's line 2 and the ! in the title.
  • Clause-②: no (narrowing) — right. No new key on any published payload (no); the accept set of POST /api/v1/analytics/query and /analytics/sql narrows ((narrowing) is BREAKING). The fields[].type value change rides the same banner.
  • minor — right. The launch-window convention (check-changeset-no-major.mjs: a breaking change ships as minor, major is refused) and Check Changeset green.
  • ADR-0087 not-required (already-registered dataset-measure-selecting-aggregate-field-type-refused, dataset-measure-aggregate-field-type-refused) — right category. Both ids exist at the merge base (packages/spec/src/migrations/entries/semantic/18.*); the pairs this door refuses are exactly the pairs those two entries register, with the same three routes (count, a sort for a first or last record, a numeric or temporal field for a quantity stored as text). It is the disposition the lane landed for the identical shape, a query-time reader of the same table (.changeset/20914-aggregate-door-whole-table.md and 20914-release-sum-row.md, the engine door), and the alternatives are closed on facts: registered needs an id new in this diff (the claim fenced packages/spec/src/**, and a second entry would be a second account of one pair), no-migration-prescription is refused by the FROM → TO body, unpublished / runtime-interface-only / type-surface-only do not describe a runtime narrowing. One residual, recorded in ③: the two entries' surface prose names DatasetMeasureSchema rows and not Cube.measures.

③ Boundary flags

Read at 2026-10-01T08:38Z: the card, the PR, the net diff and the head's check-runs; nothing built, run or re-run.

Implemented-by: claude/issue-21044-cube-measure-type-table
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 08:39
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 08:39
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 39ab294 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21044-cube-measure-type-table branch October 1, 2026 09:07
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…e policies — double accumulation, the PostgreSQL boolean cast and the empty-sum fold, hoisted into core (objectstack-ai#21042) (objectstack-ai#21209)

Fixes objectstack-ai#21042
Clause-②: yes (widening)

## What this changes

The analytics native-SQL strategy (`NativeSQLStrategy`, the default on a
SQL driver) skipped three aggregate policies that
`SqlDriver.aggregate()` applies. So one route answered different
numbers, or a `500`, depending on which strategy served it. This PR
follows the route ruling on objectstack-ai#21042 (comment `5925613967`): the operand
policies are hoisted into `@objectstack/core`, beside
`AGGREGATE_ANSWER_KIND`, and both faces read them from there.

- **`packages/core/src/utils/aggregate-answer.ts`**
(`@objectstack/core`, `minor`). It takes:
- `AGGREGATE_ACCUMULATION`, moved from `driver-sql` with its docblock
(the docblock and the table are byte-identical to the base, apart from
the `export` keyword);
- `aggregandColumnClass({ type, multiple })`, the one column-class
predicate (`'fractional'`, `'integral'`, `'boolean'`, or none);
- `POSTGRES_BOOLEAN_AGGREGAND_CAST`, the objectstack-ai#11635 cast, as a `Record` over
`AggregationFunction`: `sum` / `avg` / `min` / `max` are cast, the
counts never are;
- `doubleAccumulationOperand(operand, dialect)`, the double operand with
the dialect as a parameter;
- `aggregandOperandSql(func, columnClass, dialect, operand)`, the one
composition both faces emit (the cast inside, the double operand around
it).
  No `index.ts` line was added: the module is already exported.
- **`packages/drivers/driver-sql/src/sql-driver.ts`** (`patch`), in the
ruled regions only. The `AGGREGATE_ACCUMULATION` table becomes a pointer
plus an import. `isFractionalNumericType` is deleted. The two registry
fills fill `fractionalNumericFields` through the predicate.
`accumulatesInDouble` / `doubleAccumulationOperand` are replaced by
`aggregandColumnClassOf`, which maps the driver's registries onto the
predicate's classes. In `aggregate()`, the private boolean-cast
condition and the accumulation call become one
`aggregandOperandSql(funcName, class, this.dialectName, '??')`.
-
**`packages/services/service-analytics/src/strategies/native-sql-strategy.ts`**
(`patch`), in two places.
- **`resolveMeasureSql`** wraps the column it hands `AGGREGATE_SQL` /
`CONDITIONAL_AGGREGATE_SQL` in `aggregandOperandSql`. The column class
comes from the declaration the host already relays
(`declaredValueShape`), on the object the column lives on
(`columnObjectOf`, the one hop resolver). The dialect comes from
`sqlDialect`, which the strategy already reads.
- **The `execute` shaping point** that PR objectstack-ai#21040 added now folds a
`null` measure answer to `emptyGroupValueFor(measure.type)`
(`@objectstack/spec`). It does this for every measure, measure-scoped
ones included, before the number presenter, in `driver-sql`'s order. The
dataset door's `DatasetExecutor` fill stays, and it is idempotent on a
folded row.
- The one line outside those two regions is the `generateSql` call site,
which now passes `ctx` to `resolveMeasureSql`.
- No native copy of any policy, and no runtime hook asks the driver: the
rejected (C) route was not taken. `canHandle`, `buildFieldMeta`, the
hop-object sites, the filter / text-match rendering,
`analytics-service.ts` and `field-read-admission.ts` are untouched.

## The card's table, before and after

Measured through `AnalyticsService.query` (the cube door, which `POST
/api/v1/analytics/query` relays verbatim) and
`AnalyticsService.queryDataset` (the dataset door), on
`AnalyticsServicePlugin` over a real ObjectQL engine and `SqlDriver`.
**Native** is the plugin's own composition (`NativeSQLStrategy`
answered, with one raw statement and no engine aggregate). **ObjectQL**
is the same composition narrowed to `engine.aggregate`. "Before" is the
strategy file at the base `d34aa58a2a`; "after" is this branch at
`61aab5013a`. Neither merge since then touches the aggregate code paths,
and the pins below are green at `ef1f9d8484`.

Fixture:

- group `f`: `frac` (a `number` column) holds 0.1 and 0.2, and `flag`
holds true and false;
- group `i`: `stars` (a `rating` column) holds seven 1s and two 2s, and
`flag` holds 7 trues and 2 falses;
- group `n`: every aggregand is NULL in all three rows.

The measure-scoped measures filter on `tag = x`, which only group `f`
holds.

**PostgreSQL 16.13** (a private local server; the ObjectQL column is the
same before and after):

| measure | group | door | native before | native after | ObjectQL |
|:--|:--|:--|:--|:--|:--|
| `sum(frac)` | f | cube, dataset | `0.3` | `0.30000000000000004` |
`0.30000000000000004` |
| `avg(frac)` | f | cube, dataset | `0.15` | `0.15000000000000002` |
`0.15000000000000002` |
| `avg(stars)`, an integer column | i | cube, dataset |
`1.222222222222222` | `1.2222222222222223` | `1.2222222222222223` |
| `sum(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `7` | `7` |
| `avg(flag)` | i | cube, dataset | `500 DATABASE_ERROR` |
`0.7777777777777778` | `0.7777777777777778` |
| `min(flag)` / `max(flag)` | i | cube, dataset | `500 DATABASE_ERROR` |
`0` / `1` | `0` / `1` |
| `sum(frac)`, all-NULL group | n | cube | `null` | `0` | `0` |
| `sum(frac)`, all-NULL group | n | dataset | `0` (executor fill) | `0`
| `0` |
| `sum(flag)`, all-NULL group | n | cube | `500 DATABASE_ERROR` | `0` |
`0` |
| `avg(frac)`, all-NULL group | n | cube, dataset | `null` | `null` |
`null` |
| measure-scoped `sum(frac)`, no admitted row | i | cube | `null` | `0`
| `0` |
| measure-scoped `sum(frac)`, no admitted row | i | dataset | `0`
(executor fill) | `0` | `0` |
| measure-scoped `avg(frac)`, no admitted row | i | cube | `null` |
`null` | `null` |
| `count` control | n | cube, dataset | `3` | `3` | `3` |
| measure-scoped `count` control | i | cube, dataset | `0` | `0` | `0` |

**SQLite** (better-sqlite3): accumulation and the boolean answers
already agreed on every face (`0.30000000000000004`,
`0.15000000000000002`, `1.2222222222222223`, `7`, `0.7777777777777778`,
`0` / `1`). The fold is the policy that diverged there:

| measure | group | door | native before | native after | ObjectQL |
|:--|:--|:--|:--|:--|:--|
| `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | cube |
`null` | `0` | `0` |
| `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | dataset
| `0` (executor fill) | `0` | `0` |
| measure-scoped `sum(frac)`, no admitted row | i, n | cube | `null` |
`0` | `0` |
| measure-scoped `sum(frac)`, no admitted row | i, n | dataset | `0`
(executor fill) | `0` | `0` |

After the fix, the native and ObjectQL faces **differ in 0 of 144
cells** (2 drivers × 2 doors × 12 measures × 3 groups).

**MySQL is NOT MEASURED**: there is no MySQL server in this container.
The MySQL operand text is pinned offline: by `core`'s
`aggregate-answer.test.ts`, and by the `driver-sql` move proof for the
driver's own statements.

## The move proof

`driver-sql`'s aggregate statements were dumped at the base, before any
consumer changed. The dump covered `SqlDriver.aggregate()` for every
function (`count`, `count_distinct`, `sum`, `avg`, `min`, `max`, and
`count(*)`), aliased and unaliased, over 23 columns: every fractional,
integral and boolean type, the `float` / `integer` / `int` aliases,
multi-valued and untyped columns, and text / date / lookup / formula. It
ran on SQLite, PostgreSQL and MySQL, through both registration paths
(`registerObjectMetadata` and `registerExternalObject`), offline (knex
`toSQL()`).

The policies were then hoisted, `driver-sql` was switched to the
imports, and the same dump was run again:

- base dump: 1668 entries, 0 errors, md5
`8eee668372a28a7568f3eb1cc5a2bc9b`;
- after dump (at `bc8aa0cc2f`): 1668 entries, md5
`8eee668372a28a7568f3eb1cc5a2bc9b`. `cmp` printed nothing: the two dumps
are **byte-identical**.

`sql-driver.ts` and `aggregate-answer.ts` are unchanged between
`bc8aa0cc2f` and `61aab5013a`.

The committed move-proof pin,
`packages/drivers/driver-sql/src/sql-driver-21042-aggregate-policy-move.test.ts`,
holds the captured expressions for one column of each class, on each
dialect and through each registration path. It passed at the base
(`192fc0010b`: 54 / 54) and passes after (54 / 54).

## Pins (committed red first, then the fix)

| file | at the pins commit (`192fc0010b`, base code) | after |
|:--|:--|:--|
| `core` `aggregate-answer.test.ts` | 16 red (the exports did not exist)
| 22 / 22 |
| `service-analytics` `native-sql-aggregate-policies.test.ts` (each
measure on both faces at both doors, against the engine's arithmetic;
SQLite and live PostgreSQL cells) | SQLite: the cube-door folds red.
PostgreSQL: accumulation, boolean `500`, folds red | 49 / 49 |
| `service-analytics` `cube-measure-field-type-door.test.ts`, **the
lifted skip** | PostgreSQL native `max(boolean)` red (`500`) | 23 / 23 |
| `rest` `analytics-dataset-aggregate-policies-door.test.ts` (the route,
both strategies) | PostgreSQL: 7 red (accumulation and booleans). SQLite
green (that door already folded) | 19 / 19 |
| `driver-sql` move proof | 54 / 54 | 54 / 54 |

**The lifted skip:** `it.skipIf(cell.id === 'pg' && face === 'native')`
in `cube-measure-field-type-door.test.ts` (from PR objectstack-ai#21128) is gone. Its
comment now says why the cell runs on every cell and face. The
PostgreSQL native `max_flag` cell answers `1`.

`native-sql-measure-number-presentation.test.ts` gets a comment-only
edit: its header said the native statement does not carry objectstack-ai#20387's
accumulation, and it now points at the new pin.

## Ablations

There was one ablation per policy, each predicted in writing before it
ran. Each mutation was planted through `scripts/ablation-replace.mjs`,
which checks that the anchor hit and that the blob changed. Each
mutation was confirmed in the built `dist/`
(`ablation-dist-preflight.mjs`: marker present). Each restore ran by
absolute path (`git checkout HEAD`), and the file's blob was proven
equal to its `HEAD` blob with `git diff HEAD` empty. After each restore
the package was rebuilt, and the marker was proven absent from `dist/`
with the tree clean. Every prediction held exactly.

| ablation | mutation | predicted red | observed red |
|:--|:--|:--|:--|
| A1 accumulation | `core` `accumulatesInDouble`'s dialect gate never
admits PostgreSQL or MySQL | `core` 3; `driver-sql` move proof 24 (pg
and mysql × both fills × the six numeric / boolean columns);
`service-analytics` 10, PostgreSQL only (both doors × `sum` /
`avg(frac)`, `avg(stars)`, measure-scoped `sum` / `avg`); `rest` 3,
PostgreSQL only | the same 3 / 24 / 10 / 3; SQLite cells green;
`avg(flag)` green as predicted |
| A2 boolean cast | `core` `aggregandOperandSql` never casts | `core` 1;
move proof 4 (pg × both fills × `boolean` / `toggle`);
`service-analytics` 8, PostgreSQL only; the lifted cell, PostgreSQL
native and ObjectQL, 2; `rest` 4, PostgreSQL only | the same 1 / 4 / 8 /
2 / 4 |
| A3 fold | the native shaping point never folds | `service-analytics`
8, cube door only (SQLite and PostgreSQL × the three all-NULL `sum`s and
the measure-scoped `sum`); everything else green, the `rest`
dataset-door route included, because the executor fill folds there | the
same 8; `rest` 19 / 19 green |

A1 and A2 show one policy reaching both faces. Each one turned
`driver-sql`'s own statements red. Under A2 the ObjectQL face's
`max(boolean)` cell failed too, with the driver's refusal. Under A1 the
ObjectQL face answered the same exact decimal as the native face for the
plain measures: the failing assertion was the engine's number, while
native and ObjectQL still agreed.

A **reverse type check** also ran. Passing a dialect the new type
rejects (`'oracle'`) to `aggregandOperandSql` turned
`service-analytics`' typecheck red (`TS2345 ... not assignable to
parameter of type 'AggregandSqlDialect'`), which shows the rebuilt
`core` `.d.ts` was read. The file was restored byte-identical.

## Verification (at `ef1f9d8484`, after merging `origin/main` at
`cb45469e67`, which carries PR objectstack-ai#21170 and PR objectstack-ai#21173)

Everything below ran as one locked script, at `ef1f9d8484`, with each
exit code captured before any pipe. The live PostgreSQL 16.13 server ran
at `timezone = Asia/Shanghai`, and the `driver-sql` suite ran under
`TZ=America/New_York`, which are its own non-vacuity preconditions.

- **Refresh after the merge:** `pnpm turbo run build
--filter='!@objectstack/docs' --concurrency=1` exit 0, and `pnpm
--filter @objectstack/spec check:generated` exit 0.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 67 commands from the real diff (10
paths). All 67 exited 0. Reconciliation with `--ran` and the recorded
exit codes printed: `Run reconciliation — 67 derived, 67 run, 0
NOT-MEASURED, 0 UNRUN.`
- **Typecheck:** `pnpm --filter … typecheck` exit 0 for
`@objectstack/core`, `@objectstack/driver-sql`,
`@objectstack/service-analytics` and `@objectstack/rest`.
- **Tests, every vitest project of every touched package** (`vitest run
--maxWorkers=2`, with `OS_TEST_POSTGRES_URL` set so the live cells ran):

| package / project | files | tests |
|:--|:--|:--|
| `core` local | 74 passed | 2120 passed |
| `core` repo | 3 passed | 48 passed |
| `driver-sql` | 216 passed, 3 skipped | 4300 passed, 96 skipped |
| `service-analytics` | 161 passed | 3765 passed |
| `rest` local | 256 passed | 5027 passed, 127 skipped |
| `rest` repo | 5 passed | 179 passed, 1 skipped |

- **The five pin files, run explicitly:** move proof 54 / 54, `core` 22
/ 22, policies 49 / 49 (24 SQLite + 24 PostgreSQL + the oracle),
field-type door 23 / 23, `rest` route 19 / 19 (9 SQLite + 9 PostgreSQL +
the oracle).
- **Lint, narrowed and proven:** `eslint --no-inline-config --format
json` over the 9 changed code files answered 9 results, 0 errors and 0
warnings. The population is read from eslint's own config:
`ESLint.isPathIgnored` answers `false` for each of the 9. The narrowing
excludes nothing that could move, because `eslint.config.mjs` never
enables type-aware linting (no `parserOptions.project`, no typed rules),
so this diff cannot change the verdict on an untouched file. The
repo-wide `pnpm lint` is CI's.
- **The `driver-sql` live preconditions:** the first gate run used a
private server at UTC, and the suite's four timezone non-vacuity cells
failed by design (`… start it with timezone=Asia/Shanghai`). With the
server at `Asia/Shanghai` and the process at `America/New_York` the
suite is green, as listed above.
- **`main` after the final merge:** `origin/main` moved 4 commits past
`cb45469e67` before this PR opened (objectstack-ai#21149, objectstack-ai#21188, objectstack-ai#21195, objectstack-ai#21192).
None of them touches `core`, `driver-sql`, `service-analytics` or the
analytics `rest` tests. The one `packages/spec` file in the analytics
area, `ui/dataset.zod.ts`, changes a comment only. They are not merged
here; CI runs on the merge ref.

## Acceptance notes

- **MySQL is NOT MEASURED** (no server in this container). The MySQL
operand text is pinned offline in `core` and in the `driver-sql` move
proof.
- **The live PostgreSQL cells are not run in CI.** No CI step sets
`OS_TEST_POSTGRES_URL` for `service-analytics` or `rest`. The cells
above ran against a private PostgreSQL 16.13 started for this run and
removed afterwards. In CI the SQLite cells run, and so do the offline
`core` / move-proof pins.
- **Phase 0's note on the dataset door, which is no divergence:** a
measure-scoped `avg` is **absent** from a row its supplementary query
reported no row for. That is `x_avg_frac` for groups `i` and `n`, on
both strategies and before and after. It is not `null`. The new service
pin holds this cell only to "both faces agree", not to a value.
Relatedly, a dataset-door selection made only of measure-scoped measures
reports only the groups their filter admits, so the pin asks each one
beside the base count.
- **Residual, as stated in the ruling:** a host that relays no field
declarations (`declaredValueShape`), or names no SQL dialect, gets no
column class or no policy. It keeps the native arithmetic it had, and a
PostgreSQL boolean `sum` there still answers `500`. The plugin's own
composition wires both.
- **How `driver-sql` reads the class:** it reads its own registries
rather than calling the predicate per column. `fractionalNumericFields`
is filled by the predicate. `booleanFields` and `numericFields` are
filled by the driver's coercion rules, whose populations equal the
predicate's `'boolean'` and `'fractional'` ∪ `'integral'` classes. The
move proof pins that equality per column class. Asking the predicate per
column through the driver's `valueShapeFields` would retire
`fractionalNumericFields`, but its declaration and shard-alias regions
are outside the ruled surface, so this PR does not do it.
- **The scan-order residual is unchanged.** On PostgreSQL and MySQL the
double sums are added without compensation (`AGGREGATE_ACCUMULATION`'s
docblock), so three or more fractions can still differ in the last place
from SQLite and the rows path. The pins use two addends.
- objectstack-ai#21129 (the presenter for a relationship-path `min` / `max`) is not
addressed here.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants