Skip to content

fix(spec,objectql): name the aggregated column at having, PostgreSQL only at where - #20577

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20510-aggregated-column-refusal-words
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-20510-aggregated-column-refusal-words

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20510

Clause-②: no

What changed

Two false phrases in the number-comparand refusal, at two positions:

  1. having said "a declared number field". The column at having
    (total, an aggregation alias or a groupBy projection) has no declared
    FieldType of its own — the door hands the verdict a synthesized
    { type: 'number' } for whatever the engine classes numeric
    (aggregatedRowColumnClasses). It now says "a numeric aggregated column".
  2. The not-a-number / boolean / date clauses named PostgreSQL's server
    error at every position.
    Only where (both spellings) ever reaches a
    live driver bind; having and the per-aggregation filter are evaluated
    by the engine itself, in JS, before any row is read, on every driver
    alike — no driver ever sees the comparand there. The clause now appears
    only where the driver actually binds it.

Measured, and the card's own claim corrected: the per-aggregation
filter's column is a real declared field, not an aggregated one — that
position narrows the object's RAW source rows before aggregation runs,
against the object's declared field map (confirmed by both the door's own
header and the existing pin at aggregations[1].filter.f_number.$gt, which
already reads 'f_number' compares a declared number field). So only its
PostgreSQL clause moves; its "declared … field" wording is unchanged.

Before / after

position column before after
where declared field … compares a declared number field against "abc" …, which is not a number: it has no numeric reading, and backends answer it differently (PostgreSQL with a server error). unchanged, byte for byte
per-aggregation filter declared field … compares a declared number field against "abc" …: it has no numeric reading, and backends answer it differently (PostgreSQL with a server error). … compares a declared number field against "abc" …: it has no numeric reading.
having aggregated column … compares a declared number field against "abc" at having.total.$gt, which is not a number: it has no numeric reading, and backends answer it differently (PostgreSQL with a server error). … compares a numeric aggregated column against "abc" at having.total.$gt, which is not a number: it has no numeric reading.

The accept set is unchanged — no refusal added or removed, words only.

Mechanism

NumberComparandRefusalSite (@objectstack/spec) gains two optional fields,
both defaulting to the pre-existing where behaviour so an existing site (or
one built before this change) renders identically:

  • aggregated?: boolean — the column is an aggregated-row column, not a
    declared field. Only having sets it.
  • boundByDriver?: boolean — this position reaches a live driver bind. Only
    where sets it true; default is true for backward compatibility.

The engine door (@objectstack/objectql) passes both at its three call
sites — it already knew which position it was building a refusal for; no
second rule, and no driver-level change.

Acceptance notes

Tests (all green, fresh builds)

  • @objectstack/spec build, then vitest run filter-number-comparand-declared-type.test.ts
    — 47/47 passed, including new cases for aggregated / boundByDriver
    and a byte-for-byte pin that the pre-existing where message is unchanged;
    pnpm typecheck — clean (no new errors).
  • @objectstack/objectql build, then vitest run engine-number-comparand-declared-type-door.test.ts engine-aggregate-having-temporal-door.test.ts — 85/85 passed; pnpm typecheck — clean.
  • @objectstack/rest build, then vitest run data-number-comparand-door.test.ts
    — 4 passed, 8 skipped (PostgreSQL/MySQL cells skipped as before; no code
    change was needed there).
  • check:api-surface, check:dual-source-exports, check:entry-nameability,
    check:exported-any, check:lean-entry-closure — all green against the
    fresh build (the widened NumberComparandRefusalSite type doesn't move the
    name-level api-surface snapshot).
  • Derived gate sweep (dispatch-gates.mjs, 86 commands): all green except 3
    that need the WHOLE workspace built (check:doc-formula-expressions,
    check:dual-build-cjs-loads, check:type-check-debt --re-measure) — out
    of this diff's local scope, left to CI.

Full command list and exit codes are in the report comment on the issue.


Generated by Claude Code

…reSQL only where a driver binds

The number-comparand refusal said "a declared number field" at `having`,
where the column is an aggregation alias or a groupBy projection with no
declared FieldType of its own; it now says "a numeric aggregated column"
there. The not-a-number/boolean/date clauses named PostgreSQL's server error
at every position; they now do so only at `where`, the one position a driver
actually binds — `having` and the per-aggregation `filter` are evaluated by
the engine itself, on every driver, before any read. The per-aggregation
`filter` genuinely reads a declared field (it narrows raw rows before
aggregation), so its "declared ... field" wording is unchanged; only its
PostgreSQL clause moves.

`NumberComparandRefusalSite` gains two optional fields the engine door
already knew: `aggregated` and `boundByDriver`, both defaulting to the old
`where` behaviour so an existing site renders byte-for-byte unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

14 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

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

