Skip to content

fix(objectql)!: having takes the temporal-comparand door where and the per-aggregation filter take (#20263) - #20307

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20263-having-temporal-door
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20263-having-temporal-door

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20263
Clause-②: no (narrowing)

What this does

engine.aggregate evaluates having itself, so no driver reads it, and none of the doors in front of it judged a temporal comparand. A comparand the aggregated column's storage rule cannot read was compared as written: having { last_placed: { $lt: "not-a-date" } } on max(placed_on) kept every group with a 200, while its where twin answered 400.

having now takes the temporal-comparand door that where (#8690) and the per-aggregation filter (#20148) take: the same walk and the same @objectstack/core predicate (isUninterpretableTemporalComparand). When #20264 moves the year range in that predicate, having follows with no second edit (pinned: see "one predicate" below).

  • packages/objectql/src/temporal-comparand-door.ts: the walk is factored into one walkCondition with a per-position scope (the kind of the column a key names, and which operators are judged). findUninterpretableTemporalComparand (the where entry) keeps its signature and behaviour. New assertHavingTemporalComparandsInterpretable(object, having, classes, query).
  • packages/objectql/src/engine.ts, ObjectQL.aggregate: one call, after assertHavingIsEvaluable and before getDriver, so it covers both applyHaving doors (native driver.aggregate() and the rows fallback) with zero driver reads on a refusal.
  • packages/objectql/src/having-filter.ts: temporalKindOf is exported (one keyword and a doc line), so the door and the storage rule read one kind.
  • Tests in objectql and rest, and .changeset/20263-having-temporal-comparand-door.md (minor, BREAKING narrowing, ADR-0087 not-required (no-migration-prescription)).

How the kind is read (H3)

From the class #20127 already derives, aggregatedRowColumnClasses, which engine.aggregate computes once and now hands to this door too; nothing is derived again. min / max of a date / datetime / time field keeps that kind, a groupBy projection of such a field takes its kind, a day bucket is a date. count / count_distinct / sum / avg, a week / month / quarter / year bucket, and every other column are not judged. An alias that names no column is still refused by #20123's words, first.

Two deliberate differences from where, each measured

  1. Order: last among the having doors. On a clause another having door refuses (an unknown operator, a key naming no column, a comparand of no comparable type, an addDays pair, an array in the equality slot), that door answers in its own words. So every cell refused at the base keeps its words byte for byte (H4), and only cells that answered 200 move.
  2. The text operators are not judged. On where a text operator on a temporal field never reaches this 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's declared-type door refuses it one step earlier, because "not a date value" is the wrong sentence for an operator no comparand could make runnable. That door does not front having, and [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's ruling keeps the row beneath it answered there (pinned in engine-text-operator-declared-type-door.test.ts). Judging them here would revive the retired wording, so $contains: "2026" and $startsWith: "not-a-date" on max(placed_on) answer exactly as before.

It judges the caller's own clause before the bigint narrowing, which is what where's object form judges (see Acceptance notes for the bigint consequence).

Measured: base 89f87f2344 and head, three drivers, both doors, both paths

InMemoryDriver, SqlDriver on SQLite, SqlDriver on PostgreSQL 16 (private cluster). Through engine.aggregate and POST /api/v1/data/:object/query, native path and rows path, groupBy customer over four groups. 1650 cells per run; 162 moved, all of them having cells that went from 200 with one read to 400 INVALID_FILTER with zero reads. Within each row below the three drivers, both doors and both paths agree.

having column · comparand $gt base → head $lt base → head where twin
max(date) · "not-a-date" no group → 400 c1–c4 → 400 400
max(date) · "+010000-01-01T00:00:00.000Z" c1–c4 → 400 no group → 400 400
max(date) · the number (REST and engine) or Date (engine) for 10000-01-01 c1–c4 → 400 no group → 400 400
max(date) · "2026-02-01" (control) c2 → c2 c1, c3 → c1, c3 200
min(datetime) · "not-a-date" no group → 400 c1–c4 → 400 400
min(datetime) · the extended-year ISO, number or Date unchanged unchanged not refused by the door (the datetime rule reads it; PostgreSQL answers 500, #20264's range)
min(datetime) · 2026 instant (control) c2–c4 → same c1 → same 200
max(time) · "not-a-date" no group → 400 c1–c4 → 400 400
max(time) · extended-year ISO, number, Date unchanged unchanged not refused by the door
max(time) · "12:00" (control) c3, c4 → same c1 → same 200
count / sum / avg · a string unchanged unchanged not judged (not temporal)

H3 cells: "not-a-date" on a placed_on or opened_at groupBy key, "noon" on a slot key, "not-a-date" and the number for 10000-01-01 on a day bucket: 200 → 400 on all three drivers and both doors. A month bucket and a text key: unchanged.

H2, paths. The call sits before getDriver, the middleware chain and both applyHaving doors. Driver reads of the object on a refusal: 0 on every refused cell (was 1), on both paths, on an empty and a populated object.

H4, collateral. Byte-identical base → head on all three drivers, both paths, both doors: every where and per-aggregation filter cell (11 shapes, each at both doors), and 41 having shapes: a day, an ISO instant, an epoch-ms number or string, in-range Date, zone-naive instant, wall clock, extended-year instant on datetime, {today} / {30_days_ago} / {not_a_token}, the empty and whitespace string, null, $exists, $in / $nin / $between, $not / $or / $and, { $field }, $contains / $startsWith, a string on sum, and the ten existing refusals (#20099 x3, #20123 x3, #20127, #19974, the type door, $icontains), in their words.

H5, the words. Envelope INVALID_FILTER / 400, the where door's wording class, naming the having path, the column, what it aggregates and its kind:

aggregate('ledger_20263'): `having` on 'col' (max(placed_on), a date column) compares against "not-a-date" at having.col.$lt, which is not a date value this platform can interpret. Compared with each group as written, it would keep no group or every group, a 200 indistinguishable from a real answer. The `having` was NOT applied. Write a "YYYY-MM-DD" calendar day.

The year class (a number or Date outside 0..9999 on a date column) keeps #20240's sentence. Disclosure: like where, the column's declared kind, and otherwise only what the query carries (alias, function, field, comparand, path). The remedy names no {placeholder}, unlike where's: having resolves none, so naming one would send the author to a literal.

Tests and evidence (head a2ff967202)

  • pnpm --filter @objectstack/objectql exec vitest run --project local --project repo --maxWorkers=2: 321 files, 5828 tests passed, at 438014d26c, before merging main (the merge touched no objectql file; the objectql source is byte-identical at head, and the one objectql test file changed since was re-run at head).
  • pnpm --filter @objectstack/rest exec vitest run --project local --project repo --maxWorkers=2, at 49d218bf7a (after merging main, closure rebuilt): 204 files, 3681 passed, 1 skipped.
  • pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2, at 438014d26c, with OS_TEST_POSTGRES_URL (server Asia/Shanghai, process TZ=America/New_York): 199 files passed, 3 skipped; 3888 tests passed, 88 skipped. The suite's own summary: live postgres RAN, live mysql NOT RUN (no MySQL here; CI's Temporal Conformance job runs it).
  • pnpm --filter @objectstack/driver-memory exec vitest run --maxWorkers=2, at 49d218bf7a: 57 files, 1374 tests passed.
  • New: engine-aggregate-having-temporal-door.test.ts 51 passed; data-query-having-temporal-door.test.ts 11 passed (re-run at head).
  • 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).
  • ESLint, narrowed and proven: eslint --no-inline-config --format json over the 6 changed .ts files, 6 files counted in the JSON, 0 errors, 0 warnings. Population 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.

Ablation 1 (the door call), at 438014d26c, through scripts/ablation-replace.mjs with restore proven. The call in engine.ts was replaced by a marker; objectql rebuilt (exit 0); ablation-dist-preflight found the marker in 4 built files. objectql test: 20 failed / 31 passed (every refused, one-predicate and H3 case red; every unchanged and earlier-door case green). REST test: 6 failed / 5 passed. Restore leg: blob equals HEAD, rebuilt, marker absent from all 14 built files, tree clean, 51/51 and 11/11 green.

Ablation 2 (the text-operator skip). judgesOperator forced to judge every operator: exactly the two text-operator cells went red (2 failed / 49 passed); restored byte-identical.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at head: 64 commands. All run at a2ff967202; --ran reconciliation: 64 derived, 62 run, 2 NOT MEASURED, 0 unrun.

  • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, reason: PREREQUISITE NOT MET (exit 3), both read a whole-workspace dist/ this worktree does not hold. CI builds it.
  • Red by design: node scripts/check-empty-changeset.mjs --base origin/main exits 1 on the two DELIBERATE CORRECTIONS below. Its own text: say so on the PR and get it confirmed; it stays red either way.
  • check:query-options-erasure went red on the first run (test surface 236 → 238, two as any option bags in the new tests); the options are typed now and it holds at 236.
  • node scripts/check-issue-citations.mjs --base 2dccb7d494: exit 0, 16 citations resolve.
  • Also run, as the derivation flagged their rosters under packages/: check:authz-resolver, check:error-code-casing, check:filter-alias-parity, all exit 0.

Deviations

Acceptance notes (observations, not filed)

  • A bigint comparand is judged by neither having nor where's object form (the door runs before the type door narrows it); where's array form narrows first and refuses it. In-process only, as JSON carries no bigint. Reported to the seat below.
  • On PostgreSQL, where with a non-numeric string on a number field answers 500 DATABASE_ERROR where memory and SQLite answer 200. Outside the temporal family; noted, not measured further.

Out-of-scope findings for the seat (not filed by this PR)

  1. having resolves no {placeholder}: { lp: { $gt: "{current_year_start}" } } on max(placed_on) keeps no group (both groups are after 2026-01-01), and {not_a_token} answers 200 where where answers FILTER_TOKEN_UNKNOWN / 400. resolveWhereTokens reads ast.where only.
  2. The per-aggregation filter's temporal refusal names the wrong position: a bad date in aggregations[1].filter is reported "at where.placed_on.$gt".
  3. On PostgreSQL, SqlDriver's native aggregate returns count / sum / avg as strings ("n":"1", "total":"20.000000000000000000000000000000", also over REST), so having { n: { $in: [2] } } keeps no group there while memory and SQLite keep c1, c2, and a string comparand on those columns answers differently on the native and the rows path.
  4. The bigint hole in the first Acceptance note, if the seat grades it a defect.

Generated by Claude Code

…e per-aggregation filter take (#20263)

A having comparand its aggregated column's storage rule cannot read is
refused INVALID_FILTER / 400 before any driver read, by the same walk and
the same core predicate as where; the kind comes from the column class
#20127 derives. Text operators are stepped over (#15661's door owns them).

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…door over SqlDriver (#20263)

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…rect the two pending notes it makes false (#20263)

Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx
Co-authored-by: Claude <noreply@anthropic.com>
…d of erasing them (#20263)

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 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 13 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query))
  • content/docs/api/data-api.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/api/wire-format.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/data-modeling/queries.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query), /data/:object/query (route, a path literal in a comment on a changed line))
What this run could not see
  • 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 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6a6a17b62e8b58d0ee31cbfabf5fc7322032ba94

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a2ff967202602a677d8d4e5ac6d4b99cbc4bf0a4

Scope: PR #20307 (card #20263, priority:p2, area:api), 9 files, +776/−15: packages/objectql/src/{engine.ts, having-filter.ts, temporal-comparand-door.ts}, two new pins (objectql/src/engine-aggregate-having-temporal-door.test.ts, rest/src/data-query-having-temporal-door.test.ts), one edited test header (rest/src/data-query-date-year-range.test.ts), .changeset/20263-having-temporal-comparand-door.md (new) and one-clause DELIBERATE CORRECTIONS to .changeset/20240-date-year-four-digits.md and .changeset/19974-having-comparand-shape-face.md (each git diff --numstat 1/1). packages/core, packages/spec and every driver src/ untouched. Merge base with origin/main = 2dccb7d494 (my base tree); the branch merged main at 49d218bf7a (parents 438014d26c, 2dccb7d494); the 6 main commits it brought in (89f87f2344..2dccb7d494) touch none of the 9 files (git diff --stat empty), and the three objectql source blobs are identical from 438014d26c to head (9bb4a1fd1720, a0e3b8508591, 05c2b337ccef); the two later commits typed test option bags only. git merge-tree against origin/main de091b50e6: clean (exit 0). Against PR #20306's head 93af6580d2: CONFLICT on .changeset/20240-date-year-four-digits.md alone, as the seat expects (not mine to resolve). Measured, not inferred: both trees built from source (turbo --force, cache confined to scratch, OS_SKIP_DTS=1), a scratch harness over InMemoryDriver, SqlDriver/SQLite and SqlDriver/PostgreSQL 16.13 (own role/db, TZ=America/New_York, server Asia/Shanghai), through engine.aggregate / engine.find and REST POST /api/v1/data/:object/query (JSON round-tripped), both having paths (native driver.aggregate + applyHaving; rows = a filtered aggregation forcing find + in-memory aggregation), a populated (6 rows, c1–c4) and an empty object, driver reads counted. 2670 cells per tree.

① Derived judgments

  • The rows (item 1) — HOLD. 2286 of 2670 cells byte-identical base→head (status, code, kept groups, message, reads); 384 moved, every one a having cell going 200 with 1 read → 400 INVALID_FILTER with 0 reads, on all three drivers, both doors, both paths, populated and empty. None moved in the where, per-aggregation filter, or collateral sections.
    • max(date) last_placed: "not-a-date" $gt (base: no group) / $lt (base: c1–c4) → 400; "+010000-01-01T00:00:00.000Z" $gt (c1–c4) / $lt (none) → 400; number 253402300800000 $gt (c1–c4) / $lt (none) → 400; Date for 10000-01-01 (engine; over REST it is its ISO string) $gt/$lt → 400. where twins on placed_on: 400 INVALID_FILTER, 0 reads, byte-identical base→head. Control "2026-02-01": $gt c2, $lt c1,c3, unchanged; twin 200 (o3 / o1,o2,o5).
    • min(datetime) first_opened: "not-a-date" $gt (none) / $lt (c1–c4) → 400, twin 400. Extended-year ISO, number and Date for 10000: unchanged ($gt c1–c4, $lt none); twin: memory/SQLite 200 (six rows / none), PostgreSQL 500 DATABASE_ERROR — not the door's class, temporal values outside the years a four-digit text or a backend holds: a datetime comparand for year 10000 or −1 misorders on memory/SQLite and 500s on PostgreSQL; a date in year 0000 500s on PostgreSQL; a date write stores +010000-… verbatim #20264's. Control 2026-02-01T00:00:00.000Z: $gt c2,c3,c4 / $lt c1 unchanged; twin o3–o6 / o1,o2.
    • max(time) last_slot: "not-a-date" $gt (none) / $lt (c1–c4) → 400, twin 400. Extended-year ISO / number / Date: unchanged (200); twin memory 200 none, SQLite 200 six rows (number/Date $gt), PostgreSQL 500 — a pre-existing driver disagreement, unchanged. Control "12:00": $gt c3,c4 / $lt c1 unchanged; twin o5,o6 / o1,o2,o3.
    • Controls count / sum / avg with the same five comparands: every cell unchanged (200) on both trees. Their where twins on amount: memory 200 none, SQLite 200 (six rows under $lt), PostgreSQL 500 — unchanged.
    • Bar: every uninterpretable comparand on a temporal having column answers INVALID_FILTER / 400 with 0 driver reads on both paths and both populations, matching its where twin's class (400 INVALID_FILTER); every interpretable cell and every non-temporal aggregate cell byte-identical. Held.
    • Messages: native path == rows path on every refused cell; engine == REST except 36 cells where REST truncates at its 500-char bound (the year-class sentence is longer) — the existing truncateClientMessage; populated == empty modulo the object name. The measured words: aggregate('ledger_20307'): `having` on 'last_placed' (max(placed_on), a date column) compares against "not-a-date" at having.last_placed.$lt, which is not a date value this platform can interpret. Compared with each group as written, it would keep no group or every group, a 200 indistinguishable from a real answer. The `having` was NOT applied. Write a "YYYY-MM-DD" calendar day. — matches the PR's H5 quote; the number class gets core temporalStorageForm: the date arm leaves a year outside 1000..9999 unpadded — over REST the epoch-ms number for 0999-06-15 counts $gt 0 / $lt 7 on InMemoryDriver and SQLite (correct 6 / 0); its ISO string counts 6 / 0 #20240's sentence (…outside the years 0000 to 9999…keep the wrong groups…); the remedy names no {placeholder}.
  • groupBy temporal key and day bucket (item 1) — RIGHT. groupBy ['placed_on'] + { placed_on: { $gt: 'not-a-date' } }, groupBy ['opened_at'] same, groupBy ['slot'] + 'noon', a day bucket d of opened_at with 'not-a-date' and with the number for 10000-01-01 (base kept every day bucket): 200 → 400 on all three drivers, both doors, both paths, 0 reads; words name the source (the groupBy field placed_on, a date column, the day bucket of opened_at, a date column). A month bucket ('2026-02', a text label) and a text key stay 200 none; the day-bucket control '2026-02-01' keeps the same three days. Judged: a projection of a temporal field carries that field's storage rule and a day bucket's label is a YYYY-MM-DD day, so the kind is right; a coarser bucket is text and must not be judged, and is not.
  • One predicate, no second rule (item 2) — HOLD. assertHavingTemporalComparandsInterpretable(object, having, classes, query) → walkCondition → judgeFieldComparands → judgeComparand → core's isUninterpretableTemporalComparand(kind, value); the kind is temporalKindOf(classes.get(key)) over aggregatedRowColumnClasses (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's map), computed once in engine.aggregate and handed in. No driver, no copy.
    • temporalKindOf's export is package-internal: having-filter.ts is imported only by engine.ts, in-memory-aggregation.ts and temporal-comparand-door.ts; src/index.ts and src/core.ts re-export nothing from it; on the built head, temporalKindOf, assertHavingTemporalComparandsInterpretable, findUninterpretableTemporalComparand and aggregatedRowColumnClasses are absent from both dist/index.mjs and dist/core.mjs (ObjectQL present, as the control). Not on the PUBLISHED index; not a surface widening.
    • walkCondition refactor: findUninterpretableTemporalComparand(schema, where, path = 'where', depth = 0) keeps its signature; the registry-less early return precedes the walk as before; the where scope is kindOf = temporalComparandKind(fields[key].type), judgesOperator = () => true, so the walk is the old walk. Measured: 19 where shapes and the same 19 as aggregations[1].filter, × 3 drivers × 2 doors, byte-identical base→head including message text and path (at where.placed_on.$gt, at where.placed_on.$in[1], $between[1], $or[1].placed_on.$gt), and the refusing codes (INVALID_FILTER; FILTER_TOKEN_UNKNOWN for {not_a_token}; REST-ingress VALIDATION_FAILED for the preset name — identical on both trees).
  • No collateral (item 3) — HOLD. 52 having shapes on the populated object, × 3 drivers × 2 doors × 2 paths, byte-identical: ISO instant on max(date) (c2,c4), in-range number/Date (c2), the 0999 number (c1–c4), epoch-ms string / zone-naive / bare-day bound on min(datetime) (c3,c4 / c3,c4 / c1,c2), extended-year and 10000 number/Date on min(datetime) and max(time), {today} (c1–c4), {30_days_ago} (none), {not_a_token} (200 none), '' and whitespace (c1–c4), null and $eq null (none), $exists (c1–c4), $in (c2,c4), $nin (c1,c2,c3), $between days (c1,c3,c4) and instants (c1,c2), $not (c1,c3,c4), $or (c1,c2), $and (c2), implicit equality (c4), { $field } (c1–c4), $contains '2026' (c1–c4), $startsWith 'not-a-date' (none), a string on count/sum/avg. Existing refusals keep code, status and words on both trees: objectql having does not take the rest of where's filter doors: a $field reference is never resolved, the comparand-TYPE door does not run, FilterArray sugar answers no group, and its own refusals fire only on a non-empty grouped set #20099 $median (on max(date) with 'not-a-date', and on sum), 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 unknown key totl alone, beside a bad date, and dotted, 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 addDays numeric pair alone and beside a bad date, the type door's plain-object comparand alone and beside a bad date, objectql having answers an array in the equality slot by JS coercion (having: { total: [5] } is true) — the one face ruling 乙 closes "for every driver at once" that no gate reaches #19974 array in the equality slot (bad date, and number), objectql having does not take the rest of where's filter doors: a $field reference is never resolved, the comparand-TYPE door does not run, FilterArray sugar answers no group, and its own refusals fire only on a non-empty grouped set #20099 array having, $icontains '', { $field: 'nope' }, and a bad date under $or beside a $median arm — all 400 INVALID_FILTER at the engine (the three array shapes and $icontains '' are VALIDATION_FAILED at REST ingress, unchanged). The door runs last, as claimed.
  • The pins discriminate (item 4) — HOLD, reproduced. Ablation 1 via scripts/ablation-replace.mjs --hold on engine.ts: anchor assertHavingTemporalComparandsInterpretable(object, query.having, havingColumnClasses, query); 1 → 0, blob 9bb4a1fd1720 → b3a2f59cbf05 (replacement gates the call behind globalThis.ABL20307_DOOR_OFF, import kept referenced); objectql rebuilt exit 0; ablation-dist-preflight: marker present in 4 built files; objectql pin 20 failed / 31 passed, REST pin 6 failed / 5 passed — the dev's counts. Restore: blob == HEAD 9bb4a1fd1720, git diff HEAD empty, rebuilt, preflight --absent: marker absent from all 8 built files (8, not the dev's 14: my build emitted no .d.ts), git status --porcelain empty; pins 51/51 and 11/11 after. Ablation 2 on temporal-comparand-door.ts: anchor judgesOperator: (op) => !isTextFilterOperator(op), 1 → 0, blob 05c2b337ccef → dd51c2b0fa6c, rebuilt, marker present; objectql pin 2 failed / 49 passed, exactly $contains on max(date) and $startsWith on max(date); REST 11/11 (information). Restore: blob == HEAD 05c2b337ccef, preflight absent, tree clean; both pins green on the restored tree (51/51, 11/11). (A first ablation-1 attempt whose replacement still contained the anchor was refused by the tool — anchor count 1 → 1 — and auto-restored; only the landing run is reported.)
  • Prose (item 6) — no FALSE sentence.
  • Out-of-scope findings re-measured at base 2dccb7d494 for the seat (not verdict items). (Base is the merge base, not origin/main de091b50e6: main gained 13 commits since, none in packages/objectql, core or the drivers — packages/formula/src/matches-filter.ts, metadata-protocol/src/protocol.ts, stored-migration.ts, rest/src/rest-server.ts, meta-item-read-gate.ts, index.ts moved.) Every cell below is identical at head.
    1. REPRODUCES at REST, all three drivers, both paths: having { last_placed: { $gt: '{current_year_start}' } } on max(placed_on) → 200 keeps no group (every group's max day is in 2026; the resolved bound would keep c1–c4); { $gte: '{not_a_token}' } → 200 no group, while the where twin answers 400 FILTER_TOKEN_UNKNOWN ("Unresolvable filter placeholder "{not_a_token}"…"); {30_days_ago} on having keeps none and {today} keeps c1–c4 by text order.
    2. REPRODUCES at REST and the engine, all three drivers: aggregations[1].filter: { placed_on: { $gt: 'not-a-date' } } → 400 INVALID_FILTER whose message reads …at where.placed_on.$gt… (the walk roots at where; assertTemporalComparandsInterpretable is called with no path).
    3. REPRODUCES on PostgreSQL alone: the native-path aggregated record over REST and engine carries "n":"1", "total":"20.000000000000000000000000000000", "mean":"20.000…" as strings (rows path: numbers); having { n: { $in: [2] } } keeps c1,c2 on memory, SQLite and PostgreSQL's rows path, none on PostgreSQL's native path; { total: { $in: [500, 20] } } c1,c4 vs none; 'not-a-date' $lt / extended-year $gt on count/sum/avg keep c1–c4 on PostgreSQL native vs none elsewhere.
    4. (finding 6) REPRODUCES: where { amount: { $gt: 'abc' } } → memory and SQLite 200 no rows; PostgreSQL 500 DATABASE_ERROR ("Internal server error") at REST and the engine. The same as a per-aggregation filter → 200 with 0 counts on all three (evaluated in memory).
  • CI at head (item 9), read last, all 34 check runs completed: 30 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 1 failure: Check Changeset, by design, on exactly the two corrected changesets — its log: "This PR changes a changeset it did not add: .changeset/19974-having-comparand-shape-face.md — present on the merge base and CHANGED by this PR -- this is somebody else's release note · .changeset/20240-date-year-four-digits.md — present on the merge base and CHANGED by this PR … 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 gate stays red either way" (exit 1; its --self-test 159 assertions and "No empty-frontmatter changeset introduced" passed first). No other failure. Success includes Build Core, Test Core (1–6 and aggregate), Dogfood Regression Gate (1–3 and aggregate), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), TypeScript Type Check with its four sub-jobs, Lint & Repo Gates, Governed Surface Queue Guard, the claim/card/single-writer guards, Check PR Size, Check Documentation Links, Flag docs affected, Auto Label, filter. The required-context set itself is not readable from this seat's tools; every non-skipped context other than Check Changeset is success.

② Semver level

  • Clause-②: no (narrowing) — RIGHT: the PR adds no key to a published payload and no published export (the temporalKindOf export is internal, above); it narrows having's accept set (384 measured cells 200 → 400). No widening arm is owed.
  • @objectstack/objectql: minor with the BREAKING banner — RIGHT under the launch-window convention; rest changes are tests only, so no rest changeset is owed. node scripts/check-changeset-no-major.mjs --base 2dccb7d494 --event event.json (the CURRENT PR body as the pull_request payload): exit 0 — "✓ This diff introduces no major bump. ✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch. · declaration line: Clause-②: no (narrowing) · direction arm: narrowing — a BREAKING change; during the launch window it ships minor".
  • ADR-0087 not-required (no-migration-prescription) — RIGHT: having is a request-only key (QuerySchema.having, EngineAggregateOptions.having), no stored document, and the body prescribes no FROM → TO rewrite. node scripts/check-adr-0087-registration.mjs --base 2dccb7d494 (and --base origin/main): exit 0 — "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition. .changeset/20263-having-temporal-comparand-door.md [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription) reason: this change adds no transition to migrate…". Locally node scripts/check-empty-changeset.mjs --base origin/main exits 1 naming exactly 19974-having-comparand-shape-face.md and 20240-date-year-four-digits.md, the DELIBERATE CORRECTION class, confirmed above.

③ Boundary flags

Implemented-by: claude/issue-20263-having-temporal-door
Reviewed-by: session_01Bvd69VPa6puiNzzPUroDBx

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 22:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit a78f731 Sep 27, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20263-having-temporal-door branch September 27, 2026 23:02
This was referenced Sep 27, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…er, and the per-aggregation filter refusals name aggregations[i].filter (objectstack-ai#20334) (objectstack-ai#20368)

Fixes objectstack-ai#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)

- At base, `resolveWhereTokens(ast, execCtx)` read `ast.where` alone:
`if (!ast || ast.where == null) return; ast.where =
resolveWhereFilterTokens(ast.where, execCtx);`. The per-aggregation
`filter` resolved through its own call to core's `resolveFilterTokens`,
and `having` reached no resolver at all.
- **The resolver needs no field type.** Core's walk replaces values and
never reads keys (`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 a `having` keyed by aggregate aliases
resolves exactly as a `where` keyed by fields does. The one
field-type-aware step, reading a resolved day by a column's storage
rule, is `applyHaving`'s, as it already is for a literal day.
- **Order against the temporal door.** On `where` the door runs inside
`lowerWhereFilterArray`, before `resolveWhereTokens`, and steps around
any `classifyFilterToken` hit (core's
`isUninterpretableTemporalComparand`). `having`'s door (objectstack-ai#20263) runs in
the same position relative to the new call: every `having` door first,
then resolution. So `{ last_placed: { $lt: 'not-a-date' }, first_opened:
{ $gte: '{not_a_token}' } }` is the temporal door's `INVALID_FILTER`,
and an earlier door's refusal (`totl`, objectstack-ai#20123) keeps its words. Both are
pinned. As on `where`, a resolved value is not judged again by the door.
- Both `applyHaving` doors (native `driver.aggregate()` and the rows
fallback) read `ast.having`, so one call covers both paths, before
`executeWithMiddleware` and any driver read. The caller's `having`
object is not written back: the resolver is copy-on-write, and the AST
is the engine's own object (pinned).

## 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 (objectstack-ai#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,
objectstack-ai#20307's out-of-scope finding 3, the region objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 2972097`: 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

- **DELIBERATE CORRECTION,
`.changeset/20263-having-temporal-comparand-door.md`**: two sentences,
numstat 2/2 against `main`. `Check Changeset` stays red by design. Every
rewrite, in order:
1. First round, the `What is judged` clause. Before (on `main`):
"`having` does not resolve placeholders, and did not before, so the
refusal's remedy names none." After (`a1a42d4c4a`): "`having` resolves
placeholders from the same release (objectstack-ai#20334), after this door, and the
refusal's remedy names none."
2. Patch round, the same clause again, because its last half is false at
`8b950b8e`. Before: the `a1a42d4c4a` text above. After: "`having`
resolves placeholders from the same release (objectstack-ai#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}`."
3. Patch round, the note's own **Fix.** line gains the placeholder form.
Before: "**Fix.** Compare a `date` column with a `YYYY-MM-DD` day, a
`datetime` column with an ISO-8601 instant, a bare day or epoch
milliseconds, and a `time` column with an `HH:MM` or `HH:MM:SS` wall
clock." After: "**Fix.** Compare a `date` column with a `YYYY-MM-DD`
day, a `datetime` column 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}`; `having` resolves them
from the same release, objectstack-ai#20334), and a `time` column with an `HH:MM` or
`HH:MM:SS` wall clock."

Its "Unchanged" list ("`{today}`-style placeholders, known or not" and
"every existing `having` refusal, in its words") is a before-and-after
statement about objectstack-ai#20263's own door, so it stays TRUE and is not touched.
The sentence "The refusal follows the `where` door'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 "**The `having` temporal refusal's remedy is `where`'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
"every `having` that carries no placeholder, its refusals in their
words;" became "every `having` that carries no placeholder, and its
refusals in their words other than the `date` / `datetime` temporal
refusal's remedy above;". Both old sentences read as "the words did not
move", which is false at `8b950b8e`.
- **Bounded in-place fix beyond the claimed surface:
`packages/objectql/src/text-operator-declared-type-door.ts`**, its
`path` parameter and the `path` field's doc line only. All four
conditions hold. ① It is ruling 2's defect class exactly: a
per-aggregation refusal naming `where`. ② 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 objectstack-ai#20309's branch;
objectstack-ai#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.ts` beyond "its path parameter only"**: in
the first round the `HAVING_REMEDY` doc 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
deletes `HAVING_REMEDY` and its doc comment and the `having` refusal
reads `REMEDY`. This is item 1 of that answer, not a surface breach.
- **The objectstack-ai#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}` on `having`, which is the branch this change removes. It
is now a case asserting `FILTER_TOKEN_UNKNOWN` / 400 with zero reads,
not the door's `INVALID_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 a
`date` and a `datetime` column it asserts the envelope (`INVALID_FILTER`
/ 400 through `expectHavingRefusal`, both paths, empty or populated, no
read), the message's first sentence, `"{30_days_ago}"` in the remedy,
and the remedy byte-equal to the `where` twin's. The ablation below
turns it red.
- **"Pin on the three drivers"** (ruling 3) is executed as objectstack-ai#20307
executed it. No driver reads `having` and 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 the `where` twin 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 has `driver-sql` but
no `driver-memory` and no CI job hands it a PostgreSQL URL. Adding
either is outside the surface.

## 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 objectstack-ai#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 objectstack-ai#20307's finding 3, which objectstack-ai#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 objectstack-ai#20334).

---------

Co-authored-by: Claude <noreply@anthropic.com>
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