Skip to content

fix(objectql)!: having resolves placeholders through the where resolver, and the per-aggregation filter refusals name aggregations[i].filter (#20334) - #20368

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20334-aggregate-positions-parity
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20334-aggregate-positions-parity

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20334
Clause-②: no (narrowing)

What this does

Two where behaviours the other filter positions of engine.aggregate lacked, per triage 5861202636's three execution notes, and a third change the seat's answer 5863946181 ordered as a patch round on this PR (open question 1 = B).

  1. having resolves {placeholder} tokens through the resolver where uses. ObjectQL.resolveWhereTokens now takes the AST slot as a parameter ('where' by default, 'having' from aggregate), and aggregate calls it once for having, after the per-aggregation filters resolve and before the middleware chain. ⛔ No second resolver: the call is the same method, through the same stage function (resolveWhereFilterTokens → @objectstack/core's resolveFilterTokens) that where and the judge use. An unknown token is FILTER_TOKEN_UNKNOWN / 400 and a context token with no value is FILTER_TOKEN_UNRESOLVED / 400, in where's words, before any driver read. A known token compares as the value it names.
  2. The per-aggregation filter's refusals name their position. assertTemporalComparandsInterpretable and assertTextOperatorTargetsAreStringCapable take an optional path (default 'where'), and the per-aggregation loop passes `aggregations[${i}].filter`, the root its list-shape and comparand-type doors already pass.
  3. The having temporal refusal's remedy is where's (patch round). assertHavingTemporalComparandsInterpretable now ends its refusal in REMEDY[hit.kind], the table the where and per-aggregation refusals read, and the parallel HAVING_REMEDY table and its docblock are deleted (net 1 line added, 16 removed). A string a date or datetime column cannot read now gets the remedy naming the relative-date placeholder ("{30_days_ago}" / "{current_month_start}"), which having resolves from item 1 on. DATE_YEAR_REMEDY is untouched, and the time kind's words are the same string as before (HAVING_REMEDY.time was REMEDY.time). REMEDY's own docblock names no reader, so it does not misstate one and is not edited.

Files: packages/objectql/src/engine.ts (resolveWhereTokens, aggregate), temporal-comparand-door.ts and text-operator-declared-type-door.ts (the path parameter; in the first, also the having remedy of item 3), two new pins (objectql/src/engine-aggregate-positions.test.ts, rest/src/rest-aggregate-positions.test.ts), one updated pin (objectql/src/engine-aggregate-having-temporal-door.test.ts: the unknown-token row replaced, the remedy case flipped), .changeset/20334-aggregate-positions.md (new: minor, BREAKING narrowing, ADR-0087 not-required (no-migration-prescription)), and a DELIBERATE CORRECTION to .changeset/20263-having-temporal-comparand-door.md (two sentences; each rewrite is listed under Deviations). Both door functions are package-internal: only engine.ts imports them, and neither src/index.ts nor src/core.ts re-exports them.

How having reaches the one resolver (H2)

The path (H3)

  • At base the per-aggregation loop called assertTemporalComparandsInterpretable(object, 'aggregate', schema, aggFilter) with no path, and the walk rooted at its default 'where'.
  • The doors that already name their position there, quoted from the base: the list-shape door, "Received string ("2026-01-10") at aggregations[1].filter.placed_on.$in"; the comparand-type door, "Filter comparand at aggregations[1].filter.amount.$eq is a plain object". The walker's refusals name `aggregations[1].filter`, and the materializable door names no path at all.
  • Partly falsified: a second door had the same defect. The text-operator declared-type door ([Decision] refuse a text operator ($contains family) over a field whose DECLARED type is not textual — INVALID_FILTER 400 at the engine's field-aware door (option C of #14079); the textual-type vocabulary is the question #15661) was also called with no path: $contains on a number field in aggregations[1].filter said at where.amount.$contains at base, on all three drivers and both doors. It is fixed here the same way, as a bounded in-place fix (see Deviations). Head: at aggregations[1].filter.placed_on.$gt, at aggregations[1].filter.$or[1].placed_on.$lt, at aggregations[1].filter.amount.$contains, and at aggregations[2].filter.… when the filter sits on the third aggregation.

Measured: base 26daf0b036 and head, three drivers, both doors, both having paths

InMemoryDriver, SqlDriver on SQLite and SqlDriver on PostgreSQL 16.13 (a private role and database on the system cluster, database timezone Asia/Shanghai, process TZ=America/New_York), through engine.aggregate and POST /api/v1/data/:object/query (JSON round-tripped), four groups c1–c4 (max(placed_on) 2026-01-10 / 03-01 / 01-15 / 02-01, sum(amount) 500 / 1200 / 50 / 20). 432 cells per tree; driver reads counted. The head runtime is the objectql source at f78b1e0c9d. The later commits of the first round change tests, changesets and one doc comment, and engine.ts is byte-identical (blob 1c4f6d0a41c2) at a1a42d4c4a and at the patch round's head 8b950b8e. The patch round changes one runtime string, the having remedy, measured in its own section below. The three drivers and both doors agree on every row below unless the row says otherwise.

position · input base head where twin
having { last_placed: { $gt: '{current_year_start}' } } 200, no group 200, c1–c4 (the literal '2026-01-01' twin: c1–c4) 200, c1–c4
having { last_placed: { $gte: '{not_a_token}' } } 200, no group, 1 read FILTER_TOKEN_UNKNOWN / 400, 0 reads FILTER_TOKEN_UNKNOWN / 400
having '{TODAY}' (near miss), an unknown token on count, under $and or $or 200 (no group; $or kept c2) FILTER_TOKEN_UNKNOWN / 400, 0 reads —
having {today} $lte / {30_days_ago} $gt / {current_month_start} $lt c1–c4 / none / c1–c4 (text order) c1–c4 / none / c1–c4 (resolved; the same groups by this data) resolved
having {7_months_ago} $gt, $between ['{current_year_start}', '2026-02-01'], {current_year_start} $gte on min(opened_at), $not of a token none, none, none, c1–c4 c2; c1, c3, c4; c1–c4; none —
having $or: [{ total: { $gt: 1000 } }, { last_placed: { $gt: '{current_year_start}' } }] c2 c1–c4 —
having { customer_id: '{current_user_id}' }, user c2 none c2 c2
having the same with no user · {current_org_id} with no org (engine) · {record_id} none FILTER_TOKEN_UNRESOLVED / 400, 0 reads (REST with no user: 401 before and after) FILTER_TOKEN_UNRESOLVED / 400
having {current_org_id}, user c2, no org, both doors none FILTER_TOKEN_UNRESOLVED / 400 —
having { total: { $gt: '{today}' } } on sum none none, except PostgreSQL native: c1, c3 —
aggregations[1].filter { placed_on: { $gt: 'not-a-date' } } 400 INVALID_FILTER at where.placed_on.$gt 400 INVALID_FILTER at aggregations[1].filter.placed_on.$gt 400 at where.placed_on.$gt
aggregations[1].filter the same under $or, and the number for 10000-01-01 at where.… at aggregations[1].filter.… at where.…
aggregations[1].filter { amount: { $contains: '5' } }, { placed_on: { $startsWith: '2026' } }, and under $or at where.amount.$contains … at aggregations[1].filter.amount.$contains … at where.amount.$contains

The PostgreSQL native sum cell is not a new divergence. Its literal twin { total: { $gt: 'TODAY-AS-A-DAY' } } keeps c1, c3 there on the base too (PostgreSQL's native aggregate returns sum as a string, #20307's out-of-scope finding 3, the region #20335 holds). The resolved token compares exactly as that literal does.

Collateral (H4)

  • where: 48 of 48 cells byte-identical base → head (status, code, message, groups, reads): tokens resolved and refused, the temporal and text-operator refusals, {current_user_id} with and without a user.
  • having without a token: 96 of 96 cells byte-identical at a1a42d4c4a, including the temporal refusals in their words (the patch round then moves one thing among them, the date / datetime temporal refusal's remedy, measured in its section below), a nested $or refusal, a{b}c (braces inside a string, not a placeholder), a { $field } reference and the literal twins.
  • Per-aggregation filter, every other door: 36 of 36 byte-identical (list-shape, comparand-type, $median, $field, the unknown-token refusal and a resolved token's counts).
  • The per-aggregation temporal and text-operator refusals: code, status and reads unchanged, and at the engine the message differs in the at where. → at aggregations[1].filter. root alone (18 of 18 cells). Over REST the message is the engine's under the existing 500-character bound (truncateClientMessage), on every cell of both trees. The two 'not-a-date' refusals (489 and 496 characters at base) now cross the bound and lose the end of their remedy ("{current_month_s…). The year-class and text-operator refusals were already cut at base. The changeset says so.
  • A known token now compares as its resolved value, not as text: pinned as "the token keeps exactly the groups the value written out keeps" on both paths, for ten shapes (a comparand, $in, both $between endpoints, a bare day on min(datetime), $and / $or / $not) and {current_user_id}.

Declaration and who is reached (H5)

  • The claim's Clause-②: no (narrowing) holds. An unknown token and an unresolvable context token on having answered 200 and now answer 400, which narrows the accept set. A known token that compared as text now resolves, which is a changed answer rather than a narrowed accept set. node scripts/check-changeset-no-major.mjs --base origin/main: "✓ This diff introduces no major bump." node scripts/check-adr-0087-registration.mjs --base origin/main: "✓ … 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", .changeset/20334-aggregate-positions.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription).
  • Shipped authors of a {…} string on having: none. There are five having clauses in content/docs and skills/: queries.mdx twice, query-syntax.mdx and skills/objectstack-query/rules/aggregation.md twice. Each compares order_count / total_spent / total with a number. Outside the engine and the spec that declare it, no runtime code, example app or apps/ file composes a having. The control grep, a where / filter carrying a token in examples/, hits 16.

Patch round: the having remedy (seat answer 5863946181), measured

Before is c599f758 (the merge of main at c577e66635 into a1a42d4c4a, HAVING_REMEDY still read). After is 8b950b8e, whose temporal-comparand-door.ts is the db334ac4 blob 3ee7abf0c722.

  • Where the words are built (H1): one site, as hypothesised. It is assertHavingTemporalComparandsInterpretable in packages/objectql/src/temporal-comparand-door.ts, the only line carrying "The having was NOT applied.", and it read HAVING_REMEDY[hit.kind] once. It now reads REMEDY[hit.kind], and git grep HAVING_REMEDY answers 0 at 8b950b8e. The year-class branch still reads DATE_YEAR_REMEDY.
  • The pin sweep (H2), partly falsified. Terms swept across this repository (every tracked file) and ../objectui: HAVING_REMEDY, "The having was NOT applied", "a 200 indistinguishable from a real answer", "keep no group or every group", the two old remedy strings, "names no placeholder", "literal forms only", "remedy names none", assertHavingTemporalComparandsInterpretable, and {30_days_ago} / current_month_s in test files. Only one pin read the old remedy: engine-aggregate-having-temporal-door.test.ts, whose remedy case asserted not.toContain('{30_days_ago}'). It is flipped (Deviations). packages/rest/src/data-query-having-temporal-door.test.ts asserts only the column clause of the REST error (toContain(column)), which sits before the remedy, and no remedy words, so it has nothing to flip. The other having message-equality pins (engine-aggregate-positions.test.ts, rest-aggregate-positions.test.ts, engine-aggregate-having-comparand-shape.test.ts) compare token or walker refusals, not this door's. No doc, skill or objectui text quotes the having remedy. Every rejection assertion for a genuinely illegal shape is unchanged.
  • What moved, at the engine. 16 having cells (object ledger_having, SqlDriver on SQLite, both having paths) plus the two where twins were each read at both commits. 26 of the 26 moved cell-paths differ from before in the remedy alone: substituting the new remedy for the old one in the before message gives the after message byte for byte. The 8 unmoved cell-paths are the time column, the year class and the where twins. Both paths give one message in every cell. The having remedy equals the where twin's remedy byte for byte, on a date and on a datetime column.
  • Over REST, the 500-character bound (H3), confirmed. The bound (CLIENT_MESSAGE_MAX, a message of 500 or more characters is cut to 499 plus …) is unchanged. A date column's fixed words plus its new remedy take 409 characters, which leaves 90 for the object name, the column, what it aggregates, the quoted comparand and the path. A datetime column's take 502, so every such refusal is cut. "Whole" below means the REST error equals the engine message byte for byte.
having cell (date unless stated) engine characters, before → after REST, before → after
'not-a-date' $lt on max(placed_on) 382 → 481 whole → whole
'+010000-01-01T00:00:00.000Z' $gt on max(placed_on) 399 → 498 whole → whole (the longest measured)
an $in member, a $between endpoint, the implicit-equality slot, under $or, under $not 385, 390, 378, 389, 387 → 99 more each whole → whole
a placed_on groupBy key, a day bucket of opened_at 391, 375 → 490, 474 whole → whole
'not-a-date' $lt on min(opened_at) (datetime) 480 → 576 whole → cut: …epoch milliseconds, or a relative-date pl…
'not-a-date' on an opened_at groupBy key (datetime) 487 → 583 whole → cut: …milliseconds, or a relative-…
'last_30_days' on max(placed_on) / min(opened_at) 385 / 483 → 484 / 579 VALIDATION_FAILED / 400 from the query schema, before and after: REST never reaches the engine with a preset name
control: 'not-a-date' / 'noon' on max(slot) (time) 412 / 406, unchanged whole
control: the number for 10000-01-01 on max(placed_on) (year class) 555, unchanged cut, before and after
control, where: 'not-a-date' on placed_on / opened_at 485 / 578, unchanged whole / cut at …or a relative-date …, before and after

The changeset states this: every measured date refusal arrives whole, and a datetime refusal is cut inside the remedy, as where's already was.

  • Ablation of the flipped pin, at 8b950b8e. It ran through scripts/ablation-replace.mjs (WRAP) under one verify lock. The anchor The \`having\` was NOT applied. ${REMEDY[hit.kind]} was replaced by the old literal-only remedy table, inlined behind the marker ABLATION-20334-LITERAL-ONLY. The tool read the anchor x1 → x0, the replacement x0 → x1, and the blob 3ee7abf0c722 → bfe6275fe997. Result: engine-aggregate-having-temporal-door.test.ts had 1 failed / 50 passed, the failure being the flipped case: AssertionError: expected 'Write a "YYYY-MM-DD" calendar day.' to contain '"{30_days_ago}"'. The direction is red, as predicted. The restore was proven: blob == HEAD 3ee7abf0c722, git diff HEAD empty, tree clean. The test imports ./engine.js, the source, so the leg needed no build. The built dist/ (from db334ac4, the same objectql source) carries 0 hits of the marker. The whole objectql suite ran green again after the restore (below).

Tests and evidence, patch round (head 8b950b8e, after merging main at c577e66635)

main at c577e66635 brought no change to objectql, core or the drivers. It changed packages/rest/src (the draft-read builder gate), packages/core/src/qa and spec. The merge is c599f758, a true merge commit with parents a1a42d4c4a and c577e66635, through scripts/pm/os-regen-merge.sh: no generated artefact was pending, and main's side was taken for every os-regen path. The rest closure was rebuilt from it (turbo run build --filter='@objectstack/rest^...', 24 tasks, exit 0), and objectql was rebuilt after the fix (dist carries the new remedy strings and 0 of the old).

  • pnpm --filter @objectstack/objectql exec vitest run --project local --project repo --maxWorkers=2: 324 files, 5890 tests passed.
  • pnpm --filter @objectstack/rest exec vitest run --project local --project repo --maxWorkers=2: 213 files, 3845 passed, 2 skipped (main added two files).
  • engine-aggregate-having-temporal-door.test.ts alone: 51 passed. The flipped remedy case is one of them, and it goes red under the ablation above.
  • pnpm --filter @objectstack/objectql run typecheck: exit 0, with the test layer: "40 file(s) / 234 error(s) / 65 pinned signature(s) held", unchanged. tsc -p tsconfig.test.json --listFiles lists engine-aggregate-having-temporal-door.test.ts (1 hit). pnpm --filter @objectstack/rest run typecheck: exit 0.
  • ESLint, narrowed and proven, at 8b950b8e: eslint --no-inline-config --format json over the 6 .ts files this PR changes against the merge base counts 6 files in the JSON, 0 errors and 0 warnings. The population is read from eslint.config.mjs (packages/**/*.{ts,tsx,mts,cts}). Invariance: the config "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules)" (its own note), so this diff cannot move a verdict on an untouched file.
  • The REST measurement above ran as a scratch test file placed in packages/rest/src for one run at each commit and deleted after it. It was never committed, and the tree was clean after each run.

Tests and evidence, first round (head a1a42d4c4a, after merging main at 29720975b6, which touched none of these packages)

  • pnpm --filter @objectstack/objectql exec vitest run --project local --project repo --maxWorkers=2: 324 files, 5890 tests passed.
  • pnpm --filter @objectstack/rest exec vitest run --project local --project repo --maxWorkers=2: 211 files, 3819 passed, 2 skipped.
  • pnpm --filter @objectstack/driver-memory exec vitest run --maxWorkers=2: 57 files, 1374 passed.
  • driver-sql's nine aggregate test files (src/*aggregat*), with OS_TEST_POSTGRES_URL on the private PostgreSQL 16 database and TZ=America/New_York: 9 files, 174 passed, 4 skipped. The suite's own summary: live postgres RAN, live mysql NOT RUN (no MySQL here; CI's Temporal Conformance job runs it).
  • New pins: engine-aggregate-positions.test.ts 28 passed, rest-aggregate-positions.test.ts 6 passed. The updated engine-aggregate-having-temporal-door.test.ts passes 51 and data-query-having-temporal-door.test.ts passes 11.
  • pnpm --filter @objectstack/objectql run typecheck and pnpm --filter @objectstack/rest run typecheck: exit 0, test layers included (objectql debt held at 40 files / 234 errors, unchanged; rest 0). tsc --listFiles on each tsconfig.test.json lists the new and updated test files (objectql 2, rest 1).
  • ESLint, narrowed and proven: eslint --no-inline-config --format json over the 6 changed .ts files counts 6 files in the JSON, 0 errors and 0 warnings. The population is read from eslint.config.mjs (packages/**/*.{ts,tsx,mts,cts}). Invariance: that config enables no type-aware linting (its own note), so this diff cannot move a verdict on an untouched file.

Ablations, at 93170f5468, through scripts/ablation-replace.mjs (WRAP) under one verify lock each, with the restore proven. Each leg ran mutate → objectql rebuilt → ablation-dist-preflight marker present in 4 built files → pins. Its restore leg ran blob == HEAD 1c4f6d0a41c2 → git diff HEAD empty → rebuilt → preflight --absent over all 14 built files → tree clean → pins green again (79/79 objectql, 17/17 REST). My first attempt at leg 1 never ran: its lock call timed out (exit 99) before the build, and the tool restored the file. Only the landing runs are reported.

leg (anchor replaced by a marker) objectql (2 files) REST (2 files) what went red
the having resolution call 19 failed / 60 passed 4 failed / 13 passed every resolved and every refused having cell, plus the #20263 file's unknown-token row; the door-order, braces, caller-copy and per-aggregation cells stayed green
the temporal door's path argument 4 failed / 75 passed 1 failed / 16 passed the three temporal rows and the aggregations[2] index
the text-operator door's path argument 2 failed / 77 passed 1 failed / 16 passed the two text-operator rows

Gates, patch round (head 8b950b8e)

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 8b950b8e derives the same 65 commands, against the same 8 paths versus merge base c577e66635. All 65 ran at 8b950b8e, each exit code written to a file before any pipe. --ran reconciliation: "✓ dispatch-gates --ran: 65 derived famil(ies) accounted for — 63 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)." 0 were unrun, and 62 exited 0.

  • Red by design: node scripts/check-empty-changeset.mjs --base origin/main exits 1 on the DELIBERATE CORRECTION of .changeset/20263-having-temporal-comparand-door.md. Its lines: "✓ No empty-frontmatter changeset introduced by this diff (2 declaring changeset(s) added)." and "… DELIBERATE CORRECTION -- your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it -- say so on the PR and get it confirmed …". This PR says so under Deviations.
  • NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt, reason: PREREQUISITE NOT MET (exit 3). They need the whole workspace's built dist, which this worktree does not hold ("Run pnpm build first"; "7 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk"). CI builds it.
  • Two gates refused on this clone's shallow history (check-engine-split-ratio.mjs --days 90, exit 2; check-plugin-teardown-shape.mjs --self-test, exit 3, "cannot read the positive control at 621a487"). The clone was deepened as they prescribe (git fetch --unshallow origin main), and both were re-run at 8b950b8e: exit 0 each ("48 cases pass" for the self-test). The reconciliation records those re-runs.
  • check-changeset-no-major.mjs --base origin/main: "✓ This diff introduces no major bump." check-adr-0087-registration.mjs --base origin/main: "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition." check-issue-citations.mjs: "✅ … every citation this change adds resolves". check:nul-bytes: "OK (… no raw ASCII control bytes)". check:doc-authoring: all clean.
  • origin/main moved on during the round, to db74b169d (4 commits, among them fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370 in objectql/src/validation/record-validator.ts). None of them touches a file this PR changes, and the branch is not merged again. The gates read three-dot from the merge base c577e66635, so the moving pointer did not enter their change set.

Gates, first round

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at a1a42d4c4a derives 65 commands, the same 65 as the dispatch's list. All ran at a1a42d4c4a. The --ran reconciliation found 65 derived, 63 run, 2 NOT-MEASURED and 0 unrun.

  • NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt, reason: PREREQUISITE NOT MET (exit 3). Both read whole-workspace built artefacts this worktree does not hold, and CI builds them.
  • Red by design: node scripts/check-empty-changeset.mjs --base origin/main exits 1 on the one DELIBERATE CORRECTION below. In its own words, the fix is to "say so on the PR … and get it confirmed … this gate stays red either way". Its other line: "✓ No empty-frontmatter changeset introduced by this diff (2 declaring changeset(s) added)".
  • node scripts/check-issue-citations.mjs --base 29720975b6: exit 0, "✅ … every citation this change adds resolves", 8 citations.
  • Also run, as the derivation flagged their rosters under this diff's directories: node scripts/check-changeset-fixed.mjs, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, all exit 0.

Deviations

Acceptance notes (observations, not filed)

  • The first round's note that HAVING_REMEDY named only literal forms is resolved by the patch round (open question 1 = B).
  • Over REST, the 500-character bound now cuts a datetime column's having refusal inside the remedy, before the placeholder it names (…epoch milliseconds, or a relative-date pl…). The where refusal for a datetime field was already cut at the same place on main (578 characters, in the patch-round table above). The engine message is whole, and the order kept the bound as it is. So the placeholder half of the remedy reaches an in-process caller, and a REST caller only on a date column. Noted, not filed.
  • The per-aggregation filter still resolves tokens through a direct call to core's resolveFilterTokens with filterTokenContextFrom, which is the same resolver in a second spelling of the stage function resolveWhereFilterTokens. It is behaviour-identical and not changed here.
  • The per-aggregation temporal refusal keeps where's consequence sentence ("reach the driver as written … return 200 with an empty result"). For a per-aggregation filter the consequence is a wrong count for that one aggregation. Ruling 2 moved only the path, so the wording is left as it was.
  • .changeset/20148-aggregation-filter-where-doors.md says the per-aggregation filter is "refused by the temporal-comparand door where takes, run unchanged on this position, in its words". I judge it TRUE as scoped to objectql + REST: the per-aggregation filter still lacks four of where's doors — a bad date, an addDays numeric pair, an undeclared { $field } and an unknown key answer 200 with every count 0 #20148: the same door and sentence, now with this position's location clause. It is not corrected, and the seat may re-judge.

Open questions (answered by the seat)

  • Q1, the having remedy's words: B, in this PR (seat answer 5863946181). having's temporal refusal reads REMEDY and HAVING_REMEDY retires, done in this patch round (item 3 above).
  • Q2, the claim's file surface: A (seat answer 5863946181). The takeover claim 5863879760 amends its File surface: to list text-operator-declared-type-door.ts, its path parameter and one doc line.

Out-of-scope findings

None filed or proposed. The only divergence seen, PostgreSQL's native sum returned as a string, is #20307's finding 3, which #20335 holds.

Authored in two rounds. The first was an os-dev run under session_01Bvd69VPa6puiNzzPUroDBx (report 5862763145). The patch round was an os-dev run under the seat session session_01N8TPEsoJxPsdSdNKGnNGEN (takeover claim 5863879760; this round's report is on #20334).

…r; per-aggregation refusals name their own path

`having` passes through `resolveWhereTokens`, now parameterised by the AST
slot, so an unknown `{placeholder}` is FILTER_TOKEN_UNKNOWN / 400 and a known
one compares as the value it names. The per-aggregation `filter`'s temporal
and text-operator refusals are rooted at `aggregations[i].filter`, as its
list-shape and comparand-type refusals already are.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…ation refusal paths

The engine door on both having paths with counting drivers, the REST door
over a real SqlDriver on SQLite, the where twin as the control. The #20263
pin that held an unknown having placeholder at 200 now holds the resolver's
refusal; one pending changeset clause and one doc comment that said having
resolves no placeholder are corrected.

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…efusal paths

Also compares the refused-cell messages per having path: a refusal that
lists the aggregated row's columns names the rows path's extra aggregation.

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

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 1 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 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 70 pages)
  • 4 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 — 17 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 ab946560fd254381ae43c39afc0d3f71451fe44c → packageMentionDocs.

Which tree this was computed on

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

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

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

…g the placeholder having now resolves

having resolves {placeholder} tokens through where's resolver, so a remedy
that names only literal forms steers a caller holding a preset name away
from the one spelling that works. The having refusal now reads REMEDY, the
table where and the per-aggregation filter read; the parallel HAVING_REMEDY
table and its docblock are deleted. DATE_YEAR_REMEDY is untouched, and the
time kind's words are the same string as before.

The remedy pin is flipped: it asserts code and status (through the shared
refusal helper), the message's first sentence, the placeholder named in the
remedy, and the remedy equal to the where twin's.

Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN
Co-authored-by: Claude <noreply@anthropic.com>
…eading; correct the #20263 note again

20334-aggregate-positions: a paragraph states that the having temporal
refusal's remedy is now where's, and re-measures the REST 500-character
bound for the having refusals that remedy reaches (date: whole in every
measured cell, at most 90 characters of names and path; datetime: always
cut inside the remedy, as where's datetime refusal already was). The
"keeps that refusal and its words" and "Unchanged" sentences are narrowed
to match.

20263-having-temporal-comparand-door (DELIBERATE CORRECTION): the clause
"...and the refusal's remedy names none" now says the date / datetime
remedy is where's and names the placeholders, and the Fix line gains the
placeholder form.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8b950b8e2c0a8ba6a69556515781d5a615dfd6d4
Local-runs: none

Inputs, and nothing else: card #20334 (body and all six comments: triage 5861202636, claim 5861862114, report 5862763145, takeover claim 5863879760, seat answer 5863946181, report 5864366775); PR #20368 (body, file list of 8 files at +686/−31, and the net diff origin/main... at merge base c577e66635); the 41 check-runs on the head. Read-only git and REST GETs only; no checkout, build, test, gate or ablation. The GET of the Check Changeset job log was refused at the proxy (log-storage redirect host), so that red is judged from the file list and the job's step order, which entail it.

① Derived judgments

Gate verdicts, the check-runs on 8b950b8e: 41 runs over two firings (05:36Z, and the edited re-fire at 05:59Z after the body edit): 34 success, 5 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke, and the first firing's Auto Label and Check PR Size), 2 failure, both Check Changeset. Green: Test Core (aggregate and 1–6 of 6), TypeScript Type Check with its workspace / source / consumer / debt-ledger gates, Lint and Repo Gates, Build Core, Dogfood Regression Gate (aggregate and 1–3 of 3), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Check Documentation Links, Flag docs affected by code changes, Governed Surface Queue Guard, and the four claim guards.

Check Changeset red: right, and entailed by the file list. The job's step "Reject an empty-frontmatter changeset added by this PR" runs check-empty-changeset.mjs --base MERGE_BASE, whose foreign-changeset rule is content-blind (the status letter alone): .changeset/20263-having-temporal-comparand-door.md is M against the merge base and was not added by this PR, so it exits 1 in the DELIBERATE CORRECTION class. The job's later steps (ADR-0087 registration, the no-major guard) carry no if: always(), so CI measured neither on this head; both are judged by inspection in ②. skip-changeset is absent from the PR's labels (documentation, size/l, tests, tooling), as the workflow's route 0 and ruling D on #18375 require, and the check is not a required context.

Every accept-set and answer change the diff implies, each judged:

  1. having with an unknown {token} (a near miss such as {TODAY} included; bare, under $and / $or / $not, or as an $in member): 200 with no group for that clause → FILTER_TOKEN_UNKNOWN / 400, zero reads. RIGHT, a narrowing. One resolver: ObjectQL.resolveWhereTokens(ast, ctx, position) now takes the AST slot (private, default 'where'), and aggregate calls it once for 'having' at engine.ts L16500, after the per-aggregation filters resolve (L16482) and before executeWithMiddleware (L16502), through the same stage function (resolveWhereFilterTokens → core resolveFilterTokens) where uses. Both applyHaving doors read ast.having (native L16558, rows L16602), so one call covers both paths. Triage note 1's ⛔ no second resolver: honoured.
  2. having with a context token the request carries no value for ({current_user_id} with no user, {current_org_id} with no organization, {record_id} always): 200 → FILTER_TOKEN_UNRESOLVED / 400, zero reads. RIGHT, a narrowing; core's UnresolvedFilterTokenError, the where refusal.
  3. having with a known token: compared as its own text (which orders after every digit, so $gt / $gte kept no group and $lt / $lte every group on a date / datetime column) → the resolved value (a day macro to YYYY-MM-DD, a sub-day macro to an ISO instant, off proxyDay(now, ctx.timezone)). RIGHT, a changed answer with the accept set unchanged; pinned equal to the literal twin for ten shapes plus {current_user_id}, on both paths.
  4. Order against the doors: every having door (condition shape, list shape, comparand type, evaluable keys and columns, the objectql having: a comparand on an aggregated date column never meets the temporal-comparand door — over REST having { last_placed: { $lt: "not-a-date" } } on max(placed_on) keeps every group (200) while its where twin answers 400 #20263 temporal door at L16445) runs before the resolution at L16500, as on where the door in lowerWhereFilterArray precedes resolveWhereTokens. RIGHT; an earlier door's refusal keeps its words (pinned: totl 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, and 'not-a-date' beside {not_a_token} is the temporal door's INVALID_FILTER). As on where, a resolved value is not judged again by the door (parity, not new).
  5. The caller's having object is not written back: core's resolver is copy-on-write and the AST is the engine's own. RIGHT, pinned.
  6. Per-aggregation filter, temporal and text-operator refusals: the root where. → aggregations[i].filter. through an optional trailing path = 'where' on assertTemporalComparandsInterpretable and assertTextOperatorTargetsAreStringCapable, the loop passing its index; the where call sites (L995, L1006, L1084, L1089) are untouched and keep the default. Code, status and every other word unchanged. RIGHT; the text-operator door is the same defect class triage note 2 names without naming a door (claim amended, Q2 = A).
  7. The having temporal refusal's remedy on a date / datetime column: HAVING_REMEDY[kind] → REMEDY[kind], the table where reads; HAVING_REMEDY and its docblock deleted (0 hits at head); the time string identical (it was REMEDY.time); the DATE_YEAR_REMEDY branch untouched. RIGHT, words only, ordered by seat answer 5863946181 (Q1 = B).
  8. REST 500-character bound: CLIENT_MESSAGE_MAX = 500 and truncateClientMessage (a message under 500 is whole, otherwise 499 characters plus the ellipsis) are untouched. Re-derived arithmetically from the head's string literals, not by running anything: the having refusal's fixed words plus remedy are 409 (date) and 502 (datetime); the ledger_having cells reproduce byte for byte ('not-a-date' $lt on max(placed_on) 382 → 481, whole; '+010000-01-01T00:00:00.000Z' $gt 399 → 498, whole, the longest; 'not-a-date' $lt on min(opened_at) 480 → 576, cut at …epoch milliseconds, or a relative-date pl…); the where datetime refusal's fixed words are 525, so it was already always cut; the per-aggregation 'not-a-date' refusals on ledger_order go 489 → 506 and 496 → 513 and lose the tail after "{current_month_s. Every figure in the PR body and both notes reproduces. RIGHT as a bounded consequence of the existing bound, declared in the note; observation in ③.
  9. Public surface: nothing moves. resolveWhereTokens is private; neither door function is re-exported (src/index.ts and src/core.ts name none and export * none of those modules; package.json exports only . and ./core); no consumer outside engine.ts in packages, apps or examples (non-test). QueryAST, EngineAggregateOptions and QuerySchema are untouched.
  10. Pins: the objectql having: a comparand on an aggregated date column never meets the temporal-comparand door — over REST having { last_placed: { $lt: "not-a-date" } } on max(placed_on) keeps every group (200) while its where twin answers 400 #20263 row 'an unknown {placeholder} too' (200, no group) pinned the branch this PR removes and is replaced by a FILTER_TOKEN_UNKNOWN / 400 zero-read case; the remedy case is flipped to assert the envelope on both paths, the first sentence, "{30_days_ago}" in the remedy and the remedy byte-equal to the where twin's. New: engine-aggregate-positions.test.ts (both path shapes, counting driver, the where twin as control) and rest-aggregate-positions.test.ts (engine and REST over SqlDriver on SQLite). RIGHT. Triage note 3 "pin on the three drivers" executed as fix(objectql)!: having takes the temporal-comparand door where and the per-aggregation filter take (#20263) #20307 did (memory and PostgreSQL measured at 432 cells per tree, not pinned): accepted, because no driver reads having and the resolution and every refusal precede the driver (verified at L16445–L16500), and no package in the surface holds the engine together with those drivers; the one driver-dependent cell (PostgreSQL's native sum returned as a string) predates this PR (fix(objectql)!: having takes the temporal-comparand door where and the per-aggregation filter take (#20263) #20307 finding 3, held by driver-sql on PostgreSQL: the native aggregate returns count / sum / avg as strings ("n":"1", "total":"20.000…"), so having { n: { $in: [2] } } keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335).

② Semver level

.changeset/20334-aggregate-positions.md (new): "@objectstack/objectql": minor; title fix(objectql)!:; a BREAKING banner; Clause-②: no (narrowing) in the note and in the PR body; exactly one adr-0087 marker, not-required (no-migration-prescription), with its reasoning (no accepted spelling with a meaning to preserve; having is request-only, nothing stored to migrate). It matches the diff: the accept set narrows (① 1–2) and no public surface widens (① 9), so no; (narrowing) is BREAKING under AGENTS.md's closed pair; the launch-window convention ships an accept-set narrowing as minor with breaking-ness carried by the banner and the disposition (the #20263 and #20148 notes on main are the precedent), and the LEVEL AXIS would refuse patch for a narrowing. Only @objectstack/objectql moves under packages/**/src/**; packages/rest gains test files only and forwards unchanged. The two CI-skipped steps, judged by inspection of the head: the diff introduces no major bump, and the one declared-breaking changeset carries its disposition. The Clause-②: line is RIGHT.

DELIBERATE CORRECTION, .changeset/20263-having-temporal-comparand-door.md (the pending note of the merged #20263 / PR #20307), numstat 2/2 against main; Check Changeset red by design; this record is the written confirmation. Each rewritten sentence, base against head, judged on the head's code:

(a) The What-is-judged clause. Base: "having does not resolve placeholders, and did not before, so the refusal's remedy names none." FALSE at the head (L16500 resolves; REMEDY.date names {30_days_ago}); restoring it from base would put a false sentence back. Head: "having resolves placeholders from the same release (#20334), after this door, so the refusal's remedy on a date or datetime column is the where refusal's and names them, e.g. {30_days_ago} / {current_month_start}." TRUE: both notes are pending minor entries on @objectstack/objectql, consumed by one release; the door runs at L16445 and the resolution at L16500; the remedy is REMEDY[hit.kind], the where table, whose date and datetime strings name exactly those two tokens. Precision note, non-blocking: the date year-class refusal (a number or Date outside the years 0000–9999, DATE_YEAR_REMEDY) is also the where refusal's but names no placeholder; it is unchanged by this PR and the #20334 note says so, so the sentence's scope (a string comparand, its own example) is one clause wider than that branch. An optional tightening is "a string comparand's refusal".

(b) The Fix line. Base: "...a bare day or epoch milliseconds, and a time column with an HH:MM or HH:MM:SS wall clock." Incomplete at the head (a placeholder now works). Head adds "either one with a relative-date placeholder the resolver knows ({30_days_ago}, {current_month_start}; having resolves them from the same release, #20334)". TRUE: both are day macros rendered as YYYY-MM-DD, which a date column reads and a datetime column reads as a bare day; the door steps around every {…} string so neither is refused; pinned ({30_days_ago} $gt on max(placed_on) keeps the literal's groups; {current_month_start} on min(opened_at)).

Untouched sentences re-judged: the Unchanged list ("{today}-style placeholders, known or not" and "every existing having refusal, in its words") is a before-and-after statement about #20263's own door and stays TRUE; "The refusal follows the where door's words." stays TRUE and now includes the remedy.

This PR's own note, the patch-round rewrites, judged:

  • "keeps that refusal, in that door's words." TRUE (① 4).
  • Unchanged clause, "and its refusals in their words other than the date / datetime temporal refusal's remedy above" TRUE (the diff moves exactly that string; time and the year class do not move).
  • The added paragraph "The having temporal refusal's remedy is where's": the old remedy quoted (Write a "YYYY-MM-DD" calendar day.) equals base HAVING_REMEDY.date byte for byte, TRUE; the new remedy quoted equals head REMEDY.date byte for byte, TRUE; "code, status, what is refused and every word before the remedy are unchanged, and so are a time column's refusal and the refusal of a number or Date whose year falls outside 0000 to 9999" TRUE (the diff); "keeps its 500-character bound, which the longer remedy now reaches" TRUE (502 is not under 500); "still arrives whole in every cell measured, the longest at 498 ... at most 90 characters together" TRUE (409 + 90 = 499, under the bound; 498 reproduced); "a datetime column's refusal no longer fits ... 502 characters ... …epoch milliseconds, or a relative-date pl… ... as the where refusal for a datetime field already did" TRUE (576 cut at 499 gives that tail; where datetime fixed 525); "'last_30_days' does not reach this refusal over REST: the query schema refuses it first (VALIDATION_FAILED)" TRUE by mechanism: QuerySchema.having is FilterConditionSchema, whose Refuse the declared relative-date preset vocabulary as a bare temporal comparand at publish time — the ruled C half of #8690, carved out for the spec seat #8793 rule refuses a bare date-range preset name under $gt / $gte / $lt / $lte.
  • First-round sentences checked as well: "as that filter's list-shape and comparand-type refusals already did" TRUE (the loop's assertListComparandShapes and normalizeFilterComparandTypes calls carry the position); "Tokens resolve the way they do in where ... in the request's timezone" TRUE; "'a{b}c' is not a placeholder" TRUE (values that merely contain braces are left untouched); "The five having clauses in this repository's docs and published skills compare a numeric aggregation alias with a number ... no runtime code or example app composes a having" TRUE (5 hits: queries.mdx twice, query-syntax.mdx, aggregation.md twice, all numeric; metadata-protocol only forwards options.having); "the refusal lists them: {today}, {current_quarter_start}, {30_days_ago}, {current_user_id}" TRUE (UnknownFilterTokenError's text).
  • One imprecision, non-blocking (③ 8): the table's first row's before-cell "200, keeps no group" is exact for a bare unknown token and under $and; under $or beside a holding arm the base kept that arm's group (report 5862763145: "$or with an unknown token arm: 200 [c2]", and the PR body says so), and under $not the complement. The direction (200 → 400) and the code are right in every cell.

③ Boundary flags

  1. Q1, the having remedy's words: seat answer B (5863946181), executed at the head (① 7), the pin flipped, the ablation reported red and restored green. Answered.
  2. Q2, the claim's file surface: A; takeover claim 5863879760 lists text-operator-declared-type-door.ts, its path parameter and one doc line, and the diff touches exactly that (+7/−2). Answered.
  3. The DELIBERATE CORRECTION of the objectql having: a comparand on an aggregated date column never meets the temporal-comparand door — over REST having { last_placed: { $lt: "not-a-date" } } on max(placed_on) keeps every group (200) while its where twin answers 400 #20263 note: judged in ② and confirmed by this record. Do not restore it from base; Check Changeset stays red, is not a required context, and skip-changeset stays off. Answered.
  4. temporal-comparand-door.ts beyond "its path parameter only": the HAVING_REMEDY deletion is item 1 of seat answer B, not a surface breach. Answered.
  5. The objectql having: a comparand on an aggregated date column never meets the temporal-comparand door — over REST having { last_placed: { $lt: "not-a-date" } } on max(placed_on) keeps every group (200) while its where twin answers 400 #20263 pin row replaced rather than re-spelled: right, it pinned the removed 200 branch. Answered.
  6. "Pin on the three drivers" executed as fix(objectql)!: having takes the temporal-comparand door where and the per-aggregation filter take (#20263) #20307 did: accepted on the reasoning in ① 10. Answered, nothing to escalate.
  7. The PR assignee is unset (the dev's label write was refused) and the PR is still a draft: seat actions, not code. Escalated to the seat.
  8. Note precision, non-blocking and the seat's call (accept as written, or tighten in this PR before landing): ② (a)'s year-class scope, and the before-cell "keeps no group" under $or / $not in the objectql aggregate positions miss two of where's behaviours: having resolves no {placeholder} ({ $gte: "{not_a_token}" } answers 200 where where answers 400), and the per-aggregation filter temporal refusal names its path where.… #20334 table.
  9. Acceptance observation, not filed: over REST a datetime column's having temporal refusal is now always cut inside the remedy before the placeholder it names (502 fixed characters), as the where datetime refusal already is (525), and the per-aggregation date 'not-a-date' refusals now cross the bound too (506 / 513). The bound is the existing one and outside this surface; an in-process caller gets the whole message. The seat may file a card on CLIENT_MESSAGE_MAX against these remedies; it is not this PR's.
  10. .changeset/20148-aggregation-filter-where-doors.md, "refused by the temporal-comparand door where takes, run unchanged on this position, in its words": I concur, TRUE as scoped to objectql + REST: the per-aggregation filter still lacks four of where's doors — a bad date, an addDays numeric pair, an undeclared { $field } and an unknown key answer 200 with every count 0 #20148 (the same door and sentence, now with this position's own root, which that note's { $field } rows already claim for the position); no correction owed.
  11. PostgreSQL's native sum as a string ({ total: { $gt: '{today}' } } keeps c1, c3 on PG native only): pre-existing (fix(objectql)!: having takes the temporal-comparand door where and the per-aggregation filter take (#20263) #20307 finding 3), held by driver-sql on PostgreSQL: the native aggregate returns count / sum / avg as strings ("n":"1", "total":"20.000…"), so having { n: { $in: [2] } } keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335; not this PR's.
  12. Parity observation: as on where, a token that resolves to a value its column cannot read ({current_user_id} on max(placed_on), say) is not re-judged by the temporal door after resolution; the same posture as where, not new here.
  13. The relay body-edit footer drift in report 5864366775 is for the PM seat's readings file; not this review's.
  14. The dev's process deviations (the merge commit's trailer, the footer-less body edit, the unshallow of the shared clone): no effect on the head; noted.

Implemented-by: claude/issue-20334-aggregate-positions-parity
Reviewed-by: session_01N8TPEsoJxPsdSdNKGnNGEN

VERDICT: PASS

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

Projects

None yet

2 participants