Coarse fallback — 138 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 f572a7eb3c272a5be85e3ec64d2516c944c3b243 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

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

Read: card #20510 (body, triage grade 5879512564, the seat's scope note 5882219550, the claim 5882482745, the os-dev-report 5883674027); PR #20545's at-tier record 5882199114 (its #20510 paragraph); PR #20577 (body, its one comment, the 6-file list, the net diff against the merge-base b05743433b); the source at the head that the diff touches or calls (packages/spec/src/data/filter-number-comparand-declared-type.ts, packages/objectql/src/number-comparand-declared-type-door.ts, engine.ts at the four door call sites and the aggregate execution fork, having-filter.ts's aggregatedRowColumnClasses, the three test files, packages/rest/src/data-number-comparand-door.test.ts, packages/objectql/src/index.ts and package.json, .github/workflows/ci.yml / lint.yml / pr-automation.yml, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs); the objectui sibling at the pinned .objectui-sha; and the check-runs on the head, read once, at the reading declared in ③9.
Rendered at 2026-09-29T04:43Z.

① Derived judgments

  1. The accept set is unchanged — no refusal added or removed. numberComparandDoorVerdict and numberComparandFieldVerdict are untouched by the diff; the door's judgeComparand still routes on the verdict alone (narrows kept, anything but door-refusal kept, door-refusal refused) and the new ctx only decorates the refusal object it already built. FORM_SENTENCE keeps all eleven forms; three entries (not-a-number, boolean, date) became functions of boundByDriver, the other eight are byte-identical strings. Right.

  2. The where message is byte-for-byte unchanged on both spellings. Both where call sites — the object form (engine.ts:1024) and the lowered FilterArray form (engine.ts:1110) — call narrowNumberComparands(object, operation, schema, where) with the default path = 'where', which selects WHERE_SITE (aggregated: false, boundByDriver: true); judgeComparand writes aggregated only when true and boundByDriver only when false, so a where refusal site carries neither key and is the same object it was before this diff. numberComparandRefusalMessage then reads site.boundByDriver ?? true and site.aggregated (falsy), and the three bound clauses render the exact pre-diff strings: it has no numeric reading, and backends answer it differently (PostgreSQL with a server error). / a boolean names no number (true is not 1), and backends answer it differently (PostgreSQL with a server error). / a Date is an instant, not a number, and backends answer it differently (PostgreSQL with a server error); compare a Date with a date or datetime field. — compared character by character against the removed lines of the diff. Pinned byte-for-byte in the spec suite for all three forms at where.amount.$gt with the find('deal') prefix. Right.

  3. Truth per position, composed from the head's code.

    • where, both spellings, declared number field, forms not-a-number / boolean / date: filter on 'amount' compares a declared number field against … at where.amount.$gt, which is not a number: … and backends answer it differently (PostgreSQL with a server error). Every clause true: the key names a declared field (declaredMetaOf(schema) reads the object's own field map), and where is the one position a driver binds — the door header's own measurement table records 500 DATABASE_ERROR on PostgreSQL 16 for $gt "abc", $gt true and a Date at where. array carries no PostgreSQL clause before or after. Right.
    • Per-aggregation filter, declared number field: aggregate('deal'): filter on 'amount' compares a declared number field against "abc" at aggregations[1].filter.amount.$gt, which is not a number: it has no numeric reading. (boolean: a boolean names no number (true is not 1).; date: a Date is an instant, not a number; compare a Date with a date or datetime field.). "A declared number field" is TRUE here and the dev's correction of the card stands: engine.ts:16362 calls narrowNumberComparands(object, 'aggregate', this._registry.getObject(object), aggFilter, 'aggregations[i].filter'), i.e. the object's declared field map, because that filter narrows the object's RAW rows; and the engine evaluates it itself — hasAggregationFilter (engine.ts, the aggregate fork) forces the rows path, delete rowsAst.aggregations strips the key from the find the driver receives, and applyInMemoryAggregation applies the filter per source row; both driver faces (driver-sql's unsupportedAggregationFilterError, driver-memory's refusePerAggregationFilter) refuse the key with 501 if ever reached directly. No driver sees the comparand, so naming PostgreSQL there was false and dropping it is right. The pre-existing pin aggregate('number_door_probe'): filter on 'f_number' compares a declared number field at aggregations[1].filter.f_number already asserted the noun. Right.
    • having, column total: aggregate('deal'): filter on 'total' compares a numeric aggregated column against "abc" at having.total.$gt, which is not a number: it has no numeric reading. (and the same for boolean / date / array). The noun is true for every column the door can judge there: narrowHavingNumberComparands hands the walk { type: 'number' } only where aggregatedRowColumnClasses classes the column numeric — a count / count_distinct / sum / avg alias, a min / max of a numeric declared field, or a groupBy projection of one — all columns of the AGGREGATED ROW with no FieldType of their own (the repo's own having vocabulary: having-filter.ts's docblock, the objectql: a having key that names no column of the aggregated row keeps no group silently — having: { totl: { $gt: 100 } } answers 200 [], where an unknown where field is refused 400 #20123 / objectql having: a { $field } reference with addDays against a non-temporal aggregated column answers by epoch-ms coercion, where SQL push-down refuses the same pair on where — the aggregated row declares no temporal class to judge it by #20127 changesets, queries.mdx). No PostgreSQL is true: on the native path drv.aggregate(object, ast) is followed by the engine's applyHaving and no driver source reads ast.having (grep over every driver package's non-test source: none); on the rows path delete rowsAst.having. Right.
    • The declaredType field at having is the class member number and is no longer printed there (the interface's TSDoc says so). Right.
  4. The site type. NumberComparandRefusalSite gains aggregated?: boolean (default false) and boundByDriver?: boolean (default true). Defaulting boundByDriver to true is correct for every existing caller: every site built before this diff was rendered with the PostgreSQL clause, and every in-repo builder of a site is the objectql door, which now sets both facts at all three call sites (WHERE_SITE / AGGREGATION_FILTER_SITE / HAVING_SITE). Consumer census at the head: no source outside the two claimed files names NumberComparandRefusalSite, numberComparandRefusalMessage, findNonNumericComparand or NonNumericComparand; only engine.ts imports the door module; findNonNumericComparand is not on objectql's published entry (package.json exports are . and ./core; index.ts re-exports nothing from the door), so the refusal object's boundByDriver: false key at a non-where path reaches no published consumer; the objectui checkout at the pinned sha dd3f7e1be3 imports none of the three names. A published consumer of @objectstack/spec that builds a site without the new keys renders byte-identically (pinned: "unset boundByDriver defaults to true", "a non-aggregated site keeps saying a declared … field, set or not"). Right.

  5. api-surface / export-origins need not move. Both snapshots are name-level: api-surface/data.json:493 carries NumberComparandRefusalSite (interface) and export-origins/data.json:483 its origin; no export is added, removed or renamed, and the two new members are not what either ledger records (check:api-surface "records that an export exists, never what it resolves to" — the AGENTS.md note on the gate). The dev's local check:api-surface exit 0 agrees; CI's carrier is Type Check · consumer gates (lint.yml:6510), not concluded at the read. Right.

  6. No second rule, no driver change. The door's diff threads one RefusalSiteContext through the walk and its three entry points; the walk's structure ($and / $or / $not descent, dotted and $-key skipping, scalar and list-member judging, copy-on-write) is unchanged line for line. No driver package is in the file list. Right.

  7. Pins, cell by cell. Spec suite (new [#20510] describe): the aggregated noun at having.total.$gt with not.toContain('declared'); the declared noun with aggregated: false and with the key unset; the PostgreSQL clause present with boundByDriver: true and absent with false for exactly the three forms that ever carried it, with a length check that nothing else moved; the other eight forms identical under both flag values; the unset default equal to true for the three forms; and the byte-for-byte where pin for all three forms. Objectql suite: at having over count / sum / a numeric min, $gt and $ne, for a string and for each of true / false / a Date / an array — the noun present, declared absent, PostgreSQL absent, zero reads; at the per-aggregation filter for the four non-string comparands at $gt / $lte — the declared noun kept (regex anchored at the message head) and PostgreSQL absent, zero reads; the temporal-door suite's having pin moved to the aggregated noun with PostgreSQL absent for its three aliases; the FilterArray where pin still asserts compares a declared number field against true at where.f_number.$gt. Each flipped pin asserts code and status beside the words. Right (one absent positive pin named in ③4).

  8. The REST suite is untouched, correctly. packages/rest at the head names the moved phrases nowhere except data-number-comparand-door.test.ts:274 and :280, both where positions asserting filter on 'amount' compares a declared number field — unchanged text; its per-aggregation filter and having cells (:252, :260, :283, :288, :296, :300) assert status, code and the position path only, and no REST pin names PostgreSQL. Nothing there would flip and nothing there was owed. Right.

  9. The file surface matches the claim. The spec contract and its test, the door and the two objectql tests whose pins carried the moved words, the changeset; no REST edit (the claim allowed one only where a moved phrase is asserted — none is), no generated artifact (the claim conditioned it on the site type moving at name level — it does not). Right.

② Semver level

  • @objectstack/spec patch, @objectstack/objectql patch: the act is a correction of published refusal text (a bug fix in a released package, which the checklist grades patch); no exported symbol is added to an index, no authorable key or value is newly accepted, no accept set moves, and the name-level public surface is unchanged — the WHICH LEVEL rule in pr-automation.yml ("a fix( that changes no public surface stays patch"; the minor floor is for "a new exported symbol on an index, a new accepted key or value"). The two optional members on NumberComparandRefusalSite are the mechanism, not a new capability a consumer must adopt: unset they render exactly as before, and this family's own precedent (PR fix(spec)!: a boolean, a Date or an array compared against a number field is refused like a non-numeric string (#20502) #20545's record, ①9 / ②) did not count member-level widening of the same interface as a level driver. Right.
  • Not skip-changeset: both packages publish and both are named. Right.
  • Clause-②: no, in the PR body and in the changeset, and no direction arm: clause ② is "a new key on a published payload" (check-changeset-no-major.mjs header; clause2-line.mjs); two optional TS members on an in-process input type are not one, exactly as three TS exports were not one on fix(spec)!: a boolean, a Date or an array compared against a number field is refused like a non-numeric string (#20502) #20545; nothing narrows, so no (narrowing) arm and no **BREAKING** banner or ADR-0087 marker is owed. Check Changeset on this head concluded success. Right.

③ Boundary flags

  1. Dev deviation — writes through the fleet-write dispatch relay. A transport fact of a cloud seat container, named with its three endpoints; nothing about the diff. Not escalated.
  2. Dev deviation — check:doc-formula-expressions, check:dual-build-cjs-loads, check:type-check-debt --re-measure NOT MEASURED locally. Each needs the whole workspace built; CI's Lint & Repo Gates, Type Check · workspace and Type Check · debt ledger carry them and none had concluded at the read (③9). Not escalated; their conclusions are the gate verdicts the seat reads before enqueue.
  3. Dev deviation — the PostgreSQL cell of @objectstack/rest's data-number-comparand-door.test.ts NOT MEASURED. Read from ci.yml: every OS_TEST_POSTGRES_URL provision sits in the Temporal Conformance (live PG + MySQL) job, for the driver-sql suite, the metadata-protocol migration statements and the runtime cascade-delete matrix — not for @objectstack/rest, so CI does not answer that cell either; it is the shape the predecessor landed with. It is not needed to judge this diff: every changed byte sits at or before the refusal, which precedes driver resolution (zero reads pinned at all three positions), and the REST suite asserts only status and path at having / the per-aggregation filter and an unchanged phrase at where. Named, not a FAIL.
  4. One absent positive pin. The objectql suite asserts the PostgreSQL clause's ABSENCE at having and the per-aggregation filter and the declared noun at where, but no engine-level pin asserts the clause's PRESENCE at where; that half rests on the spec suite's byte-for-byte pin of the builder's default plus the code reading in ①2 (a where site carries no new key, so it is the pre-diff object). Sufficient for this record; a where-level toContain('PostgreSQL') would close it in a later touch of that suite. Not escalated.
  5. The groupBy-projection row of the having noun. "A numeric aggregated column" for a groupBy projection of a numeric declared field is judged right under the repo's having vocabulary (the having bullet of ①3) and the triage direction that named the phrase; no pin drives that row (the suites drive count, sum and a numeric min alias). Noted, not escalated.
  6. The card's per-aggregation filter half was wrong and the dev corrected it from the engine rather than following the card's prose — the reading in ①3 confirms it. Not escalated.
  7. open_questions: none in the report. Nothing to answer.
  8. Landing shape. No governed surface in the six paths; head repo is the base repo (not a fork); 294 changed lines; Fixes #20510 with the card's newest Claim: naming this branch (the closing-card check concluded success); draft as the relay forces — ready-then-arm is the seat's act once every required check concludes green.
  9. Check-runs on the head, read once at the reading below, 32 runs: 14 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 15 in progress, none red. Answered green: Build Core, Check Changeset, Type Check · source gates, Spec property liveness, Governed Surface Queue Guard, Check PR Size, Check Documentation Links, Auto Label, filter, Flag docs affected by code changes, and the four claim / single-writer / part-of guards. Not concluded at the read, not presumed green: Test Core (1/6) through (6/6) (the spec, objectql and REST SQLite pins in CI), Lint & Repo Gates, Type Check · consumer gates (carries check:api-surface / check:exported-any / check:dual-source-exports), Type Check · workspace, Type Check · debt ledger, Temporal Conformance (live PG + MySQL) (driver-sql, not this diff's cells), Dogfood Regression Gate (1/3) through (3/3), Dogfood Verify CLI. Their conclusions are the gate verdicts; this record does not stand in for them.
    Check-runs reading: 2026-09-29T04:34Z

Implemented-by: claude/issue-20510-aggregated-column-refusal-words
Reviewed-by: session_014EJ1ED8X4MMrT18BhVx4tx

VERDICT: PASS


Generated by Claude Code

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

2 participants