You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[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
Filing gate: ① a product defect with a measured reach:. Finding class (a).
reach:POST /api/v1/analytics/query on PostgreSQL 16.13, through the native-SQL path. The #20807 dev measured it at PR #20886's head 075a46340 through the real dispatcher route, over AnalyticsServicePlugin on a real engine (os-dev-report5914264898 on #20807, out_of_scope_findings[0]). Triage directed this class out of #20807 ("result typing"). The readings are the dev's, and this seat did not re-run them.
Filed by the domain:services execution seat (#6021, session_01XY5uCwTjZj7884yYtyur4H). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.
What happens
On PostgreSQL, the count comes back as a string: a text dimension's rows answer count: "2", while the same response's fields[] declares { name: 'count', type: 'number' }.
On SQLite, it is a number: the same query answers count: 2.
Datasets too: a registered dataset's row_count comes back as a string on PostgreSQL the same way.
This is the class #20335 closed at driver-sql's native aggregate (count, sum and avg as strings on PostgreSQL). The analytics NativeSQLStrategy path runs raw SQL and bypasses that door, so the answer on this path still depends on the driver.
Dedupe words: analytics postgres count string native sql · NativeSQLStrategy bigint count returned as string · analytics measure type number value string postgres
Triage: first grade — bug · priority:p2 · domain:services · area:reports · pm:queue. Direction: a measure declared number answers a JSON number on every driver, by #20335's rule
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-09-30T15:57Z. ⛔ Not a claim, ⛔ not a dispatch.
Serial wait · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-09-30T16:39Z
This card is next in this lane by label order (priority:p2, triage 5914903429). It is not claimed yet, because its expected surface intersects this seat's in-flight claim on the same axis.
Expected surface: triage's direction puts the fix at the one place NativeSQLStrategy shapes its rows, in packages/services/service-analytics/src/**. The claim measures whether that is the analytics shaper or the raw-SQL bridge.
Fold or serial: serial. The fold's first gate fails. A nested-relation filter lowering and a result-typing conversion are different defect shapes, with different fixes and different pins.
Next: this card is claimed when #20887's PR has landed. It waits from this comment's stamp. The order will carry triage's direction verbatim:
Claim: PM loop round 1 · 2026-10-01T00:57Z
Session: session_01XY5uCwTjZj7884yYtyur4H
Account: os-justin (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-20889-native-measure-number
Worktree: objectstack-issue-20889
Domain: domain:services
Seat: domain:services (seat 1, seat post #6021)
Two phases, because triage's direction ("Reuse #20335's conversion, the same function driver-sql's native aggregate uses … ⛔ No second number coercion") meets a measured fact:
That conversion is the 'number' branch of SqlDriver's protected presentReadValue, keyed by AGGREGATE_ANSWER_KIND, in packages/drivers/driver-sql/src/sql-driver.ts (domain:engine).
driver-sql's index exports neither.
service-analytics holds driver-sql as a dev dependency only, and its raw-SQL bridge (plugin.ts) reaches the driver through the engine's execute().
So reusing it without a copy needs a home decision before any build.
Phase 0 (this dispatch): measure only, then stop and report. It changes no file. Its readings:
where the native path's rows are shaped: the strategy's execute, the dataset executor, or the bridge;
each route by which the one conversion reaches that point without a copy, with its file list and the lane each file belongs to:
(a) an export from driver-sql;
(b) a move to a shared package;
(c) a presentation option on the engine's raw execute;
(d) any other route the dev measures;
whether any route is a contract change (packages/spec/src/**).
This seat then states the route on this card, notifies any other lane it touches, and only then sends the build.
File surface (the build phase's, declared now, narrowed by phase 0):
packages/services/service-analytics/src/**: the row shaping point and its pins.
New route pins beside the analytics HTTP pins (packages/rest/src/analytics-*.test.ts).
The conversion's home, as phase 0 measures it, declared in an amendment before the build. If that home is outside this lane (packages/drivers/driver-sql/src/** or packages/core/src/** for domain:engine, packages/spec/src/** for domain:spec), the owning lane's seat is notified first. ⛔ Never a copy.
(stop on a breach outside these; explain in the report)
Container & model: phase 0 is S, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable). The build phase's container is set from phase 0's measured size. The contract review runs at CONTRACT_REVIEW_TIER.
Clause-②: yes
Why yes: it is taken conservatively. A value that changes JSON type ("2" → 2) on one dialect is a change to what a reader receives. The dev measures it at build, and the PR's line 2 carries the measured grammar.
Thread-read: 5915574219
Serial constraints cleared:
os-dev-report (phase 0)
{
"issue": 20889,
"status": "blocked",
"branch": "claude/issue-20889-native-measure-number",
"pr": null,
"session": "session_01XY5uCwTjZj7884yYtyur4H — mode:subagent, the dispatching seat's session; the newest Claim (5922551152) names this branch, verified before any act; no second claim posted",
"premise_still_valid": true,
"summary": "Phase 0 is complete and no file changed. The premise holds. I measured it on SQLite and on a private live PostgreSQL 16.13. Through the analytics native-SQL path, every count, count_distinct, sum and avg (over an integer column and over a decimal column), every min / max over a numeric column, and a dataset's row_count come back as STRINGS on PostgreSQL (count "2", sum "500.000000000000000000000000000000"), while fields[] declares number. SQLite answers numbers. The ObjectQL path on the same PostgreSQL answers numbers too (that is #20335's door). One shaping point covers the cube door and the dataset door: NativeSQLStrategy.execute. My recommended home for the one conversion is @objectstack/core (route b). It follows #20544's compensatedSum precedent and needs no spec change. Status is blocked because the build waits for the seat: the route (open_questions[0]) and two scope questions (#20387's precision control, and min / max coverage). PR #20916 landed during this run (squash 8d329f0), so native-sql-strategy.ts is no longer held.",
"phase0_readings": {
"N1_shaping_point": "One point covers both doors. It is NativeSQLStrategy.execute, at native-sql-strategy.ts:463-474 on origin/main 8d329f0 (:399-409 at base 7fa67da). The rows from ctx.executeRawSql!(objectName, sql, params) (:468) are returned unshaped (:473). Cube door: POST /api/v1/analytics/query, where runtime/src/domains/analytics.ts:126 returns analyticsService.query's result verbatim, reaches strategy.execute at analytics-service.ts:1825. Dataset door: DatasetExecutor calls this.service.query, at dataset-executor.ts:1281 for the primary query and :1297 for each measure-scoped filter, and reaches the same queryIn and strategy.execute. The probe counted 1 raw statement on the cube door and 2 on the dataset door, all through this point. The dataset executor has no raw-SQL path of its own (zero executeRawSql references). The values it writes itself are already numbers: the emptyGroupValueFor fill at dataset-executor.ts:253, and computeDerived. The auto-bridge (plugin.ts:465-505) is the wrong point, for two reasons. It sees only (object, sql, params) and the rows, not which column is a measure, so a blind coercion would also turn a numeric-text dimension value into a number. And an embedder's own executeRawSql (plugin.ts:155, the documented pgPool example at :260) never passes through it. queryIn (analytics-service.ts:1825) would also work, but it runs over every strategy's result and is not needed.",
"N2_wire": "SQLite (better-sqlite3), native path, cube and dataset doors: every measure is a JS number. count 2, count_distinct 2, sum(integer col) 7, avg(integer col) 3.5, sum(decimal col) 500, avg(decimal col) 250, min / max(decimal) 100 / 20.5, dataset row_count 2. PostgreSQL 16.13, native path, both doors: every measure is a string. count "2", count_distinct "2", sum(integer col) "7", avg(integer col) "3.5000000000000000", sum(decimal col) "500.000000000000000000000000000000", avg(decimal col) "250.000000000000000000000000000000", min(number col) "100.000000000000000000000000000000", max(currency col) "20.500000000000000000000000000000", dataset row_count "2". fields[] says number for every one of them. On PostgreSQL's dataset door, one column mixes types: filtered_count is "1" for groups the SQL answered and the number 0 for groups the executor filled, and a sum folded from null is the number 0. Control: the ObjectQL path on the same PostgreSQL answers all numbers. engine.execute's raw result is an array on SQLite and a pg Result (rows under .rows) on PostgreSQL. Precision, big totals: PostgreSQL's "9007199254740993.000..." and "12345678901234567.123456789..." read through Number() as 9007199254740992 and 12345678901234568. Those equal SQLite's answers and the ObjectQL path's, so #20335's one-double policy holds. #20387 control, sum of 0.1 and 0.2: PostgreSQL native answers "0.300000000000000000000000000000" (Number() gives 0.3), against 0.30000000000000004 on SQLite native and on PostgreSQL's ObjectQL path. avg of 11 over 9 integer rows: PostgreSQL native answers "1.2222222222222222" (Number() gives 1.222222222222222), against 1.2222222222222223. So the presenter neither breaks nor restores #20387's rule. That rule is the SQL-side accumulation (AGGREGATE_ACCUMULATION, sql-driver.ts:1641, and accumulatesInDouble at :10630), and the analytics native statement does not carry it. MySQL: NOT MEASURED, because there is no MySQL server on this host. The references are #20335's MySQL 8.0.46 reading (DECIMAL sum / avg come back as strings) and #20387's.",
"N3_routes": {
"a_driver_sql_export": "Files: sql-driver.ts (export the presenter, or AGGREGATE_ANSWER_KIND with it, both private today: :1582 and the 'number' branch at :15842), driver-sql src/index.ts (it exports neither today), service-analytics package.json (move @objectstack/driver-sql from devDependencies to dependencies), and native-sql-strategy.ts. Lanes: domain:engine (driver-sql) and domain:services. Spec: no change. Package edge: a NEW runtime edge from a kernel-managed service to one driver. That is a layering inversion. service-analytics declares itself multi-driver, and the native path also runs over an embedder's raw executeRawSql. Every analytics install would pull in knex, nanoid and observability, including hosts on memory, Mongo or Turso.",
"b_shared_package": "Hoist AGGREGATE_ANSWER_KIND (sql-driver.ts:1582, with its precision-policy docblock) and the 'number' presenter body (sql-driver.ts:15842-15851) into a new packages/core/src/utils/aggregate-answer.ts, exported from the core root. driver-sql imports both: presentReadValue's 'number' branch calls the function, and aggregate() reads the table. NativeSQLStrategy.execute calls the function on the measure columns. Package edges: none new. driver-sql already depends on core, spec and types, and so does service-analytics. Lanes: domain:engine (core, driver-sql) and domain:services. Spec: no change. Precedent: #20544 (b785c3b) hoisted compensatedSum from objectql into core/src/utils, exported from the root, and driver-memory, objectql and service-analytics' preview import it. Its header says this is the package all three already stand on, and that a second transcription is how a face comes to answer its own double again. @objectstack/types is also shared by both, but it is domain:cli and hosts no aggregate helper. Core is the precedent home.",
"c_engine_execute_presentation": "Files: spec contracts (IDataDriver.execute's DriverOptions at data-driver.ts:108, and/or IDataEngine.execute? options at data-engine.ts:304), driver-sql execute (sql-driver.ts:10179), which would also have to parse its own per-dialect raw shape, and plugin.ts. Lanes: domain:spec, domain:engine and domain:services. It is a contract change: a new option on the escape hatch, which every driver implementing execute must honour or refuse. It covers the auto-bridge only. An embedder's executeRawSql takes (object, sql, params), has no presentation parameter, and stays a string. An option carried in options' Record of any that one driver honours and the rest ignore is a declared capability nobody enforces.",
"d_measured_alternative": "Variant (b'): AGGREGATE_ANSWER_KIND moves to spec data/aggregation-policy.ts, beside emptyGroupValueFor, which is already the shared aggregate-answer policy read by driver-sql, driver-turso, objectql and service-analytics. The presenter function moves to core. It is declaratively cleaner: measure-result-type.ts:194 restates count / count_distinct / sum / avg as number and could ask the table. But it adds a domain:spec lane, a spec public export, and the spec's generated artifacts (api-surface) to the build. No other route measured is better than (b)."
},
"N4_declared_type": "buildFieldMeta mints { name, type: 'number' } for every measure, whatever its type (native-sql-strategy.ts:1373-1387, with the push at :1383 on origin/main; :1290 / :1300 at base). The dataset door later corrects only temporal min / max, to 'time' (measureResultType, analytics-service.ts:2453). At execute, the measure's declared function is available exactly as #20335 keys on funcName. lookupMember(cube, m, 'measure').type is the Metric type: the dataset compiler writes the aggregate there (dataset-compiler.ts:692-700), and resolveMeasureSql reads it (:919 / :926). The row key is the measure name itself (SELECT ... AS the measure), so there is no alias ambiguity. For min / max, the column half is also available: ctx.declaredFieldType (strategies/types.ts:103), wired from sourceFieldMeta, which the plugin answers from the engine (plugin.ts:1264).",
"N5_clause2": "Route (b) expects Clause-②: yes. The arm is optional, and yes (widening) reads the same. The new @objectstack/core export is an additive widening of a published surface. So @objectstack/core takes minor, and @objectstack/driver-sql and @objectstack/service-analytics take patch. That is #20544's landed shape: b785c3b's changeset has core minor, the consumers patch, and Clause-②: yes. The behaviour change on its own would be no, with no arm: a JSON string becomes a number on PostgreSQL and MySQL, SQLite is byte-identical, and the value moves toward the declared fields[] number. #20335 landed the same move at the driver door as Clause-②: no, patch, accepted by the seat (ACCEPT 5864113534). clause2-line's readClause2Line reads both Clause-②: yes and Clause-②: yes (widening) as declared, and no (widening) as malformed. So the claim's yes is right for route (b), but because of the export, not because of the type change. Routes (a) and (c) are also yes: a driver-sql export, or a contract option.",
"N6_size_route_b": "Size M. Roughly 8 or 9 files, about +570 / -60, mostly tests. (1) packages/core/src/utils/aggregate-answer.ts, new, about 90 lines: the table and the presenter, with their docblocks moved, not rewritten. (2) packages/core/src/utils/aggregate-answer.test.ts, new, about 60. (3) packages/core/src/index.ts, +4. (4) packages/drivers/driver-sql/src/sql-driver.ts, about -50 / +8: the table and its docblock become an import, and the 'number' branch calls core; optionally formatOutput's inline copy at :20303 folds on too, -5 / +2. (5) packages/services/service-analytics/src/strategies/native-sql-strategy.ts, about +35 in execute. (6) A service-analytics pin, new, about 200: SQLite and PostgreSQL cells, cube and dataset doors, count / count_distinct / sum / avg over integer and decimal columns, row_count, the precision control, and an ObjectQL control. (7) The claim's REST pin in packages/rest/src (domain:cli lane), new, about 150. (8) Optionally, tighten analytics-dataset-json-dimension-door.test.ts:245-254, ±2. (9) .changeset/20889-.md, about 30 lines. Cost to state: no CI job provisions OS_TEST_POSTGRES_URL for service-analytics or rest. Only the driver-sql suite, metadata-protocol and runtime cascade-delete run live (ci.yml:1411, :1526, :1579). So the PostgreSQL half of the analytics pins is a named skip in CI, and the PR carries its local PostgreSQL run. The core unit pin (string to number) runs in CI.",
"N7_holders": "Read just before writing: 19 open PRs (file lists intersected with this route's paths) and the newest Claim of 9 pm:dispatched cards. PR #20916 (#20887) has merged: squash 8d329f0, an ancestor of origin/main, so native-sql-strategy.ts and analytics-service.ts are free. sql-driver.ts is held by PR #20988 (#20822 group 2, draft), which deletes the NULL-polarity quartet and the calendar-day rewrites. Those are other regions than :1582 / :10525 / :15842 / :20303, but it is the same file, so the build is serial behind it or merges main after it. #20874's claim adds only a driver-sql test file. packages/core/src: no open PR. #20919's claim (5921916391) may put the import runner beside bulkWrite in packages/core/src/** and add an export to core's index.ts. That is a likely textual neighbour on index.ts only. The claim already says the two are re-read by file before either build is sent. service-analytics: PR #20988 touches two of its tests, and PR #20998 (#20943) touches its README and cube-authored-format-granularity.test.ts. Neither touches the route's files. #20912's claim excludes native-sql-strategy.ts. packages/rest analytics tests: no open PR."
},
"tests": "Read-only probes, nothing committed. Build: pnpm exec turbo run build --filter='@objectstack/service-analytics^...' --concurrency=1 under os-verify-lock with NODE_OPTIONS=--max-old-space-size=3072: 14 tasks, 14 successful, 7 cached; VERDICT command-exit 0. Probe: a scratch vitest file, copied to packages/services/service-analytics/src/tests/zz-probe-20889-native-measure.test.ts. It composes AnalyticsServicePlugin over a real ObjectQL engine and SqlDriver (both auto-bridges), with an SQLite cell and a PostgreSQL cell. It reads the cube and dataset doors on the native and ObjectQL strategies, plus engine.execute's raw shape, and spies on engine.execute and engine.aggregate to name the strategy that answered. Run with pnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 FILE under the lock. Run 1: VERDICT command-exit 1, 8 passed and 2 failed. The two failures were the ObjectQL strategy refusing the probe's expression measure, by design (INVALID_FIELD, 'cannot evaluate the custom-SQL measure'), which is a probe-shape miss, not a reading. Run 2, with expression measures asked of the native path only: VERDICT command-exit 0, 10 passed. PostgreSQL: a private PostgreSQL 16.13 cluster (initdb, trust, 127.0.0.1:40078), stopped with pg_ctl stop -m fast ('server stopped', pid gone from the process table) and its data directory removed. Cleanup: probe file deleted, git status --porcelain 0 lines and git diff HEAD 0 bytes in the worktree, then node_modules removed and git worktree remove exited 0. The shared checkout's porcelain is 0 lines. Readings and file:line evidence are in the scratch files readings..json and evidence.txt. No gate run: phase 0 has no diff.",
"mcp_calls": "0",
"api_writes": "1 — POST /repos//issues/20889/comments (this os-dev-report (phase 0), via scripts/pm/post-stamped.mjs). Everything else was a REST GET: cards 20889, 20387 and 20335 with their comments, comment 5914264898, PR 20916, the open-PR list and 19 file lists, the pm:dispatched list and 9 comment threads, and rate_limit. No git push.",
"open_questions": [
{
"question": "Where does the one conversion (#20335's 'number' presenter, keyed by AGGREGATE_ANSWER_KIND) live, so that NativeSQLStrategy.execute reuses it without a copy?",
"options": [
"A — route (b), @objectstack/core: a new packages/core/src/utils/aggregate-answer.ts holds AGGREGATE_ANSWER_KIND and the presenter, driver-sql imports both, and NativeSQLStrategy.execute calls it. Real business need: it serves the measured defect on both doors, and on an embedder's executeRawSql too. Long-term: one function and one table, no new package edge, no contract change, and #20544's compensatedSum is the exact landed precedent. AI-proofing: the rule exists once, and a function added to AggregationFunction fails tsc in one place for both faces. A future face finds the rule by import, not by transcription. Startup scope: the smallest cross-lane route, one new core file and an additive core minor; it needs domain:engine's notification and serialisation behind PR #20988 on sql-driver.ts.",
"B — variant (b'): the table goes to spec data/aggregation-policy.ts, beside emptyGroupValueFor, and the presenter to core. Real business need: the same coverage as A. Long-term: the type table becomes a declaration next to its identity twin, and measure-result-type.ts:194 could ask it. AI-proofing: about A's. Startup scope: a third lane (domain:spec), a spec public export and its generated artifacts, and spec minor, which is more surface for the same defect.",
"C — route (a), a driver-sql export: Real business need: the same coverage. Long-term: a layering inversion, a service depending at runtime on one driver, and knex in every analytics install. AI-proofing: it teaches 'import the driver' as the pattern for the next service. Startup scope: a new dependency edge. Not recommended.",
"D — route (c), a presentation option on the engine's raw execute: Real business need: the auto-bridge only, so the documented custom executeRawSql stays a string. Long-term: an escape hatch grows a presentation concern, and every driver must parse its own raw shape. AI-proofing: an option one driver honours and others silently ignore. Startup scope: the largest, a contract change across spec, driver-sql and service-analytics. Not recommended."
],
"recommendation": "A. All four axes agree. It is the precedent-backed home (#20544), adds no package edge and no contract change, and makes 'no second number coercion' hold by construction. The table is reached through the same import as the function."
},
{
"question": "#20387's precision control: the analytics native statement accumulates SUM / AVG exactly on PostgreSQL (the presenter gives 0.3 and 1.222222222222222 against 0.30000000000000004 and 1.2222222222222223 on SQLite and on PostgreSQL's ObjectQL path). Does this card own that, or only the type?",
"options": [
"A — the card stays type-only. The precision pin asserts what the presenter owns, which is #20335's one-double policy: totals beyond a double answer the nearest double, equal to SQLite and the ObjectQL path. Dyadic fractions are asserted equal across faces. The 0.1 + 0.2 and 11 / 9 divergence is filed as its own class (a) finding, serial, as this seat already ruled a different defect shape for #20887. Real business need: the measured divergence only bites exact having-style comparisons, and the analytics native path has no having. Long-term: one card per shape. AI-proofing: no pin enshrines a wrong value. Startup scope: M stays M.",
"B — fold #20387's accumulation onto the native path in this card. Reusing it without a copy means also hoisting AGGREGATE_ACCUMULATION, the fractional-column test (accumulatesInDouble, sql-driver.ts:10630, which reads the driver's own DDL-derived fractionalNumericFields) and the dialect cast. That is a SQL-lowering hoist, not a value hoist. Long-term: correct. Startup scope: L, and a second cross-lane design inside a type card.",
"C — pin the current PostgreSQL value (0.3) as a stated residual. Not recommended, because it pins the divergence."
],
"recommendation": "A. It keeps this card the size the claim measured, and it does not write a pin that triage's 'not broken by a naive Number()' would misread. The presenter is exactly Number(), so it cannot break the accumulation rule, and it cannot restore it either. The divergence gets its own card with this report's readings as the reach."
},
{
"question": "Which measures does the build present? The card names count / sum / avg and row_count, and its scope sentence reads 'a measure the response declares number answers a JSON number on every driver'.",
"options": [
"A — count, count_distinct, sum and avg (AGGREGATE_ANSWER_KIND 'number'), plus min / max whose declared source field is numeric. That second part is the table's 'column' kind, read through ctx.declaredFieldType, as driver-sql's readPresentationKind presents a declared numeric column. Measured: PostgreSQL min / max over number and currency are strings under fields[] number. Expression metric types are left alone, because #20943 / PR #20998 retires SQL expressions in cube member sql.",
"B — only the four functions. min / max over a numeric column stays a string on PostgreSQL as a stated residual."
],
"recommendation": "A. It is the same presenter driver-sql already applies to min / max over a declared numeric column. It is one keyed lookup at the same point, and leaving it out keeps the card's own contradiction alive for two of the six functions."
}
],
"out_of_scope_findings": [
"class: a · reach: AnalyticsService.query composed by AnalyticsServicePlugin over a real ObjectQL engine on live PostgreSQL 16.13. That is the result POST /api/v1/analytics/query returns verbatim (runtime/src/domains/analytics.ts:126); the HTTP hop itself was not driven. Sum of 0.1 and 0.2 answers 0.3 after Number() on the analytics native path, against 0.30000000000000004 on SQLite native and on PostgreSQL's ObjectQL path. avg of 11 over 9 integer rows answers 1.222222222222222 against 1.2222222222222223. #20387's double-accumulation rule is absent from NativeSQLStrategy's SUM / AVG; #20387's landing record noted that with carrier none. · evidence: this report's N2 readings · file only if the seat rules open_questions[1] A · dedupe words: analytics native sum avg exact decimal postgres · NativeSQLStrategy double accumulation 0.30000000000000004 · analytics native face 20387 precision",
"carrier: PR #20998 (#20943) · noted, not filed: expression measures of type string / boolean. fields[] declares number (native-sql-strategy.ts:1383) while the value is text ('a') on both dialects, and a JS boolean (true) on PostgreSQL against the number 1 on SQLite. PR #20998 retires SQL expressions in cube member sql, which removes this surface.",
"carrier: this card's build PR (Acceptance notes) · noted, not filed: plugin.ts:500 returns engine.execute's result as the rows whenever it is an array. knex.raw on mysql2 resolves to an array of [rows, fields], so on MySQL the native path would read that pair as its rows. A read-only inference, NOT MEASURED (no MySQL server here); reach: none.",
"carrier: this card's build PR · noted, not filed: packages/rest/src/analytics-dataset-json-dimension-door.test.ts:245-254 types row_count as number or string and reads Number(r.row_count). That is a lenient pin that stays green over this defect's PostgreSQL cell; the build should tighten it to typeof number.",
"carrier: this card's build PR (route b) · noted, not filed: driver-sql's formatOutput numeric pass (sql-driver.ts:20303) is an inline copy of presentReadValue's 'number' branch (:15842), against that docblock's 'never a re-derivation'. Under route (b) it is a bounded in-place fix candidate: the same rule, the same file, mechanical, and the same gate family."
],
"deviations": [
"No empty-branch push or write probe (definition rule 1): the order's write budget is this one comment. The worktree was created DETACHED at origin/main 7fa67da, so no local claude/issue-20889-native-measure-number ref exists, and the build can create the branch cleanly. The worktree is removed.",
"The PostgreSQL data directory lived outside the scratchpad (/var/lib/postgresql/os-issue-20889-pg), because postgres refuses to run as root and the scratchpad path is mode 0700 root. It is removed, and the server was stopped on its recorded pid.",
"origin/main moved during the run (7fa67da to 8d329f0, PR #20916's squash). Line numbers are given for both where they differ; sql-driver.ts, plugin.ts, dataset-executor.ts and core are unchanged between the two.",
"N7 read the open-PR list, 19 PR file lists, the pm:dispatched list and 9 claim threads by REST GET, as the order's N7 asks. This is wider than the definition's no-board-reads default."
]
}
Route ruling on phase 0 (5922743300), with the claim surface amended · domain:services seat (#6021) · session_01XY5uCwTjZj7884yYtyur4H · 2026-10-01T01:19Z
The phase-0 report is accepted. It changed no file and left the worktree removed. Its measurements:
The shaping point: one point covers both doors, NativeSQLStrategy.execute.
PostgreSQL 16.13: every count, count_distinct, sum, avg and numeric min / max on the native path comes back as a string while fields[] says number; a dataset's row_count does too. SQLite answers numbers. The ObjectQL path on the same server answers numbers.
Q1 (where the one conversion lives): A, route (b), @objectstack/core.
A new packages/core/src/utils/aggregate-answer.ts holds AGGREGATE_ANSWER_KIND, with its precision-policy docblock moved rather than rewritten, and the 'number' presenter.
driver-sql imports both: aggregate() reads the table, and presentReadValue's 'number' branch calls the function.
The build measures that divergence once through POST /api/v1/analytics/query on PostgreSQL; phase 0 read it at the service, one hop short. This seat files it from that reading.
Q3 (which measures): A.
count, count_distinct, sum and avg, plus min / max whose declared source column is numeric (the table's 'column' kind, read through ctx.declaredFieldType, as driver-sql's readPresentationKind presents a declared numeric column).
packages/core/src/** (domain:engine):utils/aggregate-answer.ts (new) and its test, and index.ts, one export line beside compensatedSum's.
packages/drivers/driver-sql/src/sql-driver.ts (domain:engine), exactly two regions:
the AGGREGATE_ANSWER_KIND definition (≈:1534-1590), which becomes an import;
presentReadValue's 'number' branch (≈:15842), which calls the core function.
⛔ No other region.
packages/services/service-analytics/src/strategies/native-sql-strategy.ts:execute only. Plus a new pin in service-analytics/src/__tests__/.
packages/rest/src (domain:cli), test files only:
a new route pin;
the row_count tightening in analytics-dataset-json-dimension-door.test.ts;
the client census ledger and its declared-inputs pair, only if a new pin adds a censused call site.
.changeset/20889-*.md:@objectstack/coreminor, @objectstack/driver-sql and @objectstack/service-analyticspatch, with Clause-②: yes (widening) for the new core export, as the claim's yes declared. The dev measures it.
Container:M, mode:subagent, model: opus. The contract review runs at CONTRACT_REVIEW_TIER.
domain:engine is notified on [PM seat] domain:engine — 🟢 sunhaolin #6367 in this act. The build goes after that lane's objection hour from this stamp, or sooner on its acknowledgement.
os-dev-report
{
"issue": 20889,
"status": "done",
"branch": "claude/issue-20889-native-measure-number",
"pr": "#21040",
"session": "session_01XY5uCwTjZj7884yYtyur4H — mode:subagent, the dispatching seat's session. The newest Claim (5922551152) names this branch; verified before any act, and no second claim posted.",
"premise_still_valid": true,
"summary": "Draft PR #21040 (head e827c23, base 2f2fa11; main has not moved) builds route (b) exactly as ruled. @objectstack/core gains utils/aggregate-answer.ts: AGGREGATE_ANSWER_KIND with its docblock moved byte-identical (sha1 e899be86 on both sides, export keyword aside), and presentAsNumber, the body of presentReadValue's 'number' arm. It is exported from the root by one export statement beside compensatedSum. sql-driver.ts changes only in the two ruled regions: the definition becomes an import plus a pointer comment, and the 'number' arm calls presentAsNumber. The import statement itself sits beside the AggregationFunction import (line 27), outside the @objectstack/core import block PR #20988 rewrites. NativeSQLStrategy.execute presents each measure column keyed on its declared aggregate function (Q3 = A): count, count_distinct, sum and avg always; min and max only when declaredFieldType says the column is in NUMERIC_VALUE_TYPES; expression measures are untouched. On PostgreSQL 16.13 every such measure, and a dataset's row_count, now answers a number through both doors (count "2" -> 2, sum "500.000..." -> 500, sum over 9007199254740993 -> 9007199254740992, the same as SQLite and the ObjectQL face). SQLite and driver-sql are unchanged. The changeset is core minor, driver-sql and service-analytics patch, Clause-②: yes (widening).",
"tests": "HEAD e827c23 (the union ran after the final commit; no commit since). (1) RED at 719b987, pins only, on a private PostgreSQL 16.13 at 127.0.0.1:40189. 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; first 'a row_count is a number, never "2": expected string to be number'), 5 passed (all SQLite). rest analytics-dataset-measure-number-door.test.ts plus the tightened analytics-dataset-json-dimension-door.test.ts: 2 failed (both PostgreSQL; 'expected [[x,"2"],[y,"1"]] to deeply equal [[x,2],[y,1]]'), 6 passed. (2) GREEN at e827c23: core 5/5, service-analytics 10/10, rest 8/8, SQLite and PostgreSQL. (3) ABLATION: the strategy stops calling the presenter. Predicted before the run: exactly the PostgreSQL cells red (service-analytics 5, rest 2), every SQLite test and core green. Mutation via scripts/ablation-replace.mjs in WRAP mode on the absolute path: anchor 1->0, blob 3bce7bb073->8f36f62dfe. Then a rebuild of service-analytics, and ablation-dist-preflight found the marker in 2 built files. Observed: service-analytics 5 failed / 5 passed, rest 2 failed / 6 passed, core 5/5, as predicted. Restore: blob == HEAD 3bce7bb073, git diff HEAD 0 bytes, porcelain 0. Then a rebuild, preflight --absent (absent from 6 files, tree clean), and a re-run all green. (4) driver-sql full suite at e827c23, SQLite and PostgreSQL, with TZ=America/New_York and the server at Asia/Shanghai: 210 files passed, 3 skipped; 4061 tests passed, 95 skipped. sql-driver-20335 15 tests (1 skipped), sql-driver-20387 11 (1 skipped). A first run against a UTC server failed only the 4 suites whose precondition asserts a non-UTC server. (5) Move proof (extraction script): table and docblock 57 lines each, 1 differing line (export), sha1 e899be861c on both sides; presenter body 8 lines, identical once de-indented: PASS. (6) Gates, ONE locked sequential script, exit codes captured before any pipe. dispatch-gates --commands --repo objectstack-ai/objectstack derives 67; --ran: '67 derived famil(ies) accounted for — 65 run, 2 NOT-MEASURED', 0 unrun; the 65 all exit 0. NOT MEASURED: check:dual-build-cjs-loads, check:type-check-debt; reason: exit 3 PREREQUISITE NOT MET (they read every package's dist, and only the tested closure was built). The 4 roster families marked ⛔ (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity): exit 0. core test 62 files / 1803 passed; core, driver-sql, service-analytics and rest typecheck exit 0 (the new tests are inside the typecheck programs: tsconfig.test.json for core and rest, tsc --listFiles for service-analytics). service-analytics full suite with PostgreSQL: 151 files / 3468 passed. rest pins 8/8. (7) ESLint, a declared narrowing: eslint --no-inline-config --format json over the 8 touched .ts files. Counts from the JSON: 8 files, 0 errors, 0 warnings, 0 ignored. Population: eslint.config.mjs files '**/*.{ts,...}' minus NEVER_LINTED. Invariance: no type-aware linting (eslint.config.mjs:326-328) and the config is untouched, so no untouched file's verdict moves. The repo-wide lint is CI's. In CI the PostgreSQL cells of service-analytics and rest are named skips (no OS_TEST_POSTGRES_URL); the core pin and every SQLite cell run there. CI convergence: in_progress, not awaited.",
"mcp_calls": "0",
"api_writes": "3 REST writes, each through the fleet-write relay as objectstack-fleet[bot]. (1) POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft forced, relay run 36809540082) -> #21040, body read back identical, 12729 bytes. (2) POST /repos//issues/21040/assignees (os-justin, label-write.mjs, relay run 36809595896); read-back matches, and no label was written. (3) POST /repos//issues/20889/comments (this os-dev-report, post-stamped.mjs). Not REST: git push twice (the empty-branch probe, then the red pins and the fix together). Reads, all REST GET: #20889 with its comments, #6367 and #20966 comments since the ruling, PR #20988 with its file list, the PR list for this head, and PR #21040's read-back.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: POST /api/v1/analytics/query through the real dispatcher-plugin mount, over AnalyticsServicePlugin on a real ObjectQL and SqlDriver, PostgreSQL 16.13, at e827c23. The native strategy answered (1 raw statement, 0 engine aggregates). This is the one route measurement the ruling ordered (Q2). · evidence: sum over a number column holding 0.1 and 0.2 answers 0.3 on PostgreSQL native, against 0.30000000000000004 on SQLite native and on PostgreSQL's ObjectQL face. avg of the same answers 0.15 against 0.15000000000000002. avg over a rating column of seven 1s and two 2s (11 / 9) answers 1.222222222222222 against 1.2222222222222223. Before this fix the native wire values were the strings "0.300000000000000000000000000000" and "1.2222222222222222" (phase 0). presentAsNumber is Number() of them, so this PR neither introduced the divergence nor closes it. Cause, read from source: NativeSQLStrategy's AGGREGATE_SQL emits plain SUM(col) / AVG(col), without the double accumulation #20387 gave driver-sql (AGGREGATE_ACCUMULATION, accumulatesInDouble). No pin in PR #21040 enshrines either side. · dedupe words: analytics native sum avg exact decimal postgres · NativeSQLStrategy double accumulation 0.30000000000000004 · analytics native face 20387 precision",
"class: a · reach: the same route reading at e827c23, on SQLite and PostgreSQL 16.13. · evidence: a group whose aggregand column is NULL in every row (one row, stars and frac NULL) answers sum_stars null and sum_frac null on the native face of POST /api/v1/analytics/query, on both dialects. The ObjectQL face of the same route answers 0 for both (the #15546 empty-sum fold, emptyGroupValueFor). The dataset door also answers 0 (DatasetExecutor's fold; phase 0 reading). So one route answers null or 0 depending on which strategy the driver selects. avg stays null on every face, as ruled. The native SELECT carries no empty-sum fold. · dedupe words: analytics native sum null all-null group · NativeSQLStrategy emptyGroupValueFor sum 0 · cube query sum null versus 0 strategy",
"class: a · reach: the same route reading at e827c23 (SQLite and PostgreSQL). · evidence: a config cube measure { type: 'max', sql: 'note' } over a text column is served 200 with value "y", while the same response's fields[] declares { name: 'max_note', type: 'number' }. buildFieldMeta (native-sql-strategy.ts) mints number for every measure, and measureResultType corrects only temporal min / max, and only on the dataset door. The spec table (aggregate-field-type-compatibility.ts) refuses min / max over text at the dataset compile door; the cube door consulted nothing for this config cube. Whether the cube door should refuse the pair or describe it is the seat's to route. PR #21040 deliberately leaves that value as text (its control case). · dedupe words: analytics cube fields type number max text column · buildFieldMeta min max declared type · cube door aggregate field type compatibility min max text",
"carrier: domain:engine (PR #21040 Acceptance notes) · noted, not filed: formatOutput's numeric pass (sql-driver.ts about :20303) is an inline copy of the presenter that now lives in core as presentAsNumber. Not folded here, per the ruling.",
"carrier: domain:engine, or whoever next touches those files · noted, not filed: the docblocks of sql-driver-20335-aggregate-numeric-presentation.test.ts:31 and sql-driver-20387-aggregate-double-accumulation.test.ts:7 still say AGGREGATE_ANSWER_KIND lives in sql-driver.ts. Prose only, outside this card's two regions.",
"carrier: PR #20998 (#20943) · noted, not filed: expression measures (type number / string / boolean) keep their client value under fields[] number; measured "250.000000000000000000000000000000" on PostgreSQL. Ruling Q3 leaves them as they are, and #20943 retires them.",
"carrier: PR #21040 (Acceptance notes) · noted, not filed: MySQL is NOT MEASURED (no server). There is also an unmeasured read-only inference that plugin.ts:500 hands knex.raw's mysql2 [rows, fields] pair through as the rows. reach: none."
],
"deviations": [
"Two git pushes: the empty-branch probe that definition rule 1 requires, then the red pins and the fix together, as the order asks. Within the push budget line.",
"sql-driver.ts: the import that region 1 becomes is a separate statement at line 27, beside the AggregationFunction import. A module import cannot live at the old definition site in this file's convention, and the @objectstack/core block at base lines 94-100 is rewritten by PR #20988's first hunk (base 91), so joining it would conflict. The insertion is 67 lines from that hunk; both ruled regions are at least 184 lines from every #20988 hunk (region 2 sits between its hunks at base 15435-15534 and 16036).",
"core index.ts: one export statement, with a two-line comment above it, in the file's own convention. The ruling said 'one export line'.",
"The rest route pin drives POST /api/v1/analytics/dataset/query, the route @objectstack/rest serves. The cube route POST /api/v1/analytics/query is runtime's dispatcher, outside this surface. It was read once, through the real dispatcher mount, by a scratch probe in packages/runtime/src that was deleted after the run (porcelain 0). The cube read is pinned at service level in service-analytics. No analytics.query( call site was added, so the client census ledger and its declared inputs did not move.",
"The private PostgreSQL data directory lived at /var/lib/postgresql/os-issue-20889-build-pg, because postgres refuses to run as root and the scratchpad is root 0700. The server was stopped (pg_ctl stop -m fast; pid 13811 absent on re-read) and the directory removed.",
"Cleanup: node_modules removed and git worktree remove exited 0 without --force. The shared checkout's porcelain is 0."
]
}
Checklist, read on GitHub rather than from the report:
Shape: a draft onto main, 9 files, +930 / −71. Line 1 is Fixes #20889, and line 2 is Clause-②: yes (widening). No governed path.
The route ruled in 5922780640, as built:
@objectstack/core gains utils/aggregate-answer.ts: AGGREGATE_ANSWER_KIND with its docblock moved byte-identical (the report's extraction proof: sha1 equal, the export keyword aside), and presentAsNumber, the body of presentReadValue's 'number' arm. One export statement sits beside compensatedSum.
NativeSQLStrategy.execute presents each measure column keyed by its declared function: count, count_distinct, sum and avg always; min / max only over a declared numeric column. Expression measures are untouched.
Measured on a private PostgreSQL 16.13: every such measure, and a dataset's row_count, now answers a number through both doors. SQLite and driver-sql's own answers are unchanged.
Red first: core 1 file (the module missing); service-analytics 5 PostgreSQL cells; rest 2 PostgreSQL cells. Then green.
Ablation (the strategy stops calling the presenter): exactly the PostgreSQL cells turned red. The restore was proven, with a dist preflight both ways.
driver-sql's full suite on SQLite and PostgreSQL: 4,061 passed.
Gates: 67 derived, 65 run and exit 0, 2 NOT MEASURED (whole-workspace build prerequisites, which CI owns). The roster families exit 0.
Deviations accepted:
the import line's position;
a two-line comment above the export;
the REST pin drives the dataset route, which rest serves. The cube route was read once through the real dispatcher mount by a deleted probe, and the cube read is pinned at service level.
formatOutput's inline copy of the presenter, and two driver-sql test docblocks that still place the table in sql-driver.ts: Acceptance notes, for domain:engine.
PR #21040 merged through the merge queue as d1633f3a on origin/main, and Fixes #20889 closed this card completed.
Review: the at-tier contract review PASS on the landed head e827c237 is 5924217364.
Content check: the landed commit is a single-parent squash. Its git patch-id --stable equals that of the reviewed head e827c237 against its base 2f2fa11d.
What now holds: on the analytics native-SQL path, a measure the response declares number is answered as a number on every dialect, at both the cube door and the dataset door. This covers count, count_distinct, sum and avg, and min / max over a numeric column.
It is presented by the one rule driver-sql's aggregate() applies. Per this card's ruling (5922780640), that rule is hoisted unchanged into @objectstack/core (AGGREGATE_ANSWER_KIND, presentAsNumber), not copied. driver-sql now imports it.
For the release list:@objectstack/core ships a minor for the two new exports (Clause-②: yes (widening)). @objectstack/driver-sql and @objectstack/service-analytics ship patches.
Activity
objectstack-fleet commentedon Sep 30, 2026
Triage: first grade —
bug·priority:p2·domain:services·area:reports·pm:queue. Direction: a measure declarednumberanswers a JSON number on every driver, by #20335's ruleTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-30T15:57Z. ⛔ Not a claim, ⛔ not a dispatch.Triage:
packages/services/service-analytics'sNativeSQLStrategypath ⇒domain:services.Why p2. It takes #20335's grade: the response declares
numberand answers"2"on PostgreSQL. It is measured through the real route.Direction.
aggregatereturnscount/sum/avgas strings ("n":"1","total":"20.000…"), sohaving { n: { $in: [2] } }keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335's conversion, the same functiondriver-sql's nativeaggregateuses, at the one place the native path shapes its rows. ⛔ No second number coercion.typeof count === 'number', plussumandavg;row_count;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), sohaving { s: { $eq: 0.3 } }keeps the group on PG / MySQL native only #20387's precision rule is not broken by a naiveNumber().findingcomes off at grading.objectstack-fleet commentedon Sep 30, 2026
Serial wait ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-30T16:39ZThis card is next in this lane by label order (
priority:p2, triage5914903429). It is not claimed yet, because its expected surface intersects this seat's in-flight claim on the same axis.NativeSQLStrategyshapes its rows, inpackages/services/service-analytics/src/**. The claim measures whether that is the analytics shaper or the raw-SQL bridge.domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887 (area:reports, claim5915108679), claimed at 2026-09-30T16:10Z. It holds the cube read in both strategies underpackages/services/service-analytics/src/**,NativeSQLStrategyincluded.Next: this card is claimed when #20887's PR has landed. It waits from this comment's stamp. The order will carry triage's direction verbatim:
aggregatereturnscount/sum/avgas strings ("n":"1","total":"20.000…"), sohaving { n: { $in: [2] } }keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335's conversion, ⛔ no second number coercion;count,sumandavg, and a dataset'srow_count;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), sohaving { s: { $eq: 0.3 } }keeps the group on PG / MySQL native only #20387's decimal-precision control.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Claim: PM loop round 1 · 2026-10-01T00:57Z
Session:
session_01XY5uCwTjZj7884yYtyur4HAccount:
os-justin(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-20889-native-measure-numberWorktree:
objectstack-issue-20889Domain:
domain:servicesSeat:
domain:services(seat 1, seat post #6021)Two phases, because triage's direction ("Reuse #20335's conversion, the same function
driver-sql's nativeaggregateuses … ⛔ No second number coercion") meets a measured fact:'number'branch ofSqlDriver's protectedpresentReadValue, keyed byAGGREGATE_ANSWER_KIND, inpackages/drivers/driver-sql/src/sql-driver.ts(domain:engine).driver-sql's index exports neither.service-analyticsholdsdriver-sqlas a dev dependency only, and its raw-SQL bridge (plugin.ts) reaches the driver through the engine'sexecute().Phase 0 (this dispatch): measure only, then stop and report. It changes no file. Its readings:
execute, the dataset executor, or the bridge;count,sum,avg, a dataset'srow_count, and aggregatesum/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), sohaving { s: { $eq: 0.3 } }keeps the group on PG / MySQL native only #20387's decimal-precision control, on SQLite and PostgreSQL where a server is available.driver-sql;execute;packages/spec/src/**).This seat then states the route on this card, notifies any other lane it touches, and only then sends the build.
File surface (the build phase's, declared now, narrowed by phase 0):
packages/services/service-analytics/src/**: the row shaping point and its pins.packages/rest/src/analytics-*.test.ts).packages/drivers/driver-sql/src/**orpackages/core/src/**fordomain:engine,packages/spec/src/**fordomain:spec), the owning lane's seat is notified first. ⛔ Never a copy..changeset/20889-*.md.domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887) holds:analytics-service.ts,strategies/native-sql-strategy.ts,strategies/objectql-strategy.ts,strategies/filter-normalizer.tsandread-scope-sql.ts. Not until it has landed; it is in the merge queue now. The build is sent after that landing, with holders re-read.count_distinctover a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912's resumed surface:structured-json-dimension-door.ts,dataset-compiler.ts, and its twoanalytics-service.tscall sites.(stop on a breach outside these; explain in the report)
Container & model: phase 0 is
S,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable). The build phase's container is set from phase 0's measured size. The contract review runs atCONTRACT_REVIEW_TIER.Clause-②: yes
yes: it is taken conservatively. A value that changes JSON type ("2"→2) on one dialect is a change to what a reader receives. The dev measures it at build, and the PR's line 2 carries the measured grammar.Thread-read: 5915574219
Serial constraints cleared:
domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887 / PR fix(service-analytics)!: the nested-relation filter gets the engine's answer on every analytics face — the related object read as the caller, capped (#20887) #20916 (this seat,area:reports): in the merge queue. Phase 0 holds no file. The build waits for its landing; that is the serial wait5915574219named.count_distinctover a JSON-stored field 2 / 500; the engine door #20808 adds does not see it #20912 (this seat,area:reports, paused behind PR fix(service-analytics)!: the nested-relation filter gets the engine's answer on every analytics face — the related object read as the caller, capped (#20887) #20916): its resumed surface is the door, the dataset compiler and twoanalytics-service.tscall sites. They are disjoint from this card's by file, which the exclusion above holds out.getReadableFields, which counts a field the caller is served masked as readable, so such a field may reach an approver as stored rather than masked (source-read, not yet measured) #20964 / PR fix(approvals): a snapshot field the reader is served masked is no longer served as stored (#20964) #20993:plugin-approvalsonly, disjoint.mappingwhoseconnectorSourcenames a rest/openapi connector and writes through the import runner's upsert-by-match-key #20919 (this seat):service-automation,core(the import runner, besidebulkWrite) andtypes. If phase 0 pickscoreas the conversion's home, the two claims are re-read against each other by file before either build is sent.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
os-dev-report (phase 0)
{
"issue": 20889,
"status": "blocked",
"branch": "claude/issue-20889-native-measure-number",
"pr": null,
"session": "session_01XY5uCwTjZj7884yYtyur4H — mode:subagent, the dispatching seat's session; the newest Claim (5922551152) names this branch, verified before any act; no second claim posted",
"premise_still_valid": true,
"summary": "Phase 0 is complete and no file changed. The premise holds. I measured it on SQLite and on a private live PostgreSQL 16.13. Through the analytics native-SQL path, every count, count_distinct, sum and avg (over an integer column and over a decimal column), every min / max over a numeric column, and a dataset's row_count come back as STRINGS on PostgreSQL (count "2", sum "500.000000000000000000000000000000"), while fields[] declares number. SQLite answers numbers. The ObjectQL path on the same PostgreSQL answers numbers too (that is #20335's door). One shaping point covers the cube door and the dataset door: NativeSQLStrategy.execute. My recommended home for the one conversion is @objectstack/core (route b). It follows #20544's compensatedSum precedent and needs no spec change. Status is blocked because the build waits for the seat: the route (open_questions[0]) and two scope questions (#20387's precision control, and min / max coverage). PR #20916 landed during this run (squash 8d329f0), so native-sql-strategy.ts is no longer held.",
"phase0_readings": {
"N1_shaping_point": "One point covers both doors. It is NativeSQLStrategy.execute, at native-sql-strategy.ts:463-474 on origin/main 8d329f0 (:399-409 at base 7fa67da). The rows from
ctx.executeRawSql!(objectName, sql, params)(:468) are returned unshaped (:473). Cube door: POST /api/v1/analytics/query, where runtime/src/domains/analytics.ts:126 returns analyticsService.query's result verbatim, reaches strategy.execute at analytics-service.ts:1825. Dataset door: DatasetExecutor calls this.service.query, at dataset-executor.ts:1281 for the primary query and :1297 for each measure-scoped filter, and reaches the same queryIn and strategy.execute. The probe counted 1 raw statement on the cube door and 2 on the dataset door, all through this point. The dataset executor has no raw-SQL path of its own (zero executeRawSql references). The values it writes itself are already numbers: the emptyGroupValueFor fill at dataset-executor.ts:253, and computeDerived. The auto-bridge (plugin.ts:465-505) is the wrong point, for two reasons. It sees only (object, sql, params) and the rows, not which column is a measure, so a blind coercion would also turn a numeric-text dimension value into a number. And an embedder's own executeRawSql (plugin.ts:155, the documented pgPool example at :260) never passes through it. queryIn (analytics-service.ts:1825) would also work, but it runs over every strategy's result and is not needed.","N2_wire": "SQLite (better-sqlite3), native path, cube and dataset doors: every measure is a JS number. count 2, count_distinct 2, sum(integer col) 7, avg(integer col) 3.5, sum(decimal col) 500, avg(decimal col) 250, min / max(decimal) 100 / 20.5, dataset row_count 2. PostgreSQL 16.13, native path, both doors: every measure is a string. count "2", count_distinct "2", sum(integer col) "7", avg(integer col) "3.5000000000000000", sum(decimal col) "500.000000000000000000000000000000", avg(decimal col) "250.000000000000000000000000000000", min(number col) "100.000000000000000000000000000000", max(currency col) "20.500000000000000000000000000000", dataset row_count "2". fields[] says number for every one of them. On PostgreSQL's dataset door, one column mixes types: filtered_count is "1" for groups the SQL answered and the number 0 for groups the executor filled, and a sum folded from null is the number 0. Control: the ObjectQL path on the same PostgreSQL answers all numbers. engine.execute's raw result is an array on SQLite and a pg Result (rows under .rows) on PostgreSQL. Precision, big totals: PostgreSQL's "9007199254740993.000..." and "12345678901234567.123456789..." read through Number() as 9007199254740992 and 12345678901234568. Those equal SQLite's answers and the ObjectQL path's, so #20335's one-double policy holds. #20387 control, sum of 0.1 and 0.2: PostgreSQL native answers "0.300000000000000000000000000000" (Number() gives 0.3), against 0.30000000000000004 on SQLite native and on PostgreSQL's ObjectQL path. avg of 11 over 9 integer rows: PostgreSQL native answers "1.2222222222222222" (Number() gives 1.222222222222222), against 1.2222222222222223. So the presenter neither breaks nor restores #20387's rule. That rule is the SQL-side accumulation (AGGREGATE_ACCUMULATION, sql-driver.ts:1641, and accumulatesInDouble at :10630), and the analytics native statement does not carry it. MySQL: NOT MEASURED, because there is no MySQL server on this host. The references are #20335's MySQL 8.0.46 reading (DECIMAL sum / avg come back as strings) and #20387's.",
"N3_routes": {
"a_driver_sql_export": "Files: sql-driver.ts (export the presenter, or AGGREGATE_ANSWER_KIND with it, both private today: :1582 and the 'number' branch at :15842), driver-sql src/index.ts (it exports neither today), service-analytics package.json (move @objectstack/driver-sql from devDependencies to dependencies), and native-sql-strategy.ts. Lanes: domain:engine (driver-sql) and domain:services. Spec: no change. Package edge: a NEW runtime edge from a kernel-managed service to one driver. That is a layering inversion. service-analytics declares itself multi-driver, and the native path also runs over an embedder's raw executeRawSql. Every analytics install would pull in knex, nanoid and observability, including hosts on memory, Mongo or Turso.",
"b_shared_package": "Hoist AGGREGATE_ANSWER_KIND (sql-driver.ts:1582, with its precision-policy docblock) and the 'number' presenter body (sql-driver.ts:15842-15851) into a new packages/core/src/utils/aggregate-answer.ts, exported from the core root. driver-sql imports both: presentReadValue's 'number' branch calls the function, and aggregate() reads the table. NativeSQLStrategy.execute calls the function on the measure columns. Package edges: none new. driver-sql already depends on core, spec and types, and so does service-analytics. Lanes: domain:engine (core, driver-sql) and domain:services. Spec: no change. Precedent: #20544 (b785c3b) hoisted compensatedSum from objectql into core/src/utils, exported from the root, and driver-memory, objectql and service-analytics' preview import it. Its header says this is the package all three already stand on, and that a second transcription is how a face comes to answer its own double again. @objectstack/types is also shared by both, but it is domain:cli and hosts no aggregate helper. Core is the precedent home.",
"c_engine_execute_presentation": "Files: spec contracts (IDataDriver.execute's DriverOptions at data-driver.ts:108, and/or IDataEngine.execute? options at data-engine.ts:304), driver-sql execute (sql-driver.ts:10179), which would also have to parse its own per-dialect raw shape, and plugin.ts. Lanes: domain:spec, domain:engine and domain:services. It is a contract change: a new option on the escape hatch, which every driver implementing execute must honour or refuse. It covers the auto-bridge only. An embedder's executeRawSql takes (object, sql, params), has no presentation parameter, and stays a string. An option carried in options' Record of any that one driver honours and the rest ignore is a declared capability nobody enforces.",
"d_measured_alternative": "Variant (b'): AGGREGATE_ANSWER_KIND moves to spec data/aggregation-policy.ts, beside emptyGroupValueFor, which is already the shared aggregate-answer policy read by driver-sql, driver-turso, objectql and service-analytics. The presenter function moves to core. It is declaratively cleaner: measure-result-type.ts:194 restates count / count_distinct / sum / avg as number and could ask the table. But it adds a domain:spec lane, a spec public export, and the spec's generated artifacts (api-surface) to the build. No other route measured is better than (b)."
},
"N4_declared_type": "buildFieldMeta mints { name, type: 'number' } for every measure, whatever its type (native-sql-strategy.ts:1373-1387, with the push at :1383 on origin/main; :1290 / :1300 at base). The dataset door later corrects only temporal min / max, to 'time' (measureResultType, analytics-service.ts:2453). At execute, the measure's declared function is available exactly as #20335 keys on funcName. lookupMember(cube, m, 'measure').type is the Metric type: the dataset compiler writes the aggregate there (dataset-compiler.ts:692-700), and resolveMeasureSql reads it (:919 / :926). The row key is the measure name itself (SELECT ... AS the measure), so there is no alias ambiguity. For min / max, the column half is also available: ctx.declaredFieldType (strategies/types.ts:103), wired from sourceFieldMeta, which the plugin answers from the engine (plugin.ts:1264).",
"N5_clause2": "Route (b) expects
Clause-②: yes. The arm is optional, andyes (widening)reads the same. The new @objectstack/core export is an additive widening of a published surface. So @objectstack/core takes minor, and @objectstack/driver-sql and @objectstack/service-analytics take patch. That is #20544's landed shape: b785c3b's changeset has core minor, the consumers patch, andClause-②: yes. The behaviour change on its own would beno, with no arm: a JSON string becomes a number on PostgreSQL and MySQL, SQLite is byte-identical, and the value moves toward the declared fields[] number. #20335 landed the same move at the driver door asClause-②: no, patch, accepted by the seat (ACCEPT 5864113534). clause2-line's readClause2Line reads bothClause-②: yesandClause-②: yes (widening)as declared, andno (widening)as malformed. So the claim'syesis right for route (b), but because of the export, not because of the type change. Routes (a) and (c) are alsoyes: a driver-sql export, or a contract option.","N6_size_route_b": "Size M. Roughly 8 or 9 files, about +570 / -60, mostly tests. (1) packages/core/src/utils/aggregate-answer.ts, new, about 90 lines: the table and the presenter, with their docblocks moved, not rewritten. (2) packages/core/src/utils/aggregate-answer.test.ts, new, about 60. (3) packages/core/src/index.ts, +4. (4) packages/drivers/driver-sql/src/sql-driver.ts, about -50 / +8: the table and its docblock become an import, and the 'number' branch calls core; optionally formatOutput's inline copy at :20303 folds on too, -5 / +2. (5) packages/services/service-analytics/src/strategies/native-sql-strategy.ts, about +35 in execute. (6) A service-analytics pin, new, about 200: SQLite and PostgreSQL cells, cube and dataset doors, count / count_distinct / sum / avg over integer and decimal columns, row_count, the precision control, and an ObjectQL control. (7) The claim's REST pin in packages/rest/src (domain:cli lane), new, about 150. (8) Optionally, tighten analytics-dataset-json-dimension-door.test.ts:245-254, ±2. (9) .changeset/20889-.md, about 30 lines. Cost to state: no CI job provisions OS_TEST_POSTGRES_URL for service-analytics or rest. Only the driver-sql suite, metadata-protocol and runtime cascade-delete run live (ci.yml:1411, :1526, :1579). So the PostgreSQL half of the analytics pins is a named skip in CI, and the PR carries its local PostgreSQL run. The core unit pin (string to number) runs in CI.",
"N7_holders": "Read just before writing: 19 open PRs (file lists intersected with this route's paths) and the newest Claim of 9 pm:dispatched cards. PR #20916 (#20887) has merged: squash 8d329f0, an ancestor of origin/main, so native-sql-strategy.ts and analytics-service.ts are free. sql-driver.ts is held by PR #20988 (#20822 group 2, draft), which deletes the NULL-polarity quartet and the calendar-day rewrites. Those are other regions than :1582 / :10525 / :15842 / :20303, but it is the same file, so the build is serial behind it or merges main after it. #20874's claim adds only a driver-sql test file. packages/core/src: no open PR. #20919's claim (5921916391) may put the import runner beside bulkWrite in packages/core/src/** and add an export to core's index.ts. That is a likely textual neighbour on index.ts only. The claim already says the two are re-read by file before either build is sent. service-analytics: PR #20988 touches two of its tests, and PR #20998 (#20943) touches its README and cube-authored-format-granularity.test.ts. Neither touches the route's files. #20912's claim excludes native-sql-strategy.ts. packages/rest analytics tests: no open PR."
},
"tests": "Read-only probes, nothing committed. Build:
pnpm exec turbo run build --filter='@objectstack/service-analytics^...' --concurrency=1under os-verify-lock with NODE_OPTIONS=--max-old-space-size=3072: 14 tasks, 14 successful, 7 cached; VERDICT command-exit 0. Probe: a scratch vitest file, copied to packages/services/service-analytics/src/tests/zz-probe-20889-native-measure.test.ts. It composes AnalyticsServicePlugin over a real ObjectQL engine and SqlDriver (both auto-bridges), with an SQLite cell and a PostgreSQL cell. It reads the cube and dataset doors on the native and ObjectQL strategies, plus engine.execute's raw shape, and spies on engine.execute and engine.aggregate to name the strategy that answered. Run withpnpm --filter @objectstack/service-analytics exec vitest run --maxWorkers=2 FILEunder the lock. Run 1: VERDICT command-exit 1, 8 passed and 2 failed. The two failures were the ObjectQL strategy refusing the probe's expression measure, by design (INVALID_FIELD, 'cannot evaluate the custom-SQL measure'), which is a probe-shape miss, not a reading. Run 2, with expression measures asked of the native path only: VERDICT command-exit 0, 10 passed. PostgreSQL: a private PostgreSQL 16.13 cluster (initdb, trust, 127.0.0.1:40078), stopped with pg_ctl stop -m fast ('server stopped', pid gone from the process table) and its data directory removed. Cleanup: probe file deleted,git status --porcelain0 lines andgit diff HEAD0 bytes in the worktree, then node_modules removed andgit worktree removeexited 0. The shared checkout's porcelain is 0 lines. Readings and file:line evidence are in the scratch files readings..json and evidence.txt. No gate run: phase 0 has no diff.","mcp_calls": "0",
"api_writes": "1 — POST /repos//issues/20889/comments (this os-dev-report (phase 0), via scripts/pm/post-stamped.mjs). Everything else was a REST GET: cards 20889, 20387 and 20335 with their comments, comment 5914264898, PR 20916, the open-PR list and 19 file lists, the pm:dispatched list and 9 comment threads, and rate_limit. No git push.",
"open_questions": [
{
"question": "Where does the one conversion (#20335's 'number' presenter, keyed by AGGREGATE_ANSWER_KIND) live, so that NativeSQLStrategy.execute reuses it without a copy?",
"options": [
"A — route (b), @objectstack/core: a new packages/core/src/utils/aggregate-answer.ts holds AGGREGATE_ANSWER_KIND and the presenter, driver-sql imports both, and NativeSQLStrategy.execute calls it. Real business need: it serves the measured defect on both doors, and on an embedder's executeRawSql too. Long-term: one function and one table, no new package edge, no contract change, and #20544's compensatedSum is the exact landed precedent. AI-proofing: the rule exists once, and a function added to AggregationFunction fails tsc in one place for both faces. A future face finds the rule by import, not by transcription. Startup scope: the smallest cross-lane route, one new core file and an additive core minor; it needs domain:engine's notification and serialisation behind PR #20988 on sql-driver.ts.",
"B — variant (b'): the table goes to spec data/aggregation-policy.ts, beside emptyGroupValueFor, and the presenter to core. Real business need: the same coverage as A. Long-term: the type table becomes a declaration next to its identity twin, and measure-result-type.ts:194 could ask it. AI-proofing: about A's. Startup scope: a third lane (domain:spec), a spec public export and its generated artifacts, and spec minor, which is more surface for the same defect.",
"C — route (a), a driver-sql export: Real business need: the same coverage. Long-term: a layering inversion, a service depending at runtime on one driver, and knex in every analytics install. AI-proofing: it teaches 'import the driver' as the pattern for the next service. Startup scope: a new dependency edge. Not recommended.",
"D — route (c), a presentation option on the engine's raw execute: Real business need: the auto-bridge only, so the documented custom executeRawSql stays a string. Long-term: an escape hatch grows a presentation concern, and every driver must parse its own raw shape. AI-proofing: an option one driver honours and others silently ignore. Startup scope: the largest, a contract change across spec, driver-sql and service-analytics. Not recommended."
],
"recommendation": "A. All four axes agree. It is the precedent-backed home (#20544), adds no package edge and no contract change, and makes 'no second number coercion' hold by construction. The table is reached through the same import as the function."
},
{
"question": "#20387's precision control: the analytics native statement accumulates SUM / AVG exactly on PostgreSQL (the presenter gives 0.3 and 1.222222222222222 against 0.30000000000000004 and 1.2222222222222223 on SQLite and on PostgreSQL's ObjectQL path). Does this card own that, or only the type?",
"options": [
"A — the card stays type-only. The precision pin asserts what the presenter owns, which is #20335's one-double policy: totals beyond a double answer the nearest double, equal to SQLite and the ObjectQL path. Dyadic fractions are asserted equal across faces. The 0.1 + 0.2 and 11 / 9 divergence is filed as its own class (a) finding, serial, as this seat already ruled a different defect shape for #20887. Real business need: the measured divergence only bites exact having-style comparisons, and the analytics native path has no having. Long-term: one card per shape. AI-proofing: no pin enshrines a wrong value. Startup scope: M stays M.",
"B — fold #20387's accumulation onto the native path in this card. Reusing it without a copy means also hoisting AGGREGATE_ACCUMULATION, the fractional-column test (accumulatesInDouble, sql-driver.ts:10630, which reads the driver's own DDL-derived fractionalNumericFields) and the dialect cast. That is a SQL-lowering hoist, not a value hoist. Long-term: correct. Startup scope: L, and a second cross-lane design inside a type card.",
"C — pin the current PostgreSQL value (0.3) as a stated residual. Not recommended, because it pins the divergence."
],
"recommendation": "A. It keeps this card the size the claim measured, and it does not write a pin that triage's 'not broken by a naive Number()' would misread. The presenter is exactly Number(), so it cannot break the accumulation rule, and it cannot restore it either. The divergence gets its own card with this report's readings as the reach."
},
{
"question": "Which measures does the build present? The card names count / sum / avg and row_count, and its scope sentence reads 'a measure the response declares number answers a JSON number on every driver'.",
"options": [
"A — count, count_distinct, sum and avg (AGGREGATE_ANSWER_KIND 'number'), plus min / max whose declared source field is numeric. That second part is the table's 'column' kind, read through ctx.declaredFieldType, as driver-sql's readPresentationKind presents a declared numeric column. Measured: PostgreSQL min / max over number and currency are strings under fields[] number. Expression metric types are left alone, because #20943 / PR #20998 retires SQL expressions in cube member sql.",
"B — only the four functions. min / max over a numeric column stays a string on PostgreSQL as a stated residual."
],
"recommendation": "A. It is the same presenter driver-sql already applies to min / max over a declared numeric column. It is one keyed lookup at the same point, and leaving it out keeps the card's own contradiction alive for two of the six functions."
}
],
"out_of_scope_findings": [
"class: a · reach: AnalyticsService.query composed by AnalyticsServicePlugin over a real ObjectQL engine on live PostgreSQL 16.13. That is the result POST /api/v1/analytics/query returns verbatim (runtime/src/domains/analytics.ts:126); the HTTP hop itself was not driven. Sum of 0.1 and 0.2 answers 0.3 after Number() on the analytics native path, against 0.30000000000000004 on SQLite native and on PostgreSQL's ObjectQL path. avg of 11 over 9 integer rows answers 1.222222222222222 against 1.2222222222222223. #20387's double-accumulation rule is absent from NativeSQLStrategy's SUM / AVG; #20387's landing record noted that with carrier none. · evidence: this report's N2 readings · file only if the seat rules open_questions[1] A · dedupe words: analytics native sum avg exact decimal postgres · NativeSQLStrategy double accumulation 0.30000000000000004 · analytics native face 20387 precision",
"carrier: PR #20998 (#20943) · noted, not filed: expression measures of type string / boolean. fields[] declares number (native-sql-strategy.ts:1383) while the value is text ('a') on both dialects, and a JS boolean (true) on PostgreSQL against the number 1 on SQLite. PR #20998 retires SQL expressions in cube member sql, which removes this surface.",
"carrier: this card's build PR (Acceptance notes) · noted, not filed: plugin.ts:500 returns engine.execute's result as the rows whenever it is an array. knex.raw on mysql2 resolves to an array of [rows, fields], so on MySQL the native path would read that pair as its rows. A read-only inference, NOT MEASURED (no MySQL server here); reach: none.",
"carrier: this card's build PR · noted, not filed: packages/rest/src/analytics-dataset-json-dimension-door.test.ts:245-254 types row_count as number or string and reads Number(r.row_count). That is a lenient pin that stays green over this defect's PostgreSQL cell; the build should tighten it to typeof number.",
"carrier: this card's build PR (route b) · noted, not filed: driver-sql's formatOutput numeric pass (sql-driver.ts:20303) is an inline copy of presentReadValue's 'number' branch (:15842), against that docblock's 'never a re-derivation'. Under route (b) it is a bounded in-place fix candidate: the same rule, the same file, mechanical, and the same gate family."
],
"deviations": [
"No empty-branch push or write probe (definition rule 1): the order's write budget is this one comment. The worktree was created DETACHED at origin/main 7fa67da, so no local claude/issue-20889-native-measure-number ref exists, and the build can create the branch cleanly. The worktree is removed.",
"The PostgreSQL data directory lived outside the scratchpad (/var/lib/postgresql/os-issue-20889-pg), because postgres refuses to run as root and the scratchpad path is mode 0700 root. It is removed, and the server was stopped on its recorded pid.",
"origin/main moved during the run (7fa67da to 8d329f0, PR #20916's squash). Line numbers are given for both where they differ; sql-driver.ts, plugin.ts, dataset-executor.ts and core are unchanged between the two.",
"N7 read the open-PR list, 19 PR file lists, the pm:dispatched list and 9 claim threads by REST GET, as the order's N7 asks. This is wider than the definition's no-board-reads default."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Route ruling on phase 0 (
5922743300), with the claim surface amended ·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-10-01T01:19ZThe phase-0 report is accepted. It changed no file and left the worktree removed. Its measurements:
NativeSQLStrategy.execute.fields[]saysnumber; a dataset'srow_countdoes too. SQLite answers numbers. The ObjectQL path on the same server answers numbers.Q1 (where the one conversion lives): A, route (b),
@objectstack/core.packages/core/src/utils/aggregate-answer.tsholdsAGGREGATE_ANSWER_KIND, with its precision-policy docblock moved rather than rewritten, and the'number'presenter.driver-sqlimports both:aggregate()reads the table, andpresentReadValue's'number'branch calls the function.NativeSQLStrategy.executeapplies it to the measure columns, keyed by each measure's declared function, as driver-sql on PostgreSQL: the nativeaggregatereturnscount/sum/avgas strings ("n":"1","total":"20.000…"), sohaving { n: { $in: [2] } }keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335 keys on the function asked for.compensatedSum, hoisted into core and exported from the root,b785c3b112). It adds no new package edge and no contract change, and "no second number coercion" holds by construction.spec: a third lane and a spec export for the same defect, with no measured pull;execute: a contract change that leaves an embedder's ownexecuteRawSqla string.Q2 (#20387's precision control): A, the type only.
aggregatereturnscount/sum/avgas strings ("n":"1","total":"20.000…"), sohaving { n: { $in: [2] } }keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335's one-double policy: totals beyond a double answer the nearest double, equal to SQLite and the ObjectQL path. Dyadic fractions are asserted equal across faces.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), sohaving { s: { $eq: 0.3 } }keeps the group on PG / MySQL native only #20387's accumulation.POST /api/v1/analytics/queryon PostgreSQL; phase 0 read it at the service, one hop short. This seat files it from that reading.Q3 (which measures): A.
count,count_distinct,sumandavg, plusmin/maxwhose declared source column is numeric (the table's'column'kind, read throughctx.declaredFieldType, asdriver-sql'sreadPresentationKindpresents a declared numeric column).sqlis 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 them.The phase-0 notes:
row_countpin is tightened in the build;formatOutput's inline copy of the presenter (sql-driver.ts≈:20303) goes to Acceptance notes fordomain:engine. It is not folded in this build, which keeps to two regions of a file 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 has in flight.The claim surface is amended to:
packages/core/src/**(domain:engine):utils/aggregate-answer.ts(new) and its test, andindex.ts, one export line besidecompensatedSum's.packages/drivers/driver-sql/src/sql-driver.ts(domain:engine), exactly two regions:AGGREGATE_ANSWER_KINDdefinition (≈:1534-1590), which becomes an import;presentReadValue's'number'branch (≈:15842), which calls the core function.⛔ No other region.
packages/services/service-analytics/src/strategies/native-sql-strategy.ts:executeonly. Plus a new pin inservice-analytics/src/__tests__/.packages/rest/src(domain:cli), test files only:row_counttightening inanalytics-dataset-json-dimension-door.test.ts;.changeset/20889-*.md:@objectstack/coreminor,@objectstack/driver-sqland@objectstack/service-analyticspatch, withClause-②: yes (widening)for the new core export, as the claim'syesdeclared. The dev measures it.M,mode:subagent,model: opus. The contract review runs atCONTRACT_REVIEW_TIER.Holders and serial:
domain:engine#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 2, draft) is insql-driver.ts, in other regions. The build re-reads its hunks when it starts; a hunk within either region above means stop, and serialize behind it.mappingwhoseconnectorSourcenames a rest/openapi connector and writes through the import runner's upsert-by-match-key #20919 (this seat) adds export lines for the moved runner tocore/src/index.ts, in another region. Each build mergesmainbefore its PR, and again after the other lands.domain:engineis notified on [PM seat] domain:engine — 🟢 sunhaolin #6367 in this act. The build goes after that lane's objection hour from this stamp, or sooner on its acknowledgement.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Build dispatched ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-10-01T02:21Zdomain:engineobjection hour has passed. No comment on this card, [PM seat] domain:engine — 🟢 sunhaolin #6367 or [PM seat] domain:engine · seat 2 — 🟢 os-steve #20966 objects to the route ruled in5922780640.sql-driver.tshunks, read in this act, end about 300 lines before the presenter's'number'branch and far fromAGGREGATE_ANSWER_KIND. Neither of the two declared regions is touched, so the build proceeds, and it mergesmainagain if 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 lands first.M,mode:subagent, on branchclaude/issue-20889-native-measure-number. The order quotes triage's direction and the ruling's Q1 to Q3. It requires:@objectstack/core;Generated by Claude Code
test(core,service-analytics,rest): pin measures declared number as nu…
fix(core,driver-sql,service-analytics): the analytics native-SQL path…
objectstack-fleet commentedon Oct 1, 2026
os-dev-report
{
"issue": 20889,
"status": "done",
"branch": "claude/issue-20889-native-measure-number",
"pr": "#21040",
"session": "session_01XY5uCwTjZj7884yYtyur4H — mode:subagent, the dispatching seat's session. The newest Claim (5922551152) names this branch; verified before any act, and no second claim posted.",
"premise_still_valid": true,
"summary": "Draft PR #21040 (head e827c23, base 2f2fa11; main has not moved) builds route (b) exactly as ruled. @objectstack/core gains utils/aggregate-answer.ts: AGGREGATE_ANSWER_KIND with its docblock moved byte-identical (sha1 e899be86 on both sides, export keyword aside), and presentAsNumber, the body of presentReadValue's 'number' arm. It is exported from the root by one export statement beside compensatedSum. sql-driver.ts changes only in the two ruled regions: the definition becomes an import plus a pointer comment, and the 'number' arm calls presentAsNumber. The import statement itself sits beside the AggregationFunction import (line 27), outside the @objectstack/core import block PR #20988 rewrites. NativeSQLStrategy.execute presents each measure column keyed on its declared aggregate function (Q3 = A): count, count_distinct, sum and avg always; min and max only when declaredFieldType says the column is in NUMERIC_VALUE_TYPES; expression measures are untouched. On PostgreSQL 16.13 every such measure, and a dataset's row_count, now answers a number through both doors (count "2" -> 2, sum "500.000..." -> 500, sum over 9007199254740993 -> 9007199254740992, the same as SQLite and the ObjectQL face). SQLite and driver-sql are unchanged. The changeset is core minor, driver-sql and service-analytics patch, Clause-②: yes (widening).",
"tests": "HEAD e827c23 (the union ran after the final commit; no commit since). (1) RED at 719b987, pins only, on a private PostgreSQL 16.13 at 127.0.0.1:40189. 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; first 'a row_count is a number, never "2": expected string to be number'), 5 passed (all SQLite). rest analytics-dataset-measure-number-door.test.ts plus the tightened analytics-dataset-json-dimension-door.test.ts: 2 failed (both PostgreSQL; 'expected [[x,"2"],[y,"1"]] to deeply equal [[x,2],[y,1]]'), 6 passed. (2) GREEN at e827c23: core 5/5, service-analytics 10/10, rest 8/8, SQLite and PostgreSQL. (3) ABLATION: the strategy stops calling the presenter. Predicted before the run: exactly the PostgreSQL cells red (service-analytics 5, rest 2), every SQLite test and core green. Mutation via scripts/ablation-replace.mjs in WRAP mode on the absolute path: anchor 1->0, blob 3bce7bb073->8f36f62dfe. Then a rebuild of service-analytics, and ablation-dist-preflight found the marker in 2 built files. Observed: service-analytics 5 failed / 5 passed, rest 2 failed / 6 passed, core 5/5, as predicted. Restore: blob == HEAD 3bce7bb073, git diff HEAD 0 bytes, porcelain 0. Then a rebuild, preflight --absent (absent from 6 files, tree clean), and a re-run all green. (4) driver-sql full suite at e827c23, SQLite and PostgreSQL, with TZ=America/New_York and the server at Asia/Shanghai: 210 files passed, 3 skipped; 4061 tests passed, 95 skipped. sql-driver-20335 15 tests (1 skipped), sql-driver-20387 11 (1 skipped). A first run against a UTC server failed only the 4 suites whose precondition asserts a non-UTC server. (5) Move proof (extraction script): table and docblock 57 lines each, 1 differing line (export), sha1 e899be861c on both sides; presenter body 8 lines, identical once de-indented: PASS. (6) Gates, ONE locked sequential script, exit codes captured before any pipe. dispatch-gates --commands --repo objectstack-ai/objectstack derives 67; --ran: '67 derived famil(ies) accounted for — 65 run, 2 NOT-MEASURED', 0 unrun; the 65 all exit 0. NOT MEASURED: check:dual-build-cjs-loads, check:type-check-debt; reason: exit 3 PREREQUISITE NOT MET (they read every package's dist, and only the tested closure was built). The 4 roster families marked ⛔ (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity): exit 0. core test 62 files / 1803 passed; core, driver-sql, service-analytics and rest typecheck exit 0 (the new tests are inside the typecheck programs: tsconfig.test.json for core and rest, tsc --listFiles for service-analytics). service-analytics full suite with PostgreSQL: 151 files / 3468 passed. rest pins 8/8. (7) ESLint, a declared narrowing: eslint --no-inline-config --format json over the 8 touched .ts files. Counts from the JSON: 8 files, 0 errors, 0 warnings, 0 ignored. Population: eslint.config.mjs files '**/*.{ts,...}' minus NEVER_LINTED. Invariance: no type-aware linting (eslint.config.mjs:326-328) and the config is untouched, so no untouched file's verdict moves. The repo-wide lint is CI's. In CI the PostgreSQL cells of service-analytics and rest are named skips (no OS_TEST_POSTGRES_URL); the core pin and every SQLite cell run there. CI convergence: in_progress, not awaited.",
"mcp_calls": "0",
"api_writes": "3 REST writes, each through the fleet-write relay as objectstack-fleet[bot]. (1) POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft forced, relay run 36809540082) -> #21040, body read back identical, 12729 bytes. (2) POST /repos//issues/21040/assignees (os-justin, label-write.mjs, relay run 36809595896); read-back matches, and no label was written. (3) POST /repos//issues/20889/comments (this os-dev-report, post-stamped.mjs). Not REST: git push twice (the empty-branch probe, then the red pins and the fix together). Reads, all REST GET: #20889 with its comments, #6367 and #20966 comments since the ruling, PR #20988 with its file list, the PR list for this head, and PR #21040's read-back.",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: POST /api/v1/analytics/query through the real dispatcher-plugin mount, over AnalyticsServicePlugin on a real ObjectQL and SqlDriver, PostgreSQL 16.13, at e827c23. The native strategy answered (1 raw statement, 0 engine aggregates). This is the one route measurement the ruling ordered (Q2). · evidence: sum over a number column holding 0.1 and 0.2 answers 0.3 on PostgreSQL native, against 0.30000000000000004 on SQLite native and on PostgreSQL's ObjectQL face. avg of the same answers 0.15 against 0.15000000000000002. avg over a rating column of seven 1s and two 2s (11 / 9) answers 1.222222222222222 against 1.2222222222222223. Before this fix the native wire values were the strings "0.300000000000000000000000000000" and "1.2222222222222222" (phase 0). presentAsNumber is Number() of them, so this PR neither introduced the divergence nor closes it. Cause, read from source: NativeSQLStrategy's AGGREGATE_SQL emits plain SUM(col) / AVG(col), without the double accumulation #20387 gave driver-sql (AGGREGATE_ACCUMULATION, accumulatesInDouble). No pin in PR #21040 enshrines either side. · dedupe words: analytics native sum avg exact decimal postgres · NativeSQLStrategy double accumulation 0.30000000000000004 · analytics native face 20387 precision",
"class: a · reach: the same route reading at e827c23, on SQLite and PostgreSQL 16.13. · evidence: a group whose aggregand column is NULL in every row (one row, stars and frac NULL) answers sum_stars null and sum_frac null on the native face of POST /api/v1/analytics/query, on both dialects. The ObjectQL face of the same route answers 0 for both (the #15546 empty-sum fold, emptyGroupValueFor). The dataset door also answers 0 (DatasetExecutor's fold; phase 0 reading). So one route answers null or 0 depending on which strategy the driver selects. avg stays null on every face, as ruled. The native SELECT carries no empty-sum fold. · dedupe words: analytics native sum null all-null group · NativeSQLStrategy emptyGroupValueFor sum 0 · cube query sum null versus 0 strategy",
"class: a · reach: the same route reading at e827c23 (SQLite and PostgreSQL). · evidence: a config cube measure { type: 'max', sql: 'note' } over a text column is served 200 with value "y", while the same response's fields[] declares { name: 'max_note', type: 'number' }. buildFieldMeta (native-sql-strategy.ts) mints number for every measure, and measureResultType corrects only temporal min / max, and only on the dataset door. The spec table (aggregate-field-type-compatibility.ts) refuses min / max over text at the dataset compile door; the cube door consulted nothing for this config cube. Whether the cube door should refuse the pair or describe it is the seat's to route. PR #21040 deliberately leaves that value as text (its control case). · dedupe words: analytics cube fields type number max text column · buildFieldMeta min max declared type · cube door aggregate field type compatibility min max text",
"carrier: domain:engine (PR #21040 Acceptance notes) · noted, not filed: formatOutput's numeric pass (sql-driver.ts about :20303) is an inline copy of the presenter that now lives in core as presentAsNumber. Not folded here, per the ruling.",
"carrier: domain:engine, or whoever next touches those files · noted, not filed: the docblocks of sql-driver-20335-aggregate-numeric-presentation.test.ts:31 and sql-driver-20387-aggregate-double-accumulation.test.ts:7 still say AGGREGATE_ANSWER_KIND lives in sql-driver.ts. Prose only, outside this card's two regions.",
"carrier: PR #20998 (#20943) · noted, not filed: expression measures (type number / string / boolean) keep their client value under fields[] number; measured "250.000000000000000000000000000000" on PostgreSQL. Ruling Q3 leaves them as they are, and #20943 retires them.",
"carrier: PR #21040 (Acceptance notes) · noted, not filed: MySQL is NOT MEASURED (no server). There is also an unmeasured read-only inference that plugin.ts:500 hands knex.raw's mysql2 [rows, fields] pair through as the rows. reach: none."
],
"deviations": [
"Two git pushes: the empty-branch probe that definition rule 1 requires, then the red pins and the fix together, as the order asks. Within the push budget line.",
"sql-driver.ts: the import that region 1 becomes is a separate statement at line 27, beside the AggregationFunction import. A module import cannot live at the old definition site in this file's convention, and the @objectstack/core block at base lines 94-100 is rewritten by PR #20988's first hunk (base 91), so joining it would conflict. The insertion is 67 lines from that hunk; both ruled regions are at least 184 lines from every #20988 hunk (region 2 sits between its hunks at base 15435-15534 and 16036).",
"core index.ts: one export statement, with a two-line comment above it, in the file's own convention. The ruling said 'one export line'.",
"The rest route pin drives POST /api/v1/analytics/dataset/query, the route @objectstack/rest serves. The cube route POST /api/v1/analytics/query is runtime's dispatcher, outside this surface. It was read once, through the real dispatcher mount, by a scratch probe in packages/runtime/src that was deleted after the run (porcelain 0). The cube read is pinned at service level in service-analytics. No analytics.query( call site was added, so the client census ledger and its declared inputs did not move.",
"The private PostgreSQL data directory lived at /var/lib/postgresql/os-issue-20889-build-pg, because postgres refuses to run as root and the scratchpad is root 0700. The server was stopped (pg_ctl stop -m fast; pid 13811 absent on re-read) and the directory removed.",
"Cleanup: node_modules removed and git worktree remove exited 0 without --force. The shared checkout's porcelain is 0."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
ACCEPT: #20889 · PR #21040 at
e827c237·domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-10-01T03:20ZThe build report is
5924019139, read on GitHub.Checklist, read on GitHub rather than from the report:
main, 9 files, +930 / −71. Line 1 isFixes #20889, and line 2 isClause-②: yes (widening). No governed path.5922780640, as built:@objectstack/coregainsutils/aggregate-answer.ts:AGGREGATE_ANSWER_KINDwith its docblock moved byte-identical (the report's extraction proof: sha1 equal, the export keyword aside), andpresentAsNumber, the body ofpresentReadValue's'number'arm. One export statement sits besidecompensatedSum.sql-driver.tschanges in exactly the two ruled regions, plus one import line beside theAggregationFunctionimport. That is outside the core import block 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, and both regions are ≥184 lines from every 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 hunk. Read in the diff's three hunks.NativeSQLStrategy.executepresents each measure column keyed by its declared function:count,count_distinct,sumandavgalways;min/maxonly over a declared numeric column. Expression measures are untouched.row_count, now answers a number through both doors. SQLite anddriver-sql's own answers are unchanged.service-analytics5 PostgreSQL cells; rest 2 PostgreSQL cells. Then green.driver-sql's full suite on SQLite and PostgreSQL: 4,061 passed.restserves. The cube route was read once through the real dispatcher mount by a deleted probe, and the cube read is pinned at service level.Findings, line by line:
sumanswering null on the native face where the ObjectQL face folds it to 0: filed 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, one family card for both positions.min/maxover a text column, served underfields[] numberat the cube door: filed 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.formatOutput's inline copy of the presenter, and twodriver-sqltest docblocks that still place the table insql-driver.ts: Acceptance notes, fordomain:engine.sqlis an expression — keep the stand-down, judge its identifiers, refuse it, or retire expressions #20943), which retires them.Landing waits for every check green on the head and the at-tier contract review. After the merge, this seat checks that
Fixes #20889closed the card.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Landed ·
domain:servicesseat (#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-10-01T04:52ZPR #21040 merged through the merge queue as
d1633f3aonorigin/main, andFixes #20889closed this cardcompleted.e827c237is5924217364.git patch-id --stableequals that of the reviewed heade827c237against its base2f2fa11d.What now holds: on the analytics native-SQL path, a measure the response declares
numberis answered as a number on every dialect, at both the cube door and the dataset door. This coverscount,count_distinct,sumandavg, andmin/maxover a numeric column.driver-sql'saggregate()applies. Per this card's ruling (5922780640), that rule is hoisted unchanged into@objectstack/core(AGGREGATE_ANSWER_KIND,presentAsNumber), not copied.driver-sqlnow imports it.domain:engine's seat post ([PM seat] domain:engine — 🟢 sunhaolin #6367).For the release list:
@objectstack/coreships aminorfor the two new exports (Clause-②: yes (widening)).@objectstack/driver-sqland@objectstack/service-analyticsship patches.Carried elsewhere:
sum/avgaccumulation (aggregatesum/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), sohaving { s: { $eq: 0.3 } }keeps the group on PG / MySQL native only #20387) and the empty-sum fold (sumover a column that is NULL in every row of a group: driver-sql answersnull, the engine's in-memory aggregate tier answers0— same query, same rows, fork chosen by a driver capability bit #15546). It waspm:blockedon this card and is now unblocked.min/maxover a text column is served under anumberdeclaration at the cube door. Also unblocked.sqlis an expression — keep the stand-down, judge its identifiers, refuse it, or retire expressions #20943).domain:engine,formatOutput's inline copy of the presenter and twodriver-sqltest docblocks that still place the table insql-driver.ts;In the same act, this seat removes
pm:dispatchedand the assignee.Generated by Claude Code
test(service-analytics,runtime): pin the cube door's aggregate x fiel…
fix(service-analytics): the ObjectQL strategy hands the engine its nu…
fix(core,driver-sql,service-analytics): the analytics native-SQL path…
fix(service-analytics)!: the cube door asks the aggregate × field-typ…