Skip to content

fix(core,driver-sql,service-analytics): the analytics native-SQL path answers measures declared number as numbers (#20889) - #21040

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20889-native-measure-number
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20889-native-measure-number

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20889
Clause-②: yes (widening)

What changed

On PostgreSQL, the analytics native-SQL path answered every measure its response declares number as a string: count: "2", sum: "500.000000000000000000000000000000". SQLite answered numbers. driver-sql's own aggregate() has presented these answers since #20335, but through a private table and presenter that service-analytics could not reach. The seat's route ruling (5922780640) moves that one rule into @objectstack/core, and both faces now call it.

  • @objectstack/core (new src/utils/aggregate-answer.ts, one export line in index.ts beside compensatedSum):

    • AGGREGATE_ANSWER_KIND, with its precision-policy docblock, moved from sql-driver.ts. The text is byte-identical apart from the export keyword; the proof is below.
    • presentAsNumber(value), the body of presentReadValue's 'number' arm as a named function. A string Number() reads as a number becomes that number. Any other value is returned as given.
  • @objectstack/driver-sql, sql-driver.ts, the two ruled regions only:

    • the AGGREGATE_ANSWER_KIND definition becomes an import, and a pointer comment marks where it was;
    • presentReadValue's 'number' arm returns presentAsNumber(value).

    The import is a separate statement beside the AggregationFunction import (inserted after base line 23; line 27 at HEAD). It does NOT join the @objectstack/core import block at base lines 94-100, which PR fix(plugin-security,driver-sql,driver-turso): lower type-blind at the RLS seam without a guard, then delete the F1/F2 whole-day and NOT-rewrite copies (#5930 step 4, group 2) #20988 rewrites. That hunk starts at base line 91, 67 lines below the insertion. No behaviour change: the full driver-sql suite is green on SQLite and PostgreSQL (below).

  • @objectstack/service-analytics, NativeSQLStrategy.execute only. After the raw statement runs, each measure column is presented by the measure's DECLARED aggregate function (lookupMember(cube, m, 'measure').type), never by whether a value looks numeric:

    • count, count_distinct, sum and avg (the table's 'number' kind) always take presentAsNumber;
    • min / max (the 'column' kind) take it only when declaredFieldType(object, column) is in NUMERIC_VALUE_TYPES. That is the rule driver-sql's readPresentationKind applies to a declared numeric column. A host that cannot answer, or a relationship-path column, leaves the value as given;
    • expression metric types (number / string / boolean) are left as they are (ruling Q3).

    One point covers both doors: the cube read (POST /api/v1/analytics/query) and the dataset door (POST /api/v1/analytics/dataset/query, through DatasetExecutor).

  • .changeset/20889-analytics-native-measure-number.md: @objectstack/core minor, @objectstack/driver-sql and @objectstack/service-analytics patch, Clause-②: yes (widening) for the two new root exports.

Per-measure answers, before and after

Fixture: text category; rating stars (an integer column); number amount and frac (the exact-decimal column); currency price. Group a holds 2 rows.

  • Before, PostgreSQL: phase 0's reading at base 7fa67dada3, through AnalyticsService.query (what the route relays verbatim). The red pins at 719b98795e read the same strings.
  • After: read at e827c237d1 through the real dispatcher-plugin mount of POST /api/v1/analytics/query, by a scratch probe that was deleted after the run.
function column (declared type) SQLite before SQLite after PostgreSQL 16.13 before PostgreSQL 16.13 after
count * 2 2 "2" 2
count_distinct note (text) 2 2 "2" 2
sum stars (rating, integer) 7 7 "7" 7
avg stars (rating, integer) 3.5 3.5 "3.5000000000000000" 3.5
sum amount (number, decimal) 500 500 "500.000000000000000000000000000000" 500
avg amount (number, decimal) 250 250 "250.000000000000000000000000000000" 250
min amount (number) 100 100 "100.000000000000000000000000000000" 100
max price (currency) 20.5 20.5 "20.500000000000000000000000000000" 20.5
max note (text), the control not read at base "y" not read at base "y" (left as the client gave it)
sum amount over 9007199254740993 9007199254740992 9007199254740992 "9007199254740993.000000000000000000000000000000" 9007199254740992
dataset row_count (count) * 2 2 "2" 2
dataset filtered_count, a group the statement answered * 1 1 "1" 1
expression measure, type number SUM(amount) / 2 250 250 "250.000000000000000000000000000000" unchanged, left as is (ruling Q3)

fields[] declared number for every measure on both dialects, before and after. On the ObjectQL face of the same route, every answer was already a number on both dialects, and it is unchanged.

Move proof

move-proof.py (scratch) reads the block from BASE 2f2fa11d75's sql-driver.ts and from HEAD's aggregate-answer.ts:

table+docblock: base lines 57, core lines 57; differing lines 1: ["export const AGGREGATE_ANSWER_KIND: Readonly(...) = {"]   (type arguments elided in this copy only)
  sha1 base  e899be861c6e5e13cab24558923e41b0ca7b740c
  sha1 core (export keyword removed) e899be861c6e5e13cab24558923e41b0ca7b740c
presenter: base arm body lines 8, core function body lines 8; identical after de-indent: True
MOVE PROOF: PASS

The table and its docblock are byte-identical apart from export. The presenter's eight body lines, comment included, are identical once de-indented. In core, a new file header says why the rule lives there. The moved docblock still speaks of SqlDriver.aggregate and formatOutput in the driver's voice, and the header says so.

Pins (red first), the ablation, and the local PostgreSQL run

  • Red, at 719b98795e (pins only, no fix), on a private PostgreSQL 16.13 (initdb, 127.0.0.1, trust):
    • @objectstack/core aggregate-answer.test.ts: 1 file failed, Cannot find module './aggregate-answer'.
    • service-analytics native-sql-measure-number-presentation.test.ts: 5 failed, all PostgreSQL; 5 passed, all SQLite. First failure: a row_count is a number, never "2": expected 'string' to be 'number'.
    • rest, the new analytics-dataset-measure-number-door.test.ts and the tightened analytics-dataset-json-dimension-door.test.ts: 2 failed, both PostgreSQL; 6 passed. Failures: a row_count is a number, never "2" and expected [ [ 'x', '2' ], [ 'y', '1' ] ] to deeply equal [ [ 'x', 2 ], [ 'y', 1 ] ].
  • Green, at e827c237d1: core 5/5; service-analytics 10/10 (SQLite and PostgreSQL); rest 8/8 (SQLite and PostgreSQL).
  • Ablation (the strategy stops calling the presenter). Predicted before running: exactly the PostgreSQL cells go red, service-analytics 5 and rest 2; every SQLite test and the core pin stay green.
    • The mutation went through scripts/ablation-replace.mjs (WRAP mode, with its restore trap) on the absolute path. The guard if (numberMeasures.length > 0 && Array.isArray(rows)) gained && String(numberMeasures) === 'ABLATION-20889', so it never holds. Anchor count went 1 to 0, blob 3bce7bb073 to 8f36f62dfe.
    • service-analytics was rebuilt, and ablation-dist-preflight.mjs found the marker in 2 built files.
    • Observed: service-analytics 5 failed (all PostgreSQL), 5 passed; rest 2 failed (both PostgreSQL), 6 passed; core 5/5. That is exactly as predicted.
    • Restore: the blob after restore equals HEAD (3bce7bb073), git diff HEAD is 0 bytes, and porcelain is 0 lines. Then a rebuild, ablation-dist-preflight.mjs --absent (marker absent from all 6 built files, tree clean), and a re-run: all green.
  • driver-sql's full suite at e827c237d1, SQLite and live PostgreSQL, TZ=America/New_York, with the server at Asia/Shanghai as CI runs it: 210 files passed, 3 skipped; 4061 tests passed, 95 skipped. sql-driver-20335-aggregate-numeric-presentation.test.ts 15 tests (1 skipped, the MySQL cell), and sql-driver-20387-aggregate-double-accumulation.test.ts 11 (1 skipped). A first run on a UTC server failed only the four suites whose precondition asserts a non-UTC server.
  • CI: no CI job provisions OS_TEST_POSTGRES_URL for service-analytics or rest, so their PostgreSQL cells are named skips there (skipped: set OS_TEST_POSTGRES_URL to run this cell). The core pin (string to number) and every SQLite cell run in CI. The private cluster was stopped and its data directory removed after the runs.

Gates

All at HEAD e827c237d1, 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 67. --ran reconciles: 67 derived famil(ies) accounted for — 65 run, 2 NOT-MEASURED, 0 unrun. All 65 exit 0.
    • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt. Reason: both exit 3, PREREQUISITE NOT MET. They read the built output of every package, and this run built only the dependency closure it tests. CI's lint.yml builds the full closure before them.
  • The four roster families the derivation marks ⛔ (roster in a directory this diff touches): check-changeset-fixed.mjs, check:authz-resolver, check:error-code-casing, check:filter-alias-parity. All exit 0.
  • Tests and typechecks:
    • @objectstack/core test: 62 files, 1803 tests passed. Typecheck exit 0, and its check:test-typecheck covers the new test.
    • @objectstack/driver-sql: typecheck exit 0; the full suite as above.
    • @objectstack/service-analytics full suite with OS_TEST_POSTGRES_URL set: 151 files, 3468 tests passed. Typecheck exit 0; tsc --listFiles includes the new pin.
    • @objectstack/rest: the two pin files, SQLite and PostgreSQL, 8/8. Typecheck exit 0, with check:test-typecheck over tsconfig.test.json.
  • ESLint, a declared narrowing (the repo-wide pnpm lint is CI's):
    • Touched files: eslint --no-inline-config --format json over the 8 touched .ts files. From the JSON: 8 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 8.
    • Invariance: the config enables no type-aware linting (eslint.config.mjs:326-328, no parserOptions.project), and this diff does not touch the config, so no untouched file's verdict can move.

Acceptance notes

  • MySQL: NOT MEASURED (no MySQL server here). The presenter is not gated by dialect, so a DECIMAL string would be presented the same way. Separately, a read-only inference, also unmeasured: plugin.ts:500 returns engine.execute's result as the rows whenever it is an array, and knex.raw on mysql2 resolves to a [rows, fields] pair, so on MySQL the native path may read that pair as its rows.
  • formatOutput's inline copy of the presenter (sql-driver.ts, the numeric pass at about :20303) is not folded onto presentAsNumber here, per the ruling. It is the same rule written a second time, against presentReadValue's "never a re-derivation" docblock. For domain:engine.
  • Prose that now points to the old home: sql-driver-20335-aggregate-numeric-presentation.test.ts:31 and sql-driver-20387-aggregate-double-accumulation.test.ts:7 say AGGREGATE_ANSWER_KIND lives in sql-driver.ts. These are driver-sql test docblocks outside this card's two regions. For domain:engine, or whoever next touches those files.
  • Expression measures (type: 'number' | 'string' | 'boolean') are untouched: fields[] still declares number while PostgreSQL answers "250.000…", text or a JS boolean. [Decision] analytics field gate (#20917): an authored cube member whose sql is an expression — keep the stand-down, judge its identifiers, refuse it, or retire expressions #20943 (PR feat(spec)!: an analytics cube member's sql is a column reference, and the showcase done rate moves to its dataset (#20943) #20998) retires SQL expressions in cube member sql.
  • min / max over a relationship-path column (account.revenue) stay as the client gave them. declaredFieldType looks a column up on the base object only, the same "cannot answer, do not block" tier measureResultType takes.
  • Not this card: the SUM / AVG accumulation divergence on the native statement (0.1 + 0.2 answers 0.3 on PostgreSQL native, against 0.30000000000000004 elsewhere). It was measured once through the route, as the ruling asked, and is reported to the seat. No pin enshrines either side of it.
  • The census of analytics.query( call sites (envelope-caller-census.test.ts) is unchanged: the new pins drive the route or call the service under another name, so no ledger row or declared input moved.

Generated by Claude Code

claude added 2 commits October 1, 2026 02:32
…mbers on the analytics native-SQL path (red)

Red-first pins for #20889. On PostgreSQL the analytics native-SQL path
answers count / count_distinct / sum / avg and numeric min / max as strings
while fields[] declares number; SQLite answers numbers.

- core: aggregate-answer.test.ts pins AGGREGATE_ANSWER_KIND and the 'number'
  presenter (red: the module does not exist yet).
- service-analytics: the cube read and the dataset door over a real engine
  and SqlDriver, SQLite and a named-skip PostgreSQL cell.
- rest: POST /api/v1/analytics/dataset/query on both cells, and the lenient
  row_count pin (number or string) tightened to a number.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
… answers measures declared number as numbers (#20889)

On PostgreSQL, NativeSQLStrategy returned the SQL client's rows unshaped,
so count / count_distinct / sum / avg and numeric min / max came back as
strings while fields[] declared number. driver-sql's own aggregate() has
presented those answers since #20335, through a private table and presenter.

- core: utils/aggregate-answer.ts receives AGGREGATE_ANSWER_KIND (its
  docblock moved byte-identical) and the 'number' presenter, presentAsNumber
  (the body of presentReadValue's 'number' arm). Exported from the root
  beside compensatedSum.
- driver-sql: the definition becomes an import; presentReadValue's 'number'
  arm calls presentAsNumber. No behaviour change.
- service-analytics: NativeSQLStrategy.execute presents each measure column
  keyed on its declared aggregate function, and min / max only over a column
  declared numeric (declaredFieldType). Expression measures are untouched.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added 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 3 package(s): @objectstack/core, @objectstack/driver-sql, @objectstack/service-analytics, touching 4 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/index.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 (symbol, a field of const object AGGREGATE_ANSWER_KIND))
  • content/docs/data-modeling/queries.mdx (via count_distinct (symbol, a field of const object AGGREGATE_ANSWER_KIND))
  • content/docs/deployment/validating-metadata.mdx (via count_distinct (symbol, a field of const object AGGREGATE_ANSWER_KIND))
  • content/docs/kernel/contracts/data-engine.mdx (via count_distinct (symbol, a field of const object AGGREGATE_ANSWER_KIND))
  • content/docs/protocol/objectql/query-syntax.mdx (via count_distinct (symbol, a field of const object AGGREGATE_ANSWER_KIND))
  • content/docs/ui/dashboards.mdx (via count_distinct (symbol, a field of const object AGGREGATE_ANSWER_KIND))

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

  • content/docs/releases/v15.mdx (via count_distinct (symbol, a field of const object AGGREGATE_ANSWER_KIND))
  • content/docs/releases/v17/17-0.mdx (via count_distinct (symbol, a field of const object AGGREGATE_ANSWER_KIND))
  • content/docs/releases/v17/17-5.mdx (via count_distinct (symbol, a field of const object AGGREGATE_ANSWER_KIND))

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/core/src/index.ts) — pages documenting those are invisible to this run
  • 5 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 — 35 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 2f2fa11d756f665a4c06160480c1dce15b9d67a4 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2f2fa11d756f665a4c06160480c1dce15b9d67a4

⚠️ 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 2f2fa11d756f665a4c06160480c1dce15b9d67a4 → 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: e827c237d1a18f469e33955474d81e8c3dcc9e56
Local-runs: none

Inputs: card #20889 (body and all 8 comments, the route ruling 5922780640 and the build report 5924019139 included), PR #21040 (body, 9-file list, net diff against main at 2f2fa11d75), and the check-runs on the head. Read-only: git show / git diff of fetched objects and REST reads. Judged against ruling 5922780640 (route b, @objectstack/core; Q2 type only; Q3 min / max over a declared numeric column).

① Derived judgments

  1. @objectstack/core root surface: two new exports, AGGREGATE_ANSWER_KIND and presentAsNumber (export * from './utils/aggregate-answer.js' in index.ts, one statement beside compensatedSum's). Right. It is the ruled route and aggregate sum / avg over 3+ fractional addends: driver-memory native, its analytics face and the service-analytics draft preview still add naively (0.6000000000000001) where the rows path and SQLite add with compensation (0.6) #20544's precedent. The new file's only import is a type-only AggregationFunction from @objectstack/spec/data, a declared core dependency; no new package edge anywhere (driver-sql and service-analytics already depend on core at runtime). Core carries no api-surface snapshot (that gate is packages/spec's), so nothing to regenerate.
  2. The move, re-proven here from the git objects. Table plus docblock: base sql-driver.ts lines 1533 to 1589 against aggregate-answer.ts lines 37 to 93, 57 lines each, exactly one differing line (const to export const); sha1 e899be861c6e5e13cab24558923e41b0ca7b740c on both sides once the export keyword is removed. Presenter: the base 'number' arm body (lines 15843 to 15850) and the presentAsNumber body (lines 112 to 119) are identical after de-indent, comment included. Right: byte-identical apart from the export keyword, as the ruling asked ("moved rather than rewritten").
  3. @objectstack/driver-sql: behaviour and public surface unchanged. index.ts untouched; both names were module-private before. The diff holds exactly three hunks: the import (a separate statement after base line 23), region 1 (the definition becomes a five-line pointer comment), region 2 (the 'number' arm returns presentAsNumber(value)). aggregate() still reads the table at its two sites (base 10524 / 10534) through the import, and presentReadValue's arm now calls the same body it inlined. Right: the ruled "exactly two regions" hold, and the driver's answers are the same function of the same inputs by construction. The suite and the driver-sql on PostgreSQL: the native aggregate returns count / sum / avg as strings ("n":"1", "total":"20.000…"), so having { n: { $in: [2] } } keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335 / aggregate sum / avg: PostgreSQL and MySQL native answer exact decimal (0.1 + 0.2 = 0.3) while SQLite and the engine rows path answer a double (0.30000000000000004), so having { s: { $eq: 0.3 } } keeps the group on PG / MySQL native only #20387 pins are the check-runs' verdict below, not the dev's local run.
  4. @objectstack/service-analytics NativeSQLStrategy.execute: the keying. A measure column is presented only when lookupMember(cube, m, 'measure').type is an own key of AGGREGATE_ANSWER_KIND (the hasOwnProperty guard drops the expression metric types number / string / boolean, ruling Q3) and either its kind is 'number' (count, count_distinct, sum, avg: always) or its kind is 'column' (min, max) and declaredFieldType(objectName, measure.sql) is in NUMERIC_VALUE_TYPES (number, currency, percent, rating, slider, progress, summary), the same test driver-sql's readPresentationKind applies to a declared numeric column. No value is ever sniffed: dimensions are never visited, and the pin holds a numeric-looking text dimension and max over a text column as text. Rows are keyed by member name, and the statement aliases every measure AS "measure" (strategy line 584), so the in-place write lands on the right column. Right. The accept-set does not move: nothing new is admitted, no refusal changes.
  5. Both doors through one point. The cube read's baseCtx carries declaredFieldType off sourceFieldMeta (analytics-service.ts:1277), and the dataset door compiles each measure to a cube metric whose type is the aggregate and whose sql is field ?? '*' (dataset-compiler.ts 692 to 700), so DatasetExecutor's queries reach the same execute. Right. A host with no engine wired, or a dotted column, answers undefined and min / max are left as given (see ③ item 5).
  6. @objectstack/rest: tests only. The lenient Number(r.row_count) pin becomes a typed equality, as the ruling ordered; a new dataset-route pin on both dialects. No analytics.query( call site is added, so the census ledger and its declared inputs do not move. Right.
  7. PR fix(plugin-security,driver-sql,driver-turso): lower type-blind at the RLS seam without a guard, then delete the F1/F2 whole-day and NOT-rewrite copies (#5930 step 4, group 2) #20988 collision: none. sql-driver.ts is the same blob (0af5982488) in both PRs' bases (05be3525 and 2f2fa11d; a diff of that path between them is empty), so line numbers compare directly. fix(plugin-security,driver-sql,driver-turso): lower type-blind at the RLS seam without a guard, then delete the F1/F2 whole-day and NOT-rewrite copies (#5930 step 4, group 2) #20988's hunks on the file start at 91 (the @objectstack/core import block), 4402, 4628, 4918, 5051, 15435 (through 15534), 16036 and later; this PR's sit at 21 to 26, 1533 to 1589 and 15842 to 15851. The nearest pair is 67 lines apart; region 2 lies between fix(plugin-security,driver-sql,driver-turso): lower type-blind at the RLS seam without a guard, then delete the F1/F2 whole-day and NOT-rewrite copies (#5930 step 4, group 2) #20988's 15534 and 16036. An in-memory git merge-tree --write-tree of the two heads (object-store read, no checkout) produces a tree with no conflict. After both land the file carries two @objectstack/core import statements, the pattern it already has for @objectstack/spec/data (three). Right: disjoint hunks, clean merge.
  8. Check-runs on the head (REST, final reading at 2026-10-01T03:35Z after waiting for the last Test Core shards): 34 check-runs, every one completed; 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in): path-filtered or opt-in), 0 failures. The seven required contexts are green: Lint & Repo Gates, TypeScript Type Check, Test Core (all six shards), Dogfood Regression Gate (all three), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Also green: Check Changeset, Check PR Size, the four Type Check · jobs (workspace, consumer gates, source gates, debt ledger), and the three claim / single-writer guards. These conclusions are the gate verdicts: the two derived families the dev marked NOT MEASURED locally (check:dual-build-cjs-loads, check:type-check-debt) run in CI with the full closure built, under the green Lint & Repo Gates and Type Check · debt ledger contexts, and the driver-sql suite with its driver-sql on PostgreSQL: the native aggregate returns count / sum / avg as strings ("n":"1", "total":"20.000…"), so having { n: { $in: [2] } } keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335 / aggregate sum / avg: PostgreSQL and MySQL native answer exact decimal (0.1 + 0.2 = 0.3) while SQLite and the engine rows path answer a double (0.30000000000000004), so having { s: { $eq: 0.3 } } keeps the group on PG / MySQL native only #20387 pins ran on live PostgreSQL and MySQL under Temporal Conformance. The gates hold.

② Semver level

  • .changeset/20889-analytics-native-measure-number.md: @objectstack/core minor, @objectstack/driver-sql patch, @objectstack/service-analytics patch. Right. Two new root exports on core are an additive widening of a published surface, so yes (widening) and at least minor. driver-sql publishes no surface or behaviour change but now imports two names from core, so its published dependency range must move with core's: patch is the right level and not optional. service-analytics is a bug fix in a released package: patch, never skip-changeset. rest changes tests only and publishes nothing: no entry, correctly.
  • Clause-②: yes (widening). Right. It stands on PR body line 2 and in the changeset body, one arm, the same grammar; the arm is owed by the core export, not by the value change.
  • The PostgreSQL value-type change (a JSON string becoming a number for every measure fields[] already declared number) owes no declaration beyond that. It is an answer moving toward its declared type, not a change to what the system accepts; driver-sql on PostgreSQL: the native aggregate returns count / sum / avg as strings ("n":"1", "total":"20.000…"), so having { n: { $in: [2] } } keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335 landed the identical move at the driver door as Clause-②: no, patch (.changeset/20335-pg-aggregate-numbers.md at 15bf186f50, accepted 5864113534), and SQLite answers are byte-identical. Nothing authorable is removed or renamed, so no migration text and no ADR-0087 disposition is owed. The changeset body already carries the consumer-facing sentences (what changed, the one-double precision policy, what did not move), which is the CHANGELOG text an upgrading consumer greps.

③ Boundary flags

The build report's open_questions is empty; phase 0's three questions were ruled in 5922780640, and the build matches each: Q1 route b, built as ruled (①1 to ①3). Q2 type only: no pin enshrines either side of the SUM / AVG accumulation divergence; the precision pin asserts #20335's one-double policy equal to the ObjectQL face; the one route measurement was reported and the seat filed it (#21042). Q3 min / max only over a declared numeric column, expression types untouched (①4).

Dev flags, from the acceptance notes and 5924019139:

  1. MySQL NOT MEASURED, and the plugin.ts:500 inference that knex.raw on mysql2 hands a [rows, fields] pair through as the rows. Answered as an acceptance note, which is the right carrier for an unmeasured inference (Prime Directive chore: version packages #10); the bridge shape predates this diff. The presenter itself is dialect-blind and driver-sql's own MySQL door runs in Temporal Conformance (live PG + MySQL) (①8). Escalated to the seat as a candidate measurement card for the analytics native path on MySQL; not a blocker.
  2. formatOutput's inline copy of the presenter (sql-driver.ts about :20303). Answered: the ruling kept the build to two regions on purpose; the note is addressed to domain:engine.
  3. Two driver-sql test docblocks still place AGGREGATE_ANSWER_KIND in sql-driver.ts (sql-driver-20335-aggregate-numeric-presentation.test.ts:31, sql-driver-20387-aggregate-double-accumulation.test.ts:7). Verified present at the head. Prose only, outside the two ruled regions; answered as a note for domain:engine. Not a blocker.
  4. Expression measures (number / string / boolean) keep the client's value under fields[] number. Answered by ruling Q3 and carried by [Decision] analytics field gate (#20917): an authored cube member whose sql is an expression — keep the stand-down, judge its identifiers, refuse it, or retire expressions #20943 / PR feat(spec)!: an analytics cube member's sql is a column reference, and the showcase done rate moves to its dataset (#20943) #20998.
  5. min / max over a relationship-path column (account.revenue). sourceFieldMeta reads getObject(object).fields[field], so a dotted field answers undefined, the value is left as the client gave it, and buildFieldMeta still declares number: the same declared-versus-answered shape the seat filed as analytics: a config cube min/max over a text column is served at the cube door under fields[] type number; the dataset door refuses the pair by the compatibility table, the cube door consults nothing #21044 for a text column. An unmeasured source inference, consistent with the ruling's keying through ctx.declaredFieldType. Escalated to the seat: fold into analytics: a config cube min/max over a text column is served at the cube door under fields[] type number; the dataset door refuses the pair by the compatibility table, the cube door consults nothing #21044's family or file a sibling; not this card's.
  6. analytics: the native-SQL strategy skips the engine aggregate policies — SUM/AVG exact decimal on PostgreSQL (not #20387's double), and an all-NULL group sums to null where the ObjectQL face folds it to 0 (#15546) #21042 (accumulation divergence; all-NULL sum answering null on the native face against 0 elsewhere) and analytics: a config cube min/max over a text column is served at the cube door under fields[] type number; the dataset door refuses the pair by the compatibility table, the cube door consults nothing #21044 (cube door min / max over text under fields[] number) are filed by the seat and out of this card.
  7. Deviations accepted in 5924060992 (the import as a separate statement at line 27; a two-line comment above the one core export; the REST pin driving the dataset route while the cube route was read once through the real dispatcher mount and is pinned at service level): verified harmless and collision-free (①7).
  8. CI runs no PostgreSQL cell for service-analytics or rest (OS_TEST_POSTGRES_URL unprovisioned, named skips). The live PostgreSQL evidence for this card is the dev's local red-first run and ablation, a claim; what CI holds is the core presenter pin and every SQLite cell. A pre-existing provisioning gap, not a defect of this diff; noted for the seat.

Implemented-by: claude/issue-20889-native-measure-number
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 03:37
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 03:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit d1633f3 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20889-native-measure-number branch October 1, 2026 04:52
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/xl tests tooling

Projects

None yet

2 participants