Skip to content

refactor(spec,objectql)!: retire AggregationNode.distinct — one face honoured it, five ignored it, and the same query answered two plausible numbers (#6815, ADR-0049) - #7051

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-6815-aggregation-distinct-retired
Aug 9, 2026
Merged

refactor(spec,objectql)!: retire AggregationNode.distinct — one face honoured it, five ignored it, and the same query answered two plausible numbers (#6815, ADR-0049)#7051
os-zhuang merged 2 commits into
mainfrom
claude/issue-6815-aggregation-distinct-retired

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #6815

AggregationNode.distinct is retired under ADR-0049. It was read by exactly one of the six faces that consume an aggregations[] entry: objectql's in-memory fallback deduplicated the values before applying the function, while every SQL face ignored it. So { function: 'sum', field: 'amount', distinct: true } answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource — one query, two numbers, chosen by which backend happened to serve it. Both answers are plausible, so nothing surfaced the split.

Maintainer ruling (2026-08-09), quoted verbatim

Maintainer ruling (2026-08-09): REMOVE. AggregationNode.distinct is retired per ADR-0049. [...] Implementation notes from the card, adopted: the key sits on the request surface (QueryAST builder output / POST /data/:object/query body), so no stored-metadata conversion is needed (the #4286 note applies verbatim); removal costs the in-memory fallback's collectValues flag and nothing else. The out-of-scope note stands — sum(*)-with-no-field dying as a raw dialect error is a separate gap, not to be smuggled into this PR.

Premise verification (before implementing)

Re-verified on origin/main in the worktree, and it holds:

One refinement the issue body did not have, measured rather than assumed: the blast radius inside the fallback was sum and avg only. count returns from its own branch before ever calling collectValues; count_distinct feeds the values into a Set, and dedupe-then-Set is Set; dedupe does not move min/max. Four of the six functions were already uniform. That narrows the impact statement without changing the verdict — the divergence is still real, still silent, and still decided by the backend.

The six-face table — a conclusion for every face, with grep evidence

This is the semantic surface of the change, so each face in the issue's table gets an explicit verdict. Expected end state for a removal: uniform by construction — no face reads the key. Confirmed.

Face Before After Evidence
objectql in-memory fallback read it CHANGED — dedupe limb deleted in-memory-aggregation.ts now calls collectValues(rows, field); the function's distinct parameter and its dedupe branch are gone
driver-sql SqlDriver.aggregate ignored zero change — already compliant every distinct hit in sql-driver.ts is either the SQL_AGGREGATE_FUNCTIONS lowering table ('count_distinct' maps to { sql: 'count', distinct: true }, keyed on the FUNCTION NAME) or builder.distinct(field), the driver's own distinct() door. No read of an aggregation entry's key
driver-turso RemoteTransport.aggregate ignored zero change — already compliant same shape (remote-transport.ts:541, 562-567, 1030, 1052); grep -c 'agg\.distinct|node\.distinct|entry\.distinct' returns 0
driver-mongodb buildAggregationStage ignored zero runtime change — already compliant. One dead DECLARATION deliberately left in place buildAccumulator(agg) (mongodb-aggregation.ts:120-154) switches on agg.function and reads agg.field; nothing else. ⚠️ Its local AggregationInput interface (same file, line 17) still declares distinct?: boolean — a hand-mirrored copy of the spec shape that nothing reads. Not in scope + #5499: this driver is inside the investment freeze, and the ruling scoped the cost to the fallback flag "and nothing else". Filed as an observation-class finding instead (below)
driver-memory computeAggregate ignored zero change — already compliant memory-driver.ts:1123 opens with const { function: func, field } = agg;distinct is structurally unreachable. Frozen under #5499; zero change was already compliance, so the freeze is not even engaged
service-analytics AGGREGATE_SQL ignored zero change — already compliant native-sql-strategy.ts:34 declares AGGREGATE_SQL as a Record of column-to-string functions — the lowering receives a column and nothing else, so there is no parameter a per-aggregation flag could ever arrive through. Structural, not incidental

What changed

  • Schemadistinct: retiredKey(AGGREGATION_DISTINCT_REMOVED) on AggregationNodeSchema. The prescription names the one honouring face, the five ignoring ones, and the live replacement. The constant is module-private (unlike QUERY_CURSOR_REMOVED / QUERY_DISTINCT_REMOVED, which are exported because two schemas re-declare those keys) — one tombstone covers every aggregation door, because QuerySchema.aggregations and EngineAggregateOptionsSchema.aggregations both reuse this one schema by reference rather than .extend()ing it.
  • objectqlcollectValues(rows, field) loses its distinct parameter and its dedupe branch, plus the module-header capability line. The whole runtime cost of the removal.
  • ADR-0087RETIRED_KEYS_BY_MAJOR[17] gains 'data/AggregationNode:distinct', plus a D3 SemanticMigration aggregation-node-distinct-retired and a step-17 rationale paragraph. No D2 conversion, per the ruling: QueryAST is a request surface with no stored source, the same disposition query-joins-retired / query-cursor-retired / query-distinct-retired / query-window-functions-retired already take.
  • Liveness ledgerquery.json aggregations.children.distinct flips live to dead with a note recording why the 2026-07-31 verdict was true evidence and still the wrong verdict, the ledger _note records the re-open, and the README row + counts regenerate to 15 / 1 / 5 / 0.
  • Docsprotocol/objectql/query-syntax.mdx and kernel/contracts/data-engine.mdx (hand-written); content/docs/references/**, spec-changes.json, docs/protocol-upgrade-guide.md, authorable-surface/data.json regenerated. authorable-surface/data.json gains data/AggregationNode:distinct [RETIRED] — the tombstone route's signature, versus the line vanishing on a strict removal.
  • Changesetmajor for @objectstack/spec and @objectstack/objectql, carrying the FROM/TO mapping and the adr-0087: registered marker.

Producer sweep (the #6866 precedent's other half)

Swept packages/client for a producer to delete alongside the key, as QueryBuilder.cursor() / .distinct() were deleted with theirs in #4286. There is none. QueryBuilder (packages/client/src/query-builder.ts) has no aggregation builder at all — grep -n 'aggregat\|AggregationNode' on that file returns nothing. The SDK's only contact with the shape is the pass-through type aggregations?: AggregationNode[] (client/src/index.ts:158,188), which inherits the tombstone from the spec type, so authoring distinct through the SDK is a tsc error with no producer edit needed. That is the desired end state the dispatch asked for, reached by construction.

Reverse verification — direction predicted BEFORE running

Predicted: RED, on all three new pin groups. Restoring the key to the schema and the dedupe limb to collectValues should make the rejection pins parse clean and make the fallback answer the deduplicated number again. Method: git checkout origin/main -- ... on the three source files (never git stash — shared stack), rebuild spec, re-run, restore.

Signal Predicted Measured
spec query.test.ts RED RED6 failed | 85 passed
objectql in-memory-aggregation.test.ts RED REDAssertionError: expected 500 to be 600, i.e. the dedupe limb collapsed the 100/100 pair; the count_distinct-unaffected pin stayed green, correctly
rest request-schema-gate.conformance.test.ts RED REDPOST /api/v1/data/:object/query accepted {"aggregations":[{...,"distinct":true}]} (status 200 ...): expected 200 to be 400
(unpredicted 4th) gen:schema gate (b2) not predicted RED, and correctly so1 RETIRED_KEYS_BY_MAJOR entr(ies) name a key that is still LIVE: data/AggregationNode:distinct. The registration gate refuses a retirement nobody performed, which is a second, independent proof that the tombstone is what the baseline is reading

All restored afterwards; the tree is back to the retirement and every gate is green on it.

Rejection-class assertions (ADR-0112, #6142)

The tombstone creates a new refusal face at the REST boundary, so the pin asserts the envelope, not the throw. The case rides the existing #3899 conformance loop, which asserts status === 400 and body.code === 'VALIDATION_FAILED' and a non-empty fields[] and that the protocol was never invoked. The distinct body is the only retired key on that route that is not top-level — it sits inside an aggregations[] entry, so the case also proves entry validation descends into the array. Spec-level pins assert the prescription's own text (@objectstack/spec 17, count_distinct, and each of the four ignoring driver names) plus the path of the reported issue (aggregations.0.distinct) on a mixed list where the sibling sum entry is legal.

Verification

Foreground, serialized on the shared lock.

  • pnpm --filter @objectstack/spec test gives Test Files 349 passed (349) | Tests 9094 passed (9094)
  • pnpm --filter @objectstack/objectql test gives Test Files 160 passed (160) | Tests 2766 passed (2766)
  • pnpm --filter @objectstack/rest test gives Test Files 73 passed (73) | Tests 1142 passed (1142)
  • turbo run build --filter='./packages/*' --filter='./packages/*/*' gives 70 successful, 70 total
  • turbo run typecheck (same filters) gives 120 successful, 120 total
  • Gate list enumerated from .github/workflows/lint.yml and run one by one — ESLint plus all ~40 check:* gates, every spec gate (check:liveness, check:empty-state, check:authorable-surface, check:api-surface, check:spec-changes, check:upgrade-guide, check:docs, check:generated, check:skill-*, check:variant-docs, check:exported-any, check:dual-source-exports), and check:i18n / check:i18n-coverage / check:app-nav-i18n after a full workspace build (they refuse to run on an unbuilt tree and say so). All PASS.
  • node scripts/check-adr-0087-registration.mjs --base origin/main gives 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. [major+bang] registered aggregation-node-distinct-retired (new here)
  • node scripts/check-nul-bytes.mjs OK, plus a targeted control-byte self-scan over every file this PR touches (clean).

Merge lap over main: merged origin/main (27 commits, including sibling #6998 / #4697), reset the generated tree to origin/main, committed the merge — the os-regen pre-commit hook refused the commit until the artifacts were regenerated from the merged tree, which is the mechanism working — then full regen. Sibling entries asserted by recounting from the file, not from history: ADR-0087 registry ids 50 to 51 with lost from main: [], and RETIRED_KEYS_BY_MAJOR[17] 11 to 12 with all eleven of main's keys present. Union-keep confirmed.

Out of scope, deliberately


Generated by Claude Code

claude added 2 commits August 9, 2026 07:46
…e honoured it, five ignored it (#6815, ADR-0049)

`AggregationNode.distinct` was read by exactly one of the six faces that consume
an `aggregations[]` entry: objectql's in-memory fallback deduplicated before
applying the function, while driver-sql, driver-turso, driver-mongodb,
driver-memory and service-analytics' AGGREGATE_SQL all ignored it. So
`{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated
sum on the fallback path and an ordinary sum on every SQL datasource — one
query, two plausible numbers, chosen by which backend served it.

Removed per the maintainer ruling of 2026-08-09: tombstoned with retiredKey()
(the schema is non-strict, so a bare deletion would silently strip what callers
still send), registered as RETIRED_KEYS_BY_MAJOR[17] 'data/AggregationNode:distinct'
plus the D3 semantic migration 'aggregation-node-distinct-retired'. No D2
conversion — QueryAST is a request surface with no stored source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 11:51am

Request Review

@github-actions github-actions Bot added the size/m label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec.

109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/objectql, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 9, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 9, 2026 12:22
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 3f7f14e Aug 9, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6815-aggregation-distinct-retired branch August 9, 2026 12:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[spec/drivers] AggregationNode.distinct is honoured by the in-memory fallback and ignored by every SQL face — one query, two numbers (ADR-0049)

2 participants