fix(objectql)!: having resolves placeholders through the where resolver, and the per-aggregation filter refusals name aggregations[i].filter (#20334) - #20368
Conversation
…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>
…ns-parity Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check7 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
Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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 |
…ns-parity Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
…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>
Contract reviewServed-tier: 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 ① Derived judgmentsGate verdicts, the check-runs on
Every accept-set and answer change the diff implies, each judged:
② Semver level
DELIBERATE CORRECTION, (a) The What-is-judged clause. Base: " (b) The Fix line. Base: "...a bare day or epoch milliseconds, and a Untouched sentences re-judged: the Unchanged list (" This PR's own note, the patch-round rewrites, judged:
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #20334
Clause-②: no (narrowing)
What this does
Two
wherebehaviours the other filter positions ofengine.aggregatelacked, 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).havingresolves{placeholder}tokens through the resolverwhereuses.ObjectQL.resolveWhereTokensnow takes the AST slot as a parameter ('where'by default,'having'fromaggregate), andaggregatecalls it once forhaving, 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'sresolveFilterTokens) thatwhereand the judge use. An unknown token isFILTER_TOKEN_UNKNOWN/ 400 and a context token with no value isFILTER_TOKEN_UNRESOLVED/ 400, inwhere's words, before any driver read. A known token compares as the value it names.filter's refusals name their position.assertTemporalComparandsInterpretableandassertTextOperatorTargetsAreStringCapabletake an optionalpath(default'where'), and the per-aggregation loop passes`aggregations[${i}].filter`, the root its list-shape and comparand-type doors already pass.havingtemporal refusal's remedy iswhere's (patch round).assertHavingTemporalComparandsInterpretablenow ends its refusal inREMEDY[hit.kind], the table thewhereand per-aggregation refusals read, and the parallelHAVING_REMEDYtable and its docblock are deleted (net 1 line added, 16 removed). A string adateordatetimecolumn cannot read now gets the remedy naming the relative-date placeholder ("{30_days_ago}" / "{current_month_start}"), whichhavingresolves from item 1 on.DATE_YEAR_REMEDYis untouched, and thetimekind's words are the same string as before (HAVING_REMEDY.timewasREMEDY.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.tsandtext-operator-declared-type-door.ts(thepathparameter; in the first, also thehavingremedy 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-0087not-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: onlyengine.tsimports them, and neithersrc/index.tsnorsrc/core.tsre-exports them.How
havingreaches the one resolver (H2)resolveWhereTokens(ast, execCtx)readast.wherealone:if (!ast || ast.where == null) return; ast.where = resolveWhereFilterTokens(ast.where, execCtx);. The per-aggregationfilterresolved through its own call to core'sresolveFilterTokens, andhavingreached no resolver at all.for (const [k, v] of Object.entries(node)) out[k] = walk(v)), and a token resolves from the request context alone (now,timezone,userId,orgId). So ahavingkeyed by aggregate aliases resolves exactly as awherekeyed by fields does. The one field-type-aware step, reading a resolved day by a column's storage rule, isapplyHaving's, as it already is for a literal day.wherethe door runs insidelowerWhereFilterArray, beforeresolveWhereTokens, and steps around anyclassifyFilterTokenhit (core'sisUninterpretableTemporalComparand).having's door (objectqlhaving: a comparand on an aggregateddatecolumn never meets the temporal-comparand door — over RESThaving { last_placed: { $lt: "not-a-date" } }onmax(placed_on)keeps every group (200) while itswheretwin answers 400 #20263) runs in the same position relative to the new call: everyhavingdoor first, then resolution. So{ last_placed: { $lt: 'not-a-date' }, first_opened: { $gte: '{not_a_token}' } }is the temporal door'sINVALID_FILTER, and an earlier door's refusal (totl, objectql: ahavingkey that names no column of the aggregated row keeps no group silently —having: { totl: { $gt: 100 } }answers 200 [], where an unknownwherefield is refused 400 #20123) keeps its words. Both are pinned. As onwhere, a resolved value is not judged again by the door.applyHavingdoors (nativedriver.aggregate()and the rows fallback) readast.having, so one call covers both paths, beforeexecuteWithMiddlewareand any driver read. The caller'shavingobject is not written back: the resolver is copy-on-write, and the AST is the engine's own object (pinned).The path (H3)
assertTemporalComparandsInterpretable(object, 'aggregate', schema, aggFilter)with no path, and the walk rooted at its default'where'.`aggregations[1].filter`, and the materializable door names no path at all.$containsfamily) over a field whose DECLARED type is not textual —INVALID_FILTER400 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:$containson a number field inaggregations[1].filtersaidat where.amount.$containsat 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, andat aggregations[2].filter.…when the filter sits on the third aggregation.Measured: base
26daf0b036and head, three drivers, both doors, bothhavingpathsInMemoryDriver, SqlDriver on SQLite and SqlDriver on PostgreSQL 16.13 (a private role and database on the system cluster, database timezone
Asia/Shanghai, processTZ=America/New_York), throughengine.aggregateandPOST /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 atf78b1e0c9d. The later commits of the first round change tests, changesets and one doc comment, andengine.tsis byte-identical (blob1c4f6d0a41c2) ata1a42d4c4aand at the patch round's head8b950b8e. The patch round changes one runtime string, thehavingremedy, measured in its own section below. The three drivers and both doors agree on every row below unless the row says otherwise.wheretwinhaving{ last_placed: { $gt: '{current_year_start}' } }'2026-01-01'twin: c1–c4)having{ last_placed: { $gte: '{not_a_token}' } }FILTER_TOKEN_UNKNOWN/ 400, 0 readsFILTER_TOKEN_UNKNOWN/ 400having'{TODAY}'(near miss), an unknown token oncount, under$andor$or$orkept c2)FILTER_TOKEN_UNKNOWN/ 400, 0 readshaving{today}$lte/{30_days_ago}$gt/{current_month_start}$lthaving{7_months_ago}$gt,$between['{current_year_start}', '2026-02-01'],{current_year_start}$gteonmin(opened_at),$notof a tokenhaving$or: [{ total: { $gt: 1000 } }, { last_placed: { $gt: '{current_year_start}' } }]having{ customer_id: '{current_user_id}' }, user c2havingthe same with no user ·{current_org_id}with no org (engine) ·{record_id}FILTER_TOKEN_UNRESOLVED/ 400, 0 reads (REST with no user: 401 before and after)FILTER_TOKEN_UNRESOLVED/ 400having{current_org_id}, user c2, no org, both doorsFILTER_TOKEN_UNRESOLVED/ 400having{ total: { $gt: '{today}' } }onsumaggregations[1].filter{ placed_on: { $gt: 'not-a-date' } }INVALID_FILTERat where.placed_on.$gtINVALID_FILTERat aggregations[1].filter.placed_on.$gtat where.placed_on.$gtaggregations[1].filterthe same under$or, and the number for 10000-01-01at where.…at aggregations[1].filter.…at where.…aggregations[1].filter{ amount: { $contains: '5' } },{ placed_on: { $startsWith: '2026' } }, and under$orat where.amount.$contains…at aggregations[1].filter.amount.$contains…at where.amount.$containsThe PostgreSQL native
sumcell 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 returnssumas 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.havingwithout a token: 96 of 96 cells byte-identical ata1a42d4c4a, including the temporal refusals in their words (the patch round then moves one thing among them, thedate/datetimetemporal refusal's remedy, measured in its section below), a nested$orrefusal,a{b}c(braces inside a string, not a placeholder), a{ $field }reference and the literal twins.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).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.$in, both$betweenendpoints, a bare day onmin(datetime),$and/$or/$not) and{current_user_id}.Declaration and who is reached (H5)
Clause-②: no (narrowing)holds. An unknown token and an unresolvable context token onhavinganswered 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 nomajorbump."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).{…}string onhaving: none. There are fivehavingclauses incontent/docsandskills/:queries.mdxtwice,query-syntax.mdxandskills/objectstack-query/rules/aggregation.mdtwice. Each comparesorder_count/total_spent/totalwith a number. Outside the engine and the spec that declare it, no runtime code, example app orapps/file composes ahaving. The control grep, awhere/filtercarrying a token inexamples/, hits 16.Patch round: the
havingremedy (seat answer 5863946181), measuredBefore is
c599f758(the merge ofmainatc577e66635intoa1a42d4c4a,HAVING_REMEDYstill read). After is8b950b8e, whosetemporal-comparand-door.tsis thedb334ac4blob3ee7abf0c722.assertHavingTemporalComparandsInterpretableinpackages/objectql/src/temporal-comparand-door.ts, the only line carrying "Thehavingwas NOT applied.", and it readHAVING_REMEDY[hit.kind]once. It now readsREMEDY[hit.kind], andgit grep HAVING_REMEDYanswers 0 at8b950b8e. The year-class branch still readsDATE_YEAR_REMEDY.../objectui:HAVING_REMEDY, "Thehavingwas 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_sin test files. Only one pin read the old remedy:engine-aggregate-having-temporal-door.test.ts, whose remedy case assertednot.toContain('{30_days_ago}'). It is flipped (Deviations).packages/rest/src/data-query-having-temporal-door.test.tsasserts only the column clause of the RESTerror(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 orobjectuitext quotes the having remedy. Every rejection assertion for a genuinely illegal shape is unchanged.havingcells (objectledger_having, SqlDriver on SQLite, bothhavingpaths) plus the twowheretwins 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 thetimecolumn, the year class and thewheretwins. Both paths give one message in every cell. Thehavingremedy equals thewheretwin's remedy byte for byte, on adateand on adatetimecolumn.CLIENT_MESSAGE_MAX, a message of 500 or more characters is cut to 499 plus…) is unchanged. Adatecolumn'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. Adatetimecolumn's take 502, so every such refusal is cut. "Whole" below means the RESTerrorequals the engine message byte for byte.havingcell (dateunless stated)'not-a-date'$ltonmax(placed_on)'+010000-01-01T00:00:00.000Z'$gtonmax(placed_on)$inmember, a$betweenendpoint, the implicit-equality slot, under$or, under$notplaced_ongroupBy key, adaybucket ofopened_at'not-a-date'$ltonmin(opened_at)(datetime)…epoch milliseconds, or a relative-date pl…'not-a-date'on anopened_atgroupBy key (datetime)…milliseconds, or a relative-…'last_30_days'onmax(placed_on)/min(opened_at)VALIDATION_FAILED/ 400 from the query schema, before and after: REST never reaches the engine with a preset name'not-a-date'/'noon'onmax(slot)(time)max(placed_on)(year class)where:'not-a-date'onplaced_on/opened_at…or a relative-date …, before and afterThe changeset states this: every measured
daterefusal arrives whole, and adatetimerefusal is cut inside the remedy, aswhere's already was.8b950b8e. It ran throughscripts/ablation-replace.mjs(WRAP) under one verify lock. The anchorThe \`having\` was NOT applied. ${REMEDY[hit.kind]}was replaced by the old literal-only remedy table, inlined behind the markerABLATION-20334-LITERAL-ONLY. The tool read the anchor x1 → x0, the replacement x0 → x1, and the blob3ee7abf0c722→bfe6275fe997. Result:engine-aggregate-having-temporal-door.test.tshad 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 == HEAD3ee7abf0c722,git diff HEADempty, tree clean. The test imports./engine.js, the source, so the leg needed no build. The builtdist/(fromdb334ac4, the sameobjectqlsource) carries 0 hits of the marker. The wholeobjectqlsuite ran green again after the restore (below).Tests and evidence, patch round (head
8b950b8e, after mergingmainatc577e66635)mainatc577e66635brought no change toobjectql,coreor the drivers. It changedpackages/rest/src(the draft-read builder gate),packages/core/src/qaandspec. The merge isc599f758, a true merge commit with parentsa1a42d4c4aandc577e66635, throughscripts/pm/os-regen-merge.sh: no generated artefact was pending, and main's side was taken for everyos-regenpath. The rest closure was rebuilt from it (turbo run build --filter='@objectstack/rest^...', 24 tasks, exit 0), andobjectqlwas 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.tsalone: 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 --listFileslistsengine-aggregate-having-temporal-door.test.ts(1 hit).pnpm --filter @objectstack/rest run typecheck: exit 0.8b950b8e:eslint --no-inline-config --format jsonover the 6.tsfiles this PR changes against the merge base counts 6 files in the JSON, 0 errors and 0 warnings. The population is read fromeslint.config.mjs(packages/**/*.{ts,tsx,mts,cts}). Invariance: the config "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules)" (its own note), so this diff cannot move a verdict on an untouched file.packages/rest/srcfor 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 mergingmainat29720975b6, 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*), withOS_TEST_POSTGRES_URLon the private PostgreSQL 16 database andTZ=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).engine-aggregate-positions.test.ts28 passed,rest-aggregate-positions.test.ts6 passed. The updatedengine-aggregate-having-temporal-door.test.tspasses 51 anddata-query-having-temporal-door.test.tspasses 11.pnpm --filter @objectstack/objectql run typecheckandpnpm --filter @objectstack/rest run typecheck: exit 0, test layers included (objectql debt held at 40 files / 234 errors, unchanged; rest 0).tsc --listFileson eachtsconfig.test.jsonlists the new and updated test files (objectql 2, rest 1).eslint --no-inline-config --format jsonover the 6 changed.tsfiles counts 6 files in the JSON, 0 errors and 0 warnings. The population is read fromeslint.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, throughscripts/ablation-replace.mjs(WRAP) under one verify lock each, with the restore proven. Each leg ran mutate →objectqlrebuilt →ablation-dist-preflightmarker present in 4 built files → pins. Its restore leg ran blob == HEAD1c4f6d0a41c2→git diff HEADempty → rebuilt → preflight--absentover 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.havingresolution callhavingcell, plus the #20263 file's unknown-token row; the door-order, braces, caller-copy and per-aggregation cells stayed greenpathargumentaggregations[2]indexpathargumentGates, patch round (head
8b950b8e)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat8b950b8ederives the same 65 commands, against the same 8 paths versus merge basec577e66635. All 65 ran at8b950b8e, each exit code written to a file before any pipe.--ranreconciliation: "✓ 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.node scripts/check-empty-changeset.mjs --base origin/mainexits 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.pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt, reason: PREREQUISITE NOT MET (exit 3). They need the whole workspace's builtdist, which this worktree does not hold ("Runpnpm buildfirst"; "7 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk"). CI builds it.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 at8b950b8e: 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 nomajorbump."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/mainmoved on during the round, todb74b169d(4 commits, among them fix(objectql)!: a number field refuses an array, a boolean or an object with invalid_number (#20309) #20370 inobjectql/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 basec577e66635, so the moving pointer did not enter their change set.Gates, first round
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackata1a42d4c4aderives 65 commands, the same 65 as the dispatch's list. All ran ata1a42d4c4a. The--ranreconciliation found 65 derived, 63 run, 2 NOT-MEASURED and 0 unrun.pnpm check:dual-build-cjs-loadsandpnpm 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.node scripts/check-empty-changeset.mjs --base origin/mainexits 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.node scripts/check-changeset-fixed.mjs,check:authz-resolver,check:error-code-casing,check:filter-alias-parity, all exit 0.Deviations
DELIBERATE CORRECTION,
.changeset/20263-having-temporal-comparand-door.md: two sentences, numstat 2/2 againstmain.Check Changesetstays red by design. Every rewrite, in order:What is judgedclause. Before (onmain): "havingdoes not resolve placeholders, and did not before, so the refusal's remedy names none." After (a1a42d4c4a): "havingresolves placeholders from the same release (objectql aggregate positions miss two ofwhere's behaviours:havingresolves no{placeholder}({ $gte: "{not_a_token}" }answers 200 wherewhereanswers 400), and the per-aggregationfiltertemporal refusal names its pathwhere.…#20334), after this door, and the refusal's remedy names none."8b950b8e. Before: thea1a42d4c4atext above. After: "havingresolves placeholders from the same release (objectql aggregate positions miss two ofwhere's behaviours:havingresolves no{placeholder}({ $gte: "{not_a_token}" }answers 200 wherewhereanswers 400), and the per-aggregationfiltertemporal refusal names its pathwhere.…#20334), after this door, so the refusal's remedy on adateordatetimecolumn is thewhererefusal's and names them, e.g.{30_days_ago}/{current_month_start}."datecolumn with aYYYY-MM-DDday, adatetimecolumn with an ISO-8601 instant, a bare day or epoch milliseconds, and atimecolumn with anHH:MMorHH:MM:SSwall clock." After: "Fix. Compare adatecolumn with aYYYY-MM-DDday, adatetimecolumn with an ISO-8601 instant, a bare day or epoch milliseconds, either one with a relative-date placeholder the resolver knows ({30_days_ago},{current_month_start};havingresolves them from the same release, objectql aggregate positions miss two ofwhere's behaviours:havingresolves no{placeholder}({ $gte: "{not_a_token}" }answers 200 wherewhereanswers 400), and the per-aggregationfiltertemporal refusal names its pathwhere.…#20334), and atimecolumn with anHH:MMorHH:MM:SSwall clock."Its "Unchanged" list ("
{today}-style placeholders, known or not" and "every existinghavingrefusal, in its words") is a before-and-after statement about objectqlhaving: a comparand on an aggregateddatecolumn never meets the temporal-comparand door — over RESThaving { last_placed: { $lt: "not-a-date" } }onmax(placed_on)keeps every group (200) while itswheretwin answers 400 #20263's own door, so it stays TRUE and is not touched. The sentence "The refusal follows thewheredoor's words" is truer now and is not touched either.This PR's own
.changeset/20334-aggregate-positions.md, patch round (not a correction: the file is new in this PR). Added: the paragraph "Thehavingtemporal refusal's remedy iswhere's. …", which states the change and the REST bound reading in the patch-round section above. Rewritten: "…keeps that refusal and its words." became "…keeps that refusal, in that door's words.", and the Unchanged clause "everyhavingthat carries no placeholder, its refusals in their words;" became "everyhavingthat carries no placeholder, and its refusals in their words other than thedate/datetimetemporal refusal's remedy above;". Both old sentences read as "the words did not move", which is false at8b950b8e.Bounded in-place fix beyond the claimed surface:
packages/objectql/src/text-operator-declared-type-door.ts, itspathparameter and thepathfield's doc line only. All four conditions hold. ① It is ruling 2's defect class exactly: a per-aggregation refusal namingwhere. ② The fix is mechanical and its shape is pinned by the temporal door's. ③ No open PR touches the file (a census of the 13 open PRs' file lists, including record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309's branch; driver-sql on PostgreSQL: the nativeaggregatereturnscount/sum/avgas strings ("n":"1","total":"20.000…"), sohaving { n: { $in: [2] } }keeps no group on PostgreSQL alone, where memory, SQLite and PG's rows path keep c1, c2 #20335's branch has no commits). ④ The same pins and gate families cover it. The first round's claim did not list this file; the takeover claim 5863879760 now does (open question 2 = A).temporal-comparand-door.tsbeyond "its path parameter only": in the first round theHAVING_REMEDYdoc comment was corrected with no runtime byte moved, and whether the remedy should name a placeholder was put to the seat. The seat answered B (5863946181), so the patch round deletesHAVING_REMEDYand its doc comment and thehavingrefusal readsREMEDY. This is item 1 of that answer, not a surface breach.The objectql
having: a comparand on an aggregateddatecolumn never meets the temporal-comparand door — over RESThaving { last_placed: { $lt: "not-a-date" } }onmax(placed_on)keeps every group (200) while itswheretwin answers 400 #20263 pin that held the defect is replaced, not re-spelled. Its row'an unknown {placeholder} too'asserted 200 with no group for{not_a_token}onhaving, which is the branch this change removes. It is now a case assertingFILTER_TOKEN_UNKNOWN/ 400 with zero reads, not the door'sINVALID_FILTER. In the first round the remedy case's title "having resolves none" was retitled with its assertion unchanged. In the patch round the case is flipped and retitled "the remedy names the placeholder the resolver knows, in the where twin's words". For adateand adatetimecolumn it asserts the envelope (INVALID_FILTER/ 400 throughexpectHavingRefusal, both paths, empty or populated, no read), the message's first sentence,"{30_days_ago}"in the remedy, and the remedy byte-equal to thewheretwin's. The ablation below turns it red."Pin on the three drivers" (ruling 3) is executed as fix(objectql)!: having takes the temporal-comparand door where and the per-aggregation filter take (#20263) #20307 executed it. No driver reads
havingand every refusal precedes the driver, so the engine is pinned on both path shapes with counting drivers (objectql), and the engine and REST doors over a real SqlDriver on SQLite (rest), with thewheretwin as the control everywhere. InMemoryDriver and PostgreSQL were measured (432 cells per tree, above), not pinned. No package in the claim's surface holds the engine together with those drivers: objectql has neither, and rest hasdriver-sqlbut nodriver-memoryand no CI job hands it a PostgreSQL URL. Adding either is outside the surface.Acceptance notes (observations, not filed)
HAVING_REMEDYnamed only literal forms is resolved by the patch round (open question 1 = B).datetimecolumn'shavingrefusal inside the remedy, before the placeholder it names (…epoch milliseconds, or a relative-date pl…). Thewhererefusal for adatetimefield was already cut at the same place onmain(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 adatecolumn. Noted, not filed.filterstill resolves tokens through a direct call to core'sresolveFilterTokenswithfilterTokenContextFrom, which is the same resolver in a second spelling of the stage functionresolveWhereFilterTokens. It is behaviour-identical and not changed here.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.mdsays the per-aggregation filter is "refused by the temporal-comparand doorwheretakes, run unchanged on this position, in its words". I judge it TRUE as scoped to objectql + REST: the per-aggregationfilterstill lacks four ofwhere's doors — a bad date, anaddDaysnumeric pair, an undeclared{ $field }and an unknown key answer200with 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)
havingremedy's words: B, in this PR (seat answer 5863946181).having's temporal refusal readsREMEDYandHAVING_REMEDYretires, done in this patch round (item 3 above).File surface:to listtext-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
sumreturned 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 sessionsession_01N8TPEsoJxPsdSdNKGnNGEN(takeover claim 5863879760; this round's report is on #20334).