Skip to content

fix(objectql, service-analytics)!: a plain group over a formula field is refused alike on every analytics face, instead of a 500, a driver backstop, one null group or a rendered statement - #30768

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/project-thread-w36b67
Nov 6, 2026

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

domain:server#3 · os-elon-musk · session_016WV4cyTehJsfYGVMzcXLJD

Fixes #29304

Clause-②: no (narrowing)
Metadata-protocol: none

维护者速读

  • 改了什么:按公式字段(不落库的虚拟字段)做普通分组,现在所有分析入口都给同一个 INVALID_FIELD / 400,带位置(groupBy.N)和"把值存到一个真实字段上再分组"的修法;原生 SQL 策略遇到这种维度会让路给引擎。
  • 为什么改:同一个查询原来有四种答案:原生组合 500、ObjectQL 组合是驱动兜底的 400(无位置、建议"跑 schema sync")、/analytics/sql 干跑渲染出 GROUP BY calc、草稿预览和内存聚合给一个 null 分组,看起来像成功。
  • 风险与回滚:只收窄"按公式字段分组"这一种查询;存储字段分组、日期分桶、纯度量查询照旧,且照旧走原生 SQL。回滚即 revert。
  • 先看:packages/objectql/src/aggregate-query-shape-door.ts 的 assertGroupByTargetsAreStored;native-sql-strategy.ts 的 groupsByVirtualField 让路;objectql-strategy.ts 的 assertCrossObjectAttrsStored。
  • 两分钟验证:pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/objectql-echo-non-date-bucket.test.ts,预期全部通过,含 5 条 [#29304] 用例。
  • 需要你做的:无;契约复核后席位落地。

What changes

@objectstack/objectql: a storage axis at the aggregate door.

@objectstack/service-analytics: the service's one copy, and the native route.

File surface. The seat approved one addendum before the PR: packages/services/service-analytics/src/strategies/native-sql-strategy.ts (the decline). strategies/objectql-strategy.ts was NOT in that addendum; the first version of this body said it was, which was wrong. Why that file moved (+33 / −1):

  • The echo call, assertDatasetGroupTargetsStored beside assertDatasetDateBucketsTemporal: /analytics/sql on the ObjectQL composition is answered by this echo, the dry-run call site where main already calls the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 service copy. The dispatch surface names the service's dry-run call sites, but not this file, so it is named here.
  • assertCrossObjectAttrsStored (one helper, called in the echo and in executeCrossObject) goes beyond the named call sites. While pinning the faces I found account.name_calc (a dimension through a relationship over the related object's formula) still rendered on the dry run and served on an empty base, because the engine refuses it only once a base row has a key to resolve. I added it without asking the seat for an amendment first. It is pinned by the account.name_calc rows in objectql-echo-non-date-bucket.test.ts. The seat ruled that it stays in this PR: a cross-object dimension over a formula is still a dimension naming a formula field, so it is this card's defect on another path. The seat's ACCEPT names strategies/objectql-strategy.ts (both sites) in the surface addendum.

Fixtures the narrowing moved (fixture triage, not new behaviour):

  • engine-aggregate-reference-verdict-positions.test.ts: at having, a formula column can no longer be an aggregated column, so a formula pair there is refused first by the storage door (INVALID_FIELD), as a list-or-object pair already was. The "addDays pair from a formula projection" case now asserts that refusal at groupBy.0.
  • dimension-structured-json-door.test.ts and multi-value-json-stored-door.test.ts GUARDs sweep every FieldType through a native-only harness: formula now declines out of native and that harness configures no ObjectQL path, so the guards record it as not this door's refusal.

Follow-up card: #30773. dataset-compiler.ts refuses a formula as a dataset measure but still accepts a dataset or cube dimension over one, so an author can publish a dimension that every query now refuses. The authoring-time refusal (Prime Directive 12, Metadata-protocol: narrowed) is that card's, out of this one's scope (contract review 6674767869, should-fix).

Tests

  • packages/objectql/src/engine-aggregate-query-shape-door.test.ts (+2 and two control rows): a plain group over doubled / opened_calc as a name and as { field } / { field, alias }, full message, path, param, field, fields, object, no driver read; the first offending position wins; the REST door through findData and mapDataError. Controls: status as a name and { field: 'status' } served.
  • preview-date-bucket-type.test.ts (+3): native, ObjectQL and preview faces give one refusal, field for field, for a formula with returnType date and text; the later position after a stored dimension; a stored dimension served alike on all three faces.
  • objectql-echo-non-date-bucket.test.ts (+5): /analytics/query and /analytics/sql on the default (native) composition give one refusal for an inferred dimension, after a stored one, a cube dimension's sql, and account.name_calc. Route pin: a stored dimension, a measure-only query and a cube dimension stay native (engine.aggregate never called, the native COALESCE statement served); a formula dimension declines and the engine judges it (one engine.aggregate call).
  • fallback-delegate-date-bucket-type.test.ts (+2): the delegated face refuses a plain formula group as the engine face does, query and dry run, delegate never called.
  • packages/qa/dogfood/test/analytics-formula-group-refusal.dogfood.test.ts (new, 5): the stock showcase over HTTP, showcase_project.budget_remaining (a currency formula) on /analytics/query and /analytics/sql: 400 INVALID_FIELD, the plain-group sentence and remedy, one error body on both doors; status served on both.

Acceptance notes

Verification

Measured on origin/main 447226b2 with a real SqlDriver (scratch test, deleted), a dataset and a cube dimension over calc (formula, returnType date) and calc_s (returnType text):

face before now
/analytics/query, native composition DATABASE_ERROR / 500 INVALID_FIELD / 400, groupBy.0
/analytics/query, ObjectQL composition driver backstop INVALID_FIELD / 400, no path, "run schema sync" the same refusal
/analytics/sql, both compositions SELECT calc AS "calc" … GROUP BY calc the same refusal
draft preview [{ calc: null, total: 150 }] the same refusal
engine.aggregate, in-memory aggregation one null group INVALID_FIELD / 400, groupBy.N

Controls at head, measured the same way: a region dimension and a measure-only query are served by the native strategy on both doors, as on main.

Ablations (each restored, `git diff HEAD` empty after)

Through scripts/ablation-replace.mjs (anchor hit 1 → 0, blob moved), service-analytics imports @objectstack/objectql from src (vitest alias), so no rebuild:

Suites, gates and dogfood at 42c89af

The head after the contract review, 6bcd5781, changes only the changeset (Clause-②: no (narrowing), and the BREAKING heading "Who moves"). On it: check-changeset-no-major --base origin/main, check-adr-0087-registration --base origin/main, check-empty-changeset --base origin/main and pnpm check:changeset-gate-self-tests all exit 0; scripts/pm/clause2-line.mjs readClause2Line reads the changeset as value: no, arm: narrowing and readMetadataProtocolLine as none. The full dogfood run below carries over to that head, per the #29167 precedent.

  • @objectstack/objectql test: 556 files / 11071 tests passed; typecheck exit 0. @objectstack/service-analytics test: 211 files / 5007 passed, 300 skipped; typecheck exit 0. eslint --no-inline-config over the 13 changed .ts files: exit 0.
  • Full dogfood battery (the title carries !), pnpm --filter @objectstack/dogfood test under the verify lock: VERDICT command-exit 0, Test Files 422 passed / 3 skipped (425), Tests 3521 passed / 36 skipped (3557). The new pin alone: 5 / 5.
  • dispatch-gates --commands at 42c89af2: 68 derived, 67 run with exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, exit 3: needs a whole-tree build; CI runs it). --ran reconcile: 0 UNRUN.

Skills-docs: git grep -i formula -- skills content/docs, read for grouping, dimension, bucket and analytics. No page says a formula can be grouped. skills/objectstack-data/rules/field-types.md says a formula is "virtual: not aggregatable", which still holds; it does not name grouping. It is a governed surface, so it is not touched here; a skills seat can add "or grouped by" there.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (Locally: eslint over the changed files; the repo-wide run is CI's.)
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

…gate door

A groupBy entry of any form naming a formula field (no stored column) is
refused INVALID_FIELD / 400 at groupBy.N / groupBy.N.field before any driver
is asked; a bucket over one keeps the date-bucket words.

Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD
Co-authored-by: Claude <noreply@anthropic.com>
…every analytics face

Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD
Co-authored-by: Claude <noreply@anthropic.com>
… field on the query and its echo

Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD
Co-authored-by: Claude <noreply@anthropic.com>
…n every analytics face

Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD
Co-authored-by: Claude <noreply@anthropic.com>
…; changeset and dogfood pin

Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2445cb498d70709b062e29a33057d0261155e02e.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 221 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2445cb498d70709b062e29a33057d0261155e02e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6107df58b88446c0a986d47dbf7bc6d8eeedd19f — the merge of head 8ca24327db0b7fc317c901e8cc60653a004e5643 into base 2445cb498d70709b062e29a33057d0261155e02e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6107df58b88446c0a986d47dbf7bc6d8eeedd19f && git checkout 6107df58b88446c0a986d47dbf7bc6d8eeedd19f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2445cb498d70709b062e29a33057d0261155e02e 8ca24327db0b7fc317c901e8cc60653a004e5643 && git checkout -B drift-repro 2445cb498d70709b062e29a33057d0261155e02e && git merge --no-ff 8ca24327db0b7fc317c901e8cc60653a004e5643

node scripts/docs-audit/affected-docs.mjs --json 2445cb498d70709b062e29a33057d0261155e02e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2445cb498d70709b062e29a33057d0261155e02e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 42c89af2530ede61e73c9e218918db7a6d1e0474
Local-runs: none
Scope: packages/objectql/src/aggregate-query-shape-door.ts, packages/objectql/src/engine-delete-and-aggregate.ts, packages/objectql/src/engine-aggregate-query-shape-door.test.ts, packages/objectql/src/engine-aggregate-reference-verdict-positions.test.ts, packages/services/service-analytics/src/dataset-date-bucket-door.ts, packages/services/service-analytics/src/analytics-service.ts, packages/services/service-analytics/src/strategies/native-sql-strategy.ts, packages/services/service-analytics/src/strategies/objectql-strategy.ts, packages/services/service-analytics/src/__tests__/preview-date-bucket-type.test.ts, packages/services/service-analytics/src/__tests__/objectql-echo-non-date-bucket.test.ts, packages/services/service-analytics/src/__tests__/fallback-delegate-date-bucket-type.test.ts, packages/services/service-analytics/src/__tests__/dimension-structured-json-door.test.ts, packages/services/service-analytics/src/__tests__/multi-value-json-stored-door.test.ts, packages/qa/dogfood/test/analytics-formula-group-refusal.dogfood.test.ts + cited on main: native-sql-strategy.ts canHandle (the granularity decline at the top, the decline chain) and resolveStorageTarget; objectql-strategy.ts generateSql (the echo door order), aggregateGroupKeys, executeCrossObject, resolveFkAttr (groupBy: ['id', attr]); analytics-service.ts the no-strategy exit and assertDelegatedDateBucketsTemporal; plugin.ts queryCapabilities derivation and sourceFieldMeta; packages/spec/src/data/search-fields.ts (SEARCH_VIRTUAL_TYPES, isVirtualSearchField); packages/objectql/src/index.ts and both package.json exports maps; dataset-compiler.ts (virtualFieldAggregateRemedy); .changeset/29304-formula-plain-group-refused-alike.md as the ② input; scripts/pm/clause2-line.mjs as the declaration's one reader.

Inputs read: card #29304 body and its three comments (triage 6576581758, seat claim 6673055694, os-dev-report 6674577807); #29167 body and all seven comments (os-dev-report 6575924805 with out_of_scope_findings 1 and 2, seat ruling 6575980095, ruling amendment 6577634652, landing record 6578924118) and PR #29347's seven-file list; PR #30768 body, 15-file list and the net diff vs main (merge-base 40a9265ff, 7 ahead, main 25 ahead touching none of the 15 paths); the docs-drift comment 6674581432; the check-runs on this head, read twice; the seat's dispatch order and the dev's PR body, report, gates and reconcile files on the project mount; the record template from record-recognisers.mjs --template and the contract-review reference.

① Derived judgments

  • The narrowing is exactly the card's. assertGroupByTargetsAreStored judges every groupBy entry (a name, { field }, { field, alias }, { field, dateGranularity }) by one predicate, the spec's isVirtualSearchField (SEARCH_VIRTUAL_TYPES, today formula only), on the registry's declared field, with no local type list and no reading of returnType; an undeclared name is left to the ingress door (hasOwnProperty false, continue); a schema without fields is not judged, as before. A stored field of any type passes through untouched to the two doors that already stood there, and the date-bucket door's class arm (declaredFieldClasses, date and datetime pass, a text or number bucket refused) is byte-identical to main. TRUE.
  • The analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 words stay. The engine's virtualDateBucketMessage function is untouched; the bucket arm moved out of assertDateBucketTargetsAreTemporal into the new door with the same INVALID_FIELD / 400, the same groupBy.N.dateGranularity path and the same extra keys (field, fields, object, param: 'groupBy'). The service copy's bucket sentence is unchanged except that the variable is spelled key.dateGranularity. The analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 pins in both packages are untouched by the diff and the dev reports them green and red under ablation A1. TRUE.
  • Four faces, one answer. I compared the plain-group sentence character for character across the engine (virtualGroupMessage), the service copy (assertDatasetGroupTargetsStored), and the three test files' expected strings: identical, and the tests compare each face's refusal object with toEqual (refusedAlike, bothRefusals). Code INVALID_FIELD, status and httpStatus 400, param: 'groupBy', path groupBy.N (a name, which is every key the analytics strategy builds, since aggregateGroupKeys pushes plain keys as strings) or groupBy.N.field (the engine's object form, reached only by a direct engine.aggregate or REST caller). The voice is the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 sentence's: position, field, type, "The query was NOT run", the denormalise remedy, what happened before. The faces: (1) /analytics/query native composition, through the new decline to the ObjectQL strategy and the engine door; (2) /analytics/query ObjectQL composition, the engine door; (3) /analytics/sql, the service copy at the echo, reached on both compositions because the decline is in canHandle and generateSql routes through the same selection; (4) the draft preview and the delegated fallback, the service copy at their existing call sites, each placed ahead of assertDatasetDateBucketsTemporal as the engine orders its two doors. The dogfood pin proves (1) and (3) over HTTP on the stock showcase (budget_remaining, a currency formula) and asserts the two error bodies equal. TRUE.
  • The native decline fires only for a grouped virtual dimension. groupsByVirtualField reads query.dimensions only, resolves each member through resolveStorageTarget (the compiler's own resolution: a cube dimension's sql, or the dotted path's last hop and its object) and asks the host's declaredFieldType; a measure-only query, a stored dimension, a cube dimension over a stored column, an expression sql, an unresolvable member and a host with no sourceFieldMeta all answer false and stay native. A timeDimensions entry is a group key only when it carries a granularity, which the first line of canHandle already declines; a raw one is a window filter on both strategies (aggregateGroupKeys adds no key for it), so no grouped form escapes. The route pin (engine.aggregate spy: zero calls for region, measure-only and a cube region; one call for calc) and ablation A3 (10 red) hold it. TRUE.
  • Objectql-strategy.ts is needed and in scope. Its first site is the /analytics/sql echo in generateSql, which is the dispatch order's own "service call site for the dry run"; without it the ObjectQL echo (which no engine ever sees) would still render GROUP BY calc for the native composition's dry run after the decline. Its second site, assertCrossObjectAttrsStored in executeCrossObject and in the echo, is beyond the order's named sites but is required by the card's "all faces alike" for a dotted dimension over a related object's formula: on main the FK-resolve aggregate (groupBy: ['id', attr]) is run only when a base row carries a key, so the live answer would depend on data (an empty base served the dimension) and the echo would render a LEFT JOIN over a column no table stores. The guard reuses the one service copy with the related object's own groupBy.1 position, adds no sentence, and is pinned on both faces. The seat's dispatch to me named only native-sql-strategy.ts as the addendum; the PR body says the seat accepted both. Recorded under ③.
  • Accept-set and public face. No root export of either package changes: packages/objectql/src/index.ts does not export aggregate-query-shape-door.ts (nor did it export the date-bucket door), the objectql exports map names . and ./core only, and service-analytics exports . only; dataset-date-bucket-door.ts is not re-exported. The two new export functions are module-level only, unreachable from the entry type graph, so they are shipped bytes, not a published accept set. No .describe() text and no spec file changes; the error code and status were already on the wire. Nothing widens. The one change in what is accepted is the narrowing itself.
  • Fixture triage: no existing pin weakened. (a) engine-aggregate-reference-verdict-positions.test.ts: a formula pair at having is now refused by the storage door before the verdict is asked; the row still asserts a refusal before any read (aggregate: 0, find: 0), and having-filter.ts has no formula-specific branch that the move leaves dead (its message spells (type "...") generically). The matrix GUARD still covers every stored type at having. (b) The two door GUARDs sweep FieldType.options through a native-only harness (makeService('native'), no aggregate bridge); formula used to be served by the raw-SQL double (null) and now declines out of native into the no-strategy exit, an uncoded error, so the guard records "not this door's" and the door-population assertion stays exact for every other type. (c) The engine door's CONTROL grows two served plain groups (reads 5 to 7). (d) Nothing in the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 pins moved.

② Semver level

Changeset .changeset/29304-formula-plain-group-refused-alike.md: @objectstack/objectql: patch, @objectstack/service-analytics: patch; Metadata-protocol: none; the first line carries the ! title; one ADR-0087 marker, adr-0087: not-required (no-migration-prescription) with its reason, in the HTML-comment form the gate reads; a BREAKING section with Before (measured on 447226b2), Now, Unchanged, Who sees it, and The remedy. Level: right by AGENTS.md step 3, the level follows the metadata-protocol kind alone and none takes patch; Metadata-protocol: none is TRUE for this diff, which touches no spec schema, no authorable key and no authoring gate (a dataset or cube may still declare the dimension; see the should-fix). check-changeset-no-major, check-adr-0087-registration and Check Changeset are green on this head.

The Clause-② line is spelled false. The PR body and the changeset both read Clause-②: yes (narrowing) (the fleet reader scripts/pm/clause2-line.mjs reads both as value: yes, arm: narrowing). In that one reader's closed set, yes (narrowing) means "a diff that widens one surface and narrows another; both facts are read", and no (narrowing) means "not a widening, but breaking". This diff widens nothing, as ① derives and as the changeset's own next sentence says ("Nothing is widened: no export of either package root, public member, answer member, error code or status is added"). The seat ruled exactly this on the sibling card: amendment 6577634652 on #29167, "yes (narrowing) means a diff that widens one surface and narrows another ... This PR widens nothing, so no (narrowing) is the true spelling", and PR #29347 landed with no (narrowing) after a FAIL on its declaration line. The card's and the dispatch order's yes (narrowing) predate that amendment and carry the triage's spelling. The served tier does not change (the (narrowing) arm decides the narrowing face either way; record-recognisers --paths says so for this file list), but the changeset body ships as CHANGELOG.md, so a self-contradicting declaration would be published. Must-fix below.

Check-runs on 42c89af2 at the third read: 31 completed/success (among them Build Core, Check Changeset, Governed Surface Queue Guard, Dogfood Regression Gate, Lint · ESLint, Lint · docs & code guards, Spec property liveness, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Test Core 5 to 8 of 8, PR-body guards), 15 completed/skipped, 5 in_progress (Test Core 1 to 4 of 8, Type Check · workspace), none failed. The landing precondition (every check green) is the seat's to read on the final head.

③ Boundary flags

  • open_questions: none. The dev's three out-of-scope notes are read: (1) the hand-composed native-only service now answers the no-strategy error for a formula dimension where it answered DATABASE_ERROR; I confirmed plugin.ts derives objectqlAggregate: !!executeAggregate and auto-wires the bridge, so no shipped composition reaches it, and the no-strategy exit is the documented cost of every prior decline; (2) the skills sentence, see Skills-docs; (3) the dotted position, a nit below.
  • File-surface addendum. The seat's brief to this review named strategies/native-sql-strategy.ts as the one approved addendum; the PR touches strategies/objectql-strategy.ts too and its body says the seat accepted both before the PR. The echo site falls inside the order's original surface ("the service call sites for the dry run"); the executeCrossObject guard is the one site beyond it. Both are judged needed in ①. The seat's ACCEPT should name the second file so the record and the claim's surface agree.
  • The card's Clause-②: yes (narrowing) is itself the spelling the sibling amendment retired; the fix is to the PR body and the changeset, not to the card.
  • Skills-docs: skills/objectstack-data/rules/field-types.md line 131 says a formula is "virtual: not aggregatable, a dataset measure needs a stored field": still true, says nothing about grouping, and is a Tier H governed surface, so the dev was right to leave it and name it. content/docs/data-modeling/analytics.mdx, field-types.mdx and queries.mdx make no statement about grouping by a formula; content/docs/api/error-catalog.mdx lists INVALID_FIELD for "every other read axis that names a field ... groupBy" generically, which this refusal fits. No doc is owed by this PR (the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 sibling also shipped none); the release page is release-owned and untouched. The Skills-docs line is honest.
  • Author is objectstack-fleet[bot]; no out-of-relay login. The PR is a draft; no auto-merge to disarm.
  • Independence: rendered by an isolated contract-review subagent fed the card, the sibling card, the PR, the net diff, the cited code on main, the fleet reader and the check-runs, not the seat's conclusions; adopted by the seat named in Reviewed-by:.

④ Design judgment

  • The narrowing against the spec on main: the spec declares a formula virtual (SEARCH_VIRTUAL_TYPES, "computed on read, no driver materialises a column") and already refuses it on the sort, filter, search, value-aggregate and (since analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167) bucket axes with the store-the-value remedy. Grouping was the one axis with no verdict, and every answer it gave was wrong in a different way (a 500, a backstop with a prescription that cannot help, a rendered statement that never runs, a null group that looked served). The change takes no declared capability away; it makes the spec's own refusal real on the last axis. Holds.
  • aggregate-query-shape-door.ts: one door for both forms, judged first, so a formula under a bucket keeps the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 words and a formula under a plain key gets the sibling sentence; the date-bucket door is left with the class arm alone and its docblock says so. Against engine-delete-and-aggregate.ts (called ahead of the two doors that stood there, after the structured-JSON and field-type doors, so a json field keeps its own words) it holds, and the REST door pin (findData, mapDataError) shows the wire envelope.
  • native-sql-strategy.ts: the decline is the mechanism of the eight declines above it (what native cannot serve as the engine would, the engine serves), tiered "cannot answer, do not block" like the hooks it reads, placed after the duration decline and before the capability read. It resolves members exactly as the compiler does, so the judged member and the emitted column cannot drift. Holds.
  • dataset-date-bucket-door.ts and its call sites: the one service copy, extended by one door, called ahead of the bucket door at every site the engine-less faces have (preview, echo, delegated), so the three faces and the engine order their refusals the same way. Holds.
  • objectql-strategy.ts assertCrossObjectAttrsStored: judges the keys resolveFkAttr will hand the related object, with the related object's own declaredFieldType, skipping attr === 'id' as resolveFkAttr does. The refusal is the engine's own for that aggregate, now given on any data and on the echo. Holds; the position trait is a nit below.
  • The dogfood pin reads the ADR-0112 envelope (error.code, error.message) on the stock showcase and asserts the two doors' error bodies equal. Holds.

Findings:

  • must-fix — Clause-②: yes (narrowing) in the PR body and in .changeset/29304-formula-plain-group-refused-alike.md is false by the fleet's one reader: yes (narrowing) declares a widening beside the narrowing, and this diff widens nothing (① and the changeset's own words). Spell it Clause-②: no (narrowing) on both carriers, the key named once per line, as the seat's amendment 6577634652 ruled for the sibling and as PR fix(objectql, service-analytics)!: a date bucket over a formula field is refused alike on every analytics face, instead of a driver error, one null group or a rendered statement #29347 landed. No code change; the dogfood on 42c89af2 carries over to a head that touches only the changeset and body, by the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 precedent, and the seat re-runs this review as a delta on the new head.
  • should-fix — contract-first follow-up, its own card: dataset-compiler.ts refuses a virtual field as a dataset measure (virtualFieldAggregateRemedy) but accepts a dataset or cube dimension over one, so an author can still publish a dataset whose dimension every query now refuses at 400. Prime Directive 12 wants that refusal at authoring and lint too. Metadata-protocol: narrowed, so a minor of its own, out of this card's scope.
  • should-fix — for the seat's ACCEPT, not the dev: name packages/services/service-analytics/src/strategies/objectql-strategy.ts in the file-surface addendum, so the claim's surface, the PR body and this record agree (③).
  • nit — executeCrossObject runs the base aggregate before assertCrossObjectAttrsStored, so one engine read is spent before a refusal that depends on no data. Judging before the base aggregate would save it; the dev's order keeps base-key refusals first as the echo does. Note only.
  • nit — a dotted dimension over a related object's formula is refused at the FK-resolve query's own position (aggregate('account'): groupBy.1), not the caller's dimension position; documented in the body and the same trait the structured-JSON door already carries. A later card could map the cross-object refusal onto the caller's member.
  • nit — the BREAKING section labels the audience "Who sees it" where the order asked "Who moves"; the content is there.
  • nit — the two GUARD fixtures accept any uncoded error for formula (code, status, member all undefined); asserting the no-strategy message would pin the route they describe in the comment.

Implemented-by: claude/project-thread-w36b67
Reviewed-by: session_016WV4cytQjG9X1CrCgytUM5

VERDICT: FAIL

…arrowing)

The PR widens nothing, so the declaration is `no (narrowing)`, as #29167
landed with; `yes (narrowing)` reads as widening one surface and narrowing
another. The BREAKING heading "Who sees it" becomes "Who moves".

Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6bcd5781926296cd89927c3a276491c123bd2b8d
Local-runs: none
Scope: delta on record 6674767869 (FAIL at 42c89af2). The diff 42c89af2..6bcd5781 is one commit, one file: .changeset/29304-formula-plain-group-refused-alike.md, +2 / -2. Read beside it: the PR body as patched, follow-up card #30773, scripts/pm/clause2-line.mjs as the declaration's one reader, the check-runs on both heads.

Inputs read: PR #30768 head and body at the second read; git log and git diff between the two heads; the changeset at 6bcd5781; the PR body diff against the body reviewed at 42c89af2; card #30773 body; the check-runs on 6bcd5781 (polled until both Check Changeset runs settled) and the final state of the runs on 42c89af2; record 6674767869.

① Derived judgments

② Semver level

Unchanged and right: @objectstack/objectql: patch, @objectstack/service-analytics: patch, Metadata-protocol: none (none takes patch; no authorable surface moves in this diff), the ! title on the PR and the changeset's first line, one adr-0087: not-required (no-migration-prescription) marker in the HTML-comment form, and the BREAKING section now reads Before / Now / Unchanged / Who moves / The remedy. The declaration Clause-②: no (narrowing) is consistent with the level (a non-protocol breaking change ships patch with its BREAKING banner and ADR-0087 disposition) and with the diff.

Check-runs on 6bcd5781 at the read before posting (about 02:12Z): 15 completed/success, among them both Check Changeset runs (the gates that read the declaration line and the ADR-0087 marker: check-changeset-no-major, check-adr-0087-registration, check-empty-changeset), Governed Surface Queue Guard, Dogfood Regression Gate, PR-body guards, Spec property liveness, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Plan Test Core shards, Test UI; 15 completed/skipped; 20 in_progress (Build Core, Test Core 1 to 8 of 8, the five remaining Lint · runs, the four Type Check · runs, Live E2E UI); none failed. The same runs were green on 42c89af2 for the identical code. The landing precondition (every check green) is the seat's to read on this head.

③ Boundary flags

  • open_questions: none new.
  • analytics: a dataset or cube dimension over a formula field is accepted at authoring, though every query now refuses it (follow-up to #29304) #30773 says what the should-fix asked. Filed 2026-11-06T02:01Z by the dev session as a follow-up to analytics: a plain groupBy over a formula field answers DATABASE_ERROR 500 on the native composition, INVALID_FIELD 400 on ObjectQL, and a null group on the preview and in-memory engine #29304 citing record 6674767869 should-fix 1: dataset-compiler.ts refuses a virtual field as a dataset measure (virtualFieldAggregateRemedy, the lint leg validate-dataset-measure-aggregates.ts prints the same sentence) but its dimension loop runs assertDeclared only, so a dataset or cube dimension over a formula is accepted at authoring and refused by every query; wanted: the compile, publish and lint refusal with the same remedy, judged by the spec's isVirtualSearchField / SEARCH_VIRTUAL_TYPES; declared Metadata-protocol: narrowed, a minor of its own, contract review owed. That is the finding, scope and grade I asked for. It is unlabelled and untriaged at read time, which is the triage seat's.
  • The objectql-strategy.ts surface question is closed by the seat's ruling recorded in the body; the ACCEPT should carry the same words.
  • On the old head 42c89af2, the five runs still in progress at my third read ended as 2 cancelled (superseded by this push) and the rest success; 36 success, 15 skipped, none failed. Nothing on that head was red.
  • Author is objectstack-fleet[bot]; the PR is a draft; no auto-merge to disarm.
  • Independence: rendered by the same isolated contract-review subagent that rendered 6674767869, fed the new head, the diff between heads, the patched body, the card and the check-runs; adopted by the seat named in Reviewed-by:.

④ Design judgment

No code moved, so the ④ of record 6674767869 stands as written: the narrowing makes the spec's own refusal of a virtual field real on the one query axis that had no verdict, at the engine door and in the service's one copy, with the native strategy declining to the engine; it takes no declared capability away. The two prose edits make the published changeset say what the diff does.

Findings (none must-fix):

  • The remaining nits of 6674767869 (the base aggregate read before assertCrossObjectAttrsStored, the cross-object refusal's position, the GUARD fixtures' uncoded-error shape) stand as nits and ask for no change on this PR.

Implemented-by: claude/project-thread-w36b67
Reviewed-by: session_016WV4cytQjG9X1CrCgytUM5

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5

ACCEPT · #29304 · 2026-11-06T02:07Z · Head-sha: 6bcd5781926296cd89927c3a276491c123bd2b8d

席位核对

  1. Shape: 15 files, +511/−49: the engine's assertGroupByTargetsAreStored judges every groupBy entry by the spec's isVirtualSearchField (the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 date-bucket arm moved into it with its words, code, path and keys unchanged); the service copy in dataset-date-bucket-door.ts gives the same refusal at the preview, the /analytics/sql echo and the delegated fallback; the native strategy declines (canHandle) a grouped dimension over a virtual field so the engine serves the refusal; assertCrossObjectAttrsStored covers a cross-object dimension over a formula (account.name_calc) in the ObjectQL echo and executeCrossObject. Pins on each face plus one dogfood file; three ablations red. Diff read at this head.
  2. Premise, measured: contract review 6674767869 (CONTRACT_REVIEW_TIER, head 42c89af2) judged the code on every axis (one predicate, no returnType read, stored fields and stored-date buckets byte-identical to main, all four faces alike char for char, native decline limited to grouped virtual dimensions, no weakened pin in the fixture triage) and failed only the declaration line; delta review 6674911226 (PASS, this head) clears it: Clause-②: no (narrowing) on both carriers, read by clause2-line.mjs. The yes (narrowing) spelling came from the seat's dispatch order and the claim 6673055694; the true declaration is the one on this head.
  3. Gates: 68 derived at 42c89af2: 67 exit 0, 1 exit 3 NOT MEASURED (check:dual-build-cjs-loads, whole-workspace build; CI runs it); full dogfood 422 files / 3521 tests green (title carries !), carried to this head by the analytics: a date bucket over a formula field with no stored column renders strftime in the /analytics/sql dry run and serves one null group in the draft preview, while both live faces refuse from the driver #29167 precedent (changeset-and-body-only delta). At this head the dev re-ran check-changeset-no-major, check-adr-0087-registration, check-empty-changeset and check:changeset-gate-self-tests, all exit 0. CI on this head was still running at ACCEPT; auto-merge lands only on every required check green.
  4. Accepted deviations: file-surface addendum, accepted by the seat: packages/services/service-analytics/src/strategies/native-sql-strategy.ts (the decline) and packages/services/service-analytics/src/strategies/objectql-strategy.ts at both sites (the echo's stored-door call, and assertCrossObjectAttrsStored in the echo and executeCrossObject; a cross-object dimension over a formula is still a dimension naming a formula field, and the old answer was data-dependent). Reviewer nits left as they are: the base aggregate runs before the cross-object refusal; the cross-object refusal is located at the FK-resolve query's position; the two GUARD fixtures accept any uncoded error for formula.
  5. Out of scope: the authoring-time refusal of a dataset or cube dimension over a formula (dataset-compiler.ts) is filed as analytics: a dataset or cube dimension over a formula field is accepted at authoring, though every query now refuses it (follow-up to #29304) #30773 (Metadata-protocol: narrowed, its own minor, contract review owed). No docs owed; Skills-docs: none holds.
  6. Landing: check-governed-merges --pr 30768: NOT governed, 560 lines. Ordinary queue landing by this seat: ready + auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 02:09
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 6, 2026 02:09
…ery sites

The envelope-caller census (#13079) counts producer-service analytics.query
call sites. The #29304 route pin in objectql-echo-non-date-bucket.test.ts adds
two, calling the plugin-registered AnalyticsService directly: NOT_SDK, as the
file's existing sites. Ledger row 2 -> 4; service sites and NOT_SDK total
17 -> 19. No logic change.

Claude-Session: https://claude.ai/code/session_016WV4cyTehJsfYGVMzcXLJD
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4cytQjG9X1CrCgytUM5

ACCEPT · #29304 · 2026-11-06T02:28Z · Head-sha: 8ca24327db0b7fc317c901e8cc60653a004e5643

席位核对

  1. Shape: one commit on top of 6bcd5781 (ACCEPT 6674940963), one file: packages/client/src/envelope-caller-census.test.ts +9/−5, ledger rows only. The objectql-echo-non-date-bucket.test.ts NOT_SDK row goes from 2 to 4 sites (the route pin's two analytics.query calls), the §2 producer-service count from 17 to 19, and the §3 NOT_SDK total from 17 to 19, each with a [#29304] line. Diff read by the seat.
  2. Premise, measured: Test Core (4/8) was red on 6bcd5781 on exactly these two census assertions (19 vs 17; the ledger off by the same two sites). The cause is this PR's new route pin, not main. The dev reproduced it locally and the census file now reads 21/21.
  3. Gates: client census 21/21, eslint on the file exit 0, check-empty-changeset and check-changeset-no-major exit 0. No source, changeset or declaration byte moved, so contract review 6674767869 and delta 6674911226 stand. CI is re-running on this head; auto-merge lands only on every required check green.
  4. Accepted deviations: a file-surface addendum: the census ledger file (rows only, no matcher logic).
  5. Out of scope: none new.
  6. Landing: unchanged: NOT governed; ready + auto-merge (still armed).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant