fix(core,objectql)!: a relative-date placeholder resolved outside its field's years is refused INVALID_FILTER / 400, naming the placeholder and the year - #21065
Conversation
… column's years Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…year range on every position Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…eld's years is refused Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…solved-token-year-range
…time column takes the door's time class Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…solved-token-year-range
…solved-token-year-range
📓 Docs Drift CheckThis PR changes 2 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 34 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 35366c09110603c52f2c764a7e59f7828f05d78b && git checkout 35366c09110603c52f2c764a7e59f7828f05d78b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8f784959cf7e597e4582a70b93633b10eddb2dcf 4c5f259e5cb5455f712f792b06edb95058174cf9 && git checkout -B drift-repro 8f784959cf7e597e4582a70b93633b10eddb2dcf && git merge --no-ff 4c5f259e5cb5455f712f792b06edb95058174cf9
node scripts/docs-audit/affected-docs.mjs --json 8f784959cf7e597e4582a70b93633b10eddb2dcf
|
Contract reviewServed-tier: ① Derived judgmentsInputs: card #20844 (body; triage 5910693971, release 5913754836, claim 5923455068, os-dev-report 5924544832), PR #21065's body and file list, the net diff of Accept-set changes — all narrowings, all before any driver read, all
Public-surface changes:
Wording note, not a defect: the changeset's sentence "Measured before this on InMemoryDriver and SqlDriver on SQLite" also covers the ② Semver levelThe changeset is
③ Boundary flags
(a) (b) (c) The (d) The memory cell pinned by objectql's recording driver, memory rows measured by probe only. ANSWERED, accepted: the lane convention the dev cites is real ( (e) Write-budget handling (the until-loops, then one write). Process, not contract; noted. (f) Attribution trailers: every non-merge commit on the branch carries the model-free trailer pair AGENTS.md prescribes, the PR body ends in the session-URL footer, and the merge commits carry none. Noted; nothing owed. (g) (h) Check-runs on the head: 26 Implemented-by: VERDICT: PASS |
Fixes #20844
Clause-②: no (narrowing)
A relative-date placeholder is now judged by the year of the value it resolved to. If that value falls outside its column's years (a
date0001..9999, adatetime1000..9999, core'sisOutsideTemporalYearRange), the engine refuses it withINVALID_FILTER/ 400. The refusal names the placeholder as written and the year it resolved to, in the temporal-comparand door's words. It runs onwhere(find,findOne,count,aggregate, multi-rowupdateanddelete), a per-aggregationfilter,having, andjudgeFilter, before any driver read.Dispatched by the PM loop (round 1, seat
domain:engine#2), claim comment 5923455068, sessionsession_01Ujdtvqs7ree7WyQmEDwEnG.What changes
packages/objectql/src/engine.ts, the resolution stage.resolveWhereFilterTokenstakes a required judge and hands it the condition before and after resolution whenever something resolved. Execution and the judge call the same stage function, so they cannot drift:resolveRelateThenLowerWhereserveswhereon every verb,resolveThenLowerWhereserveshavingand the per-aggregationfilter, andjudgeWhereAdmissionservesjudgeFilter. The aggregate region changes in two places: the per-aggregation resolution call passes its judge, rooted ataggregations[i].filter, and one comment that this change made false is corrected. Neither is the aggregate door.packages/objectql/src/temporal-comparand-door.ts, beside the door. The door's one walk now carries a twin tree step for step, and the door itself passes the tree as its own twin, so its verdicts are unchanged (the door suites are green). The newassertResolvedTemporalTokensInRange/assertHavingResolvedTemporalTokensInRangewalk the caller's condition beside its resolution. They judge only a comparand written as a date macro (classifyFilterTokenkinddate-macro), by the year class the door asks of a literal of the same value:dateor adatetimeasks core'sisOutsideTemporalYearRange;timecolumn asks the door's[#20480]class, an instant whose UTC year has no four-digit spelling. Year 0 (0000-…) still reads, as it does for a literal.This is ⛔ not a second pass of the door: every other comparand was judged before resolution. There is ⛔ no second copy of the range, and ⛔ no new error code.
packages/core/src/utils/filter-tokens.ts, the producer. A date macro that lands on a day outside 0001..9999 now resolves to the expanded-year form of ECMAScript's date time string format:+010026-10-01,-000001-10-01, or0000-10-01for year 0. It used to take the storage rule's unpadded spelling (10026-10-01,-1-10-01,0-10-01).Date.parsereads that spelling through the host's legacy parser, in the host's zone, so-1-10-01read as 2001-01-10. Every reader read it that way,isOutsideTemporalYearRangeincluded, for both kinds (measured below). The resolver stays field-agnostic; the range is asked of the day itself. A day inside 0001..9999 and a sub-day placeholder's instant are spelled as before.Measured, before and after (scratch probes, not committed)
The card's object has two rows:
opened_at(datetime) at 2026-03-01T10:00Z and 1500-03-01T10:00Z,placed_on(date) on the same days, andopens_at(time) at 09:00 and 12:00. The engine column isengine.findon InMemoryDriver. The REST column isPOST /api/v1/data/:object/queryon SqlDriver over SQLite (better-sqlite3).whereopened_at $gt {8000_years_from_now}10026-10-01INVALID_FILTER, year 10026opened_at $lt {2027_years_ago}-1-10-01(read as 2001-01-10)opened_at $lt {1977_years_ago}0049-10-01datetimefloor)placed_on $gt {8000_years_from_now}10026-10-01placed_on $lt {1977_years_ago}0049-10-01datekeeps 0001..0999)opens_at $gt {8000_years_from_now}(see note)+010026-10-01havingmax(opened_at) $gt {8000_years_from_now}judgeFilterof the first two rows{ ok: true }{ ok: false, code: INVALID_FILTER, status: 400 }, execution's messageopened_at $lt {100_years_ago}/$gt1926-10-01{2027_years_ago}answered one row on the base, not two as the card records: the base spelling is read by the legacy parser as a day in 2001.Note on the
timerow: it was measured on this branch before thetimecommit (d5a8e100b), with core's new spelling already in, throughPOST /api/v1/data/:object/queryon InMemoryDriver and on SQLite. On the base the value is10026-10-01, which also compares as text above everyHH:MM:SS, so that row is read from the code, not measured on2f2fa11d7.The PM's hypotheses (zone 2)
2f2fa11d7(table above). One cell has moved since the card was measured:$lt {2027_years_ago}answers the 1500 row, where the card records both rows.isOutsideTemporalYearRangecould not see years at or below 0. Core spelled them-1-10-01/0-10-01, and the range read those as 2001 / 2000 for both kinds. Measured in UTC, America/New_York and Asia/Shanghai,isOutsideTemporalYearRangeansweredfalse. The defect is upstream, so it is fixed at the producer: one field-agnostic spelling change infilter-tokens.ts, which the claim lists as staying field-agnostic, not as read-only. A consumer-side parse of the unpadded spelling would have been the lenient fallback AGENTS.md rules out. Once the spelling is readable, triage's two halves hold together: resolution refuses per kind, and thedatetimefloor of 1000 applies to a resolved placeholder as it does to a literal.judgeWhereAdmissioncalls the same stage function with the same judge. The pin asserts thatjudgeFilterreturns execution's exactcode,statusandmessage.havingis in reach. On the base,max(opened_at) $gt {8000_years_from_now}kept both groups. It is now refused by the aggregated column's class (aggregatedRowColumnClasses:min/maxkeep the field's kind, adaybucket is adate).count/sum/avgcolumns are not temporal and are not judged. Text operators are stepped over onhaving, as thehavingdoor does.{N_years_*}and every other day-or-coarser macro resolve to a calendar day:YYYY-MM-DD, or now±YYYYYY-MM-DDoutside 0001..9999.{now}/{N_hours_*}/{N_minutes_*}resolve to an instant (toISOString). The message's year is pinned for both forms: days+010026-09-30(10026),-000001-09-30(-1) and0049-09-30(49), and the instant{80000000_hours_from_now}(11153).Faces that resolve
{tokens}outside the enginepackages/services/service-analytics/src/analytics-service.tsanddataset-executor.tsare already refused for a time-dimension member (code evidence plus a predicate measurement, not measured end to end); out of scope otherwise (domain:services). Both resolve the query's positions first and then pick a strategy. The ObjectQL strategy hands the resolved literal toengine.aggregate, where the temporal-comparand door refuses it as a literal. The native-SQL strategy declines a time-dimension member whose comparand core'sisUninterpretableTemporalComparandrefuses (comparand-shape.tsfindUninterpretableTemporalMember). That predicate answerstruefor both the base spelling and the new one (measured:-1-10-01,10026-10-01,-000001-10-01,+010026-10-01, both kinds), so the declined query reaches the engine door. The residual is the hole that package already records for a temporal column not declared as a time dimension.packages/services/service-analytics/src/read-scope-sql.tsis changed on the ObjectQL face, out of scope on the native-SQL face. On the ObjectQL face the engine resolves the original scope insidewhere, so this PR's judge applies. On the native-SQL face (compileScopedFilterToSql) the resolved tree is lowered straight to SQL. A read scope is platform-authored (CEL / stored policy), not caller input, and this isdomain:services. Not measured.packages/services/service-analytics/src/strategies/filter-normalizer.tsis already compliant: it has no resolver call, only a doc comment namingresolveFilterTokens'FILTER_TOKEN_UNRESOLVED.packages/plugins/plugin-security/src/position-catalog-refusal.tsis already compliant: it never resolves. A placeholder-shaped position name is compared in JavaScript with===against catalog names (catalogCarries), so no resolved instant reaches a comparison.packages/services/service-automation/src/builtin/template.tsis changed, through the engine.interpolateFilterpasses a recognised filter placeholder through verbatim to the engine, whose resolution stage now judges it.packages/core/src/utils/analytics-date-range.tsis unaffected (a resolver caller the dispatch list did not name). It asks only fixed tokens (today,week_start,7_days_ago, …), which never land outside 0001..9999.Pins
packages/objectql/src/engine-resolved-token-year-range.test.ts(recording driver,Datepinned to 2026-09-30T12:00Z). It pins every position for both kinds;code+status+ the placeholder +resolved to "…" (the year N)+ the kind's years in the message; and the door's words per position (at aggregations[1].filter…, thehavingcolumn phrase). It also covers a list member, a$betweenbound, implicit equality, a$notbranch and the array sugar. Further cases: thedatetimefloor ({1977_years_ago},{1027_years_ago}) with thedatecontrol reaching the driver as its day, and the in-range control on every position, reaching the driver as the day it names. The rest arejudgeFilterequal to execution, thetimeclass (year 0 still read), and a text column / context placeholder not judged.packages/rest/src/data-resolved-token-year-range.test.ts(SqlDriver on SQLite, the card's two rows plus atimecolumn) checks 400INVALID_FILTERnaming the placeholder and the year atwhere, the per-aggregationfilterandhaving, with no read of the object. The controls answer the right rows, filter counts andhavinggroups.packages/core/src/utils/filter-tokens-year-outside-range.test.tschecks the expanded-year spelling on both sides of 0001..9999 and year 0. It runs in UTC and Asia/Shanghai, with the range reading the year it names, and covers the edges inside, thedatetimefloor's edge and the sub-day instant.Verification
Tests ran on
fc3fc97f3. The final head4c5f259e5adds only a merge oforigin/mainthat touches no package source (pr-automation.yml,packages/spec/CHANGELOG.md,scripts/check-empty-changeset.mjs).@objectstack/core:vitest run --project local: 62 files / 1822 tests passed;typecheckpassed.@objectstack/objectql(dist rebuilt):vitest run --project local: 350 files / 6860 tests passed;--project repo1 / 5 passed;typecheck(incl.check:test-typecheck) passed.@objectstack/rest:typecheckpassed. The new pin plusaggregation-filter-array-membership,data-temporal-year-rangeandrest-aggregate-positionspassed (21 passed, 26 named skips for unprovisioned live PG / MySQL cells). Earlier, at574bcbb51, the 27 rest suites that mention tokens, temporal values or years: 755 passed, 65 skipped. The full rest suite is declared to CI.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat4c5f259e5(67 commands, all run at that head, exits recorded). 65 exited 0 and--ranreconciles 67 / 67. NOT MEASURED:pnpm check:dual-build-cjs-loadsandpnpm check:type-check-debt, reason: exit 3 PREREQUISITE NOT MET. Both read every workspace package's builtdist/, and only the core / objectql / rest closure was built here. This diff changes no build config, noexportsand no published type. Declared to CI.node scripts/check-driver-conformance.mjs: before "50 covered cell(s), 0 in the DEBT ledger, 0 exempt"; after the same.Reverse verification (ablations, one-off, not committed)
Each leg went through
scripts/ablation-replace.mjsin wrap mode, with a trap restore. A suite resolving throughdist/got a rebuild andscripts/ablation-dist-preflight.mjson both legs. Each restore was proven by blob equality and an emptygit diff HEAD, and the tree was clean after. All three went red, the usual direction.engine.ts, atd5a8e100b). The objectql pin went 7 / 9 red and the REST pin 1 / 2 red, with the marker in objectql'sdist/(4 files). Restored: 9 / 9 and 2 / 2 green, marker absent.filter-tokens.ts, at4dcb910f1). The core pin went 12 / 24 red (every outside-range row, both hosts). The objectql pin went 6 / 8 red through core's rebuiltdist/, including$inwith{2027_years_ago}answered rather than refused. Restored: 24 / 24 and 8 / 8 green, with the original marker back indist/(2 files).timebranch answers "inside" (temporal-comparand-door.ts, atd5a8e100b). The first attempt was a no-op thatablation-replacerefused: its replacement contained the anchor, so the anchor count did not drop and no mutation ran. Redone with a disjoint replacement, the objectql pin went 1 / 9 red (exactly thetimetest). Restored: 9 / 9.Acceptance notes
data-no-operator-object-door.test.ts's header: neitherobjectqlnorrestdepends ondriver-memory. The memory measurements in the table are from the scratch probe throughengine.findon InMemoryDriver.{ owner: { opened_at: … } }) is resolved by the parent's stage before the related read. The related object's own door then refuses the resolved literal, naming the value rather than the placeholder. This PR does not change that.timeclass joins on the bounded in-place rule: the same defect class (a resolved placeholder bypasses the door's year class), the door's own pinned words, the same file and the same gates.Dateholds. A day macro resolves to the textInvalid Date, soopened_at $lt {300000_years_ago}answers 200 with both rows on memory and SQLite throughPOST /api/v1/data/:object/query. A sub-day macro throws an uncodedRangeErrorinside the resolver, and the same door answers 500INTERNAL_ERRORfor{99999999999999999999_minutes_ago}. That mechanism is not the card's extended-year text, and no shape for its refusal is pinned, so this PR leaves it alone.content/docs/releases/and everyCHANGELOG.mdare untouched. The changeset is.changeset/20844-resolved-token-year-range.md(@objectstack/coreminor,@objectstack/objectqlminor, BREAKING).check:adr-0087-registrationreads its disposition asnot-required (no-migration-prescription). The05a7547c9precedent registered no ADR-0087 id, soalready-registeredhas nothing to name.Generated by Claude Code