Skip to content

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

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20844-resolved-token-year-range
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20844-resolved-token-year-range

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 date 0001..9999, a datetime 1000..9999, core's isOutsideTemporalYearRange), the engine refuses it with INVALID_FILTER / 400. The refusal names the placeholder as written and the year it resolved to, in the temporal-comparand door's words. It runs on where (find, findOne, count, aggregate, multi-row update and delete), a per-aggregation filter, having, and judgeFilter, before any driver read.

Dispatched by the PM loop (round 1, seat domain:engine#2), claim comment 5923455068, session session_01Ujdtvqs7ree7WyQmEDwEnG.

What changes

  • packages/objectql/src/engine.ts, the resolution stage. resolveWhereFilterTokens takes 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: resolveRelateThenLowerWhere serves where on every verb, resolveThenLowerWhere serves having and the per-aggregation filter, and judgeWhereAdmission serves judgeFilter. The aggregate region changes in two places: the per-aggregation resolution call passes its judge, rooted at aggregations[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 new assertResolvedTemporalTokensInRange / assertHavingResolvedTemporalTokensInRange walk the caller's condition beside its resolution. They judge only a comparand written as a date macro (classifyFilterToken kind date-macro), by the year class the door asks of a literal of the same value:

    • a date or a datetime asks core's isOutsideTemporalYearRange;
    • a time column 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, or 0000-10-01 for year 0. It used to take the storage rule's unpadded spelling (10026-10-01, -1-10-01, 0-10-01). Date.parse reads that spelling through the host's legacy parser, in the host's zone, so -1-10-01 read as 2001-01-10. Every reader read it that way, isOutsideTemporalYearRange included, 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, and opens_at (time) at 09:00 and 12:00. The engine column is engine.find on InMemoryDriver. The REST column is POST /api/v1/data/:object/query on SqlDriver over SQLite (better-sqlite3).

where resolved to (base) base, engine on memory base, REST on SQLite now, both
opened_at $gt {8000_years_from_now} 10026-10-01 200, both rows 200, both rows 400 INVALID_FILTER, year 10026
opened_at $lt {2027_years_ago} -1-10-01 (read as 2001-01-10) 200, the 1500 row 200, the 1500 row 400, year -1
opened_at $lt {1977_years_ago} 0049-10-01 200, no row 200, no row 400, year 49 (the datetime floor)
placed_on $gt {8000_years_from_now} 10026-10-01 200, both rows 200, both rows 400, year 10026
placed_on $lt {1977_years_ago} 0049-10-01 200, no row 200, no row 200, no row (a date keeps 0001..0999)
opens_at $gt {8000_years_from_now} (see note) +010026-10-01 200, both rows 200, both rows 400 (the time class)
having max(opened_at) $gt {8000_years_from_now} 200, both groups 400
judgeFilter of the first two rows { ok: true } { ok: false, code: INVALID_FILTER, status: 400 }, execution's message
control: opened_at $lt {100_years_ago} / $gt 1926-10-01 the 1500 row / the 2026 row the same unchanged

{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 time row: it was measured on this branch before the time commit (d5a8e100b), with core's new spelling already in, through POST /api/v1/data/:object/query on InMemoryDriver and on SQLite. On the base the value is 10026-10-01, which also compares as text above every HH:MM:SS, so that row is read from the code, not measured on 2f2fa11d7.

The PM's hypotheses (zone 2)

  • H1, reproduced on 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.
  • H2, partly falsified. The check lives where the PM expected: the engine's resolution stage, which holds the field map. But judging "the values resolution produced" with isOutsideTemporalYearRange could 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, isOutsideTemporalYearRange answered false. The defect is upstream, so it is fixed at the producer: one field-agnostic spelling change in filter-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 the datetime floor of 1000 applies to a resolved placeholder as it does to a literal.
  • H3, both doors agree. judgeWhereAdmission calls the same stage function with the same judge. The pin asserts that judgeFilter returns execution's exact code, status and message.
  • H4, having is 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 / max keep the field's kind, a day bucket is a date). count / sum / avg columns are not temporal and are not judged. Text operators are stepped over on having, as the having door does.
  • H5, two output forms. {N_years_*} and every other day-or-coarser macro resolve to a calendar day: YYYY-MM-DD, or now ±YYYYYY-MM-DD outside 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) and 0049-09-30 (49), and the instant {80000000_hours_from_now} (11153).

Faces that resolve {tokens} outside the engine

  • packages/services/service-analytics/src/analytics-service.ts and dataset-executor.ts are 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 to engine.aggregate, where the temporal-comparand door refuses it as a literal. The native-SQL strategy declines a time-dimension member whose comparand core's isUninterpretableTemporalComparand refuses (comparand-shape.ts findUninterpretableTemporalMember). That predicate answers true for 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.ts is changed on the ObjectQL face, out of scope on the native-SQL face. On the ObjectQL face the engine resolves the original scope inside where, 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 is domain:services. Not measured.
  • packages/services/service-analytics/src/strategies/filter-normalizer.ts is already compliant: it has no resolver call, only a doc comment naming resolveFilterTokens' FILTER_TOKEN_UNRESOLVED.
  • packages/plugins/plugin-security/src/position-catalog-refusal.ts is 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.ts is changed, through the engine. interpolateFilter passes a recognised filter placeholder through verbatim to the engine, whose resolution stage now judges it.
  • packages/core/src/utils/analytics-date-range.ts is 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, Date pinned 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…, the having column phrase). It also covers a list member, a $between bound, implicit equality, a $not branch and the array sugar. Further cases: the datetime floor ({1977_years_ago}, {1027_years_ago}) with the date control reaching the driver as its day, and the in-range control on every position, reaching the driver as the day it names. The rest are judgeFilter equal to execution, the time class (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 a time column) checks 400 INVALID_FILTER naming the placeholder and the year at where, the per-aggregation filter and having, with no read of the object. The controls answer the right rows, filter counts and having groups.
  • packages/core/src/utils/filter-tokens-year-outside-range.test.ts checks 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, the datetime floor's edge and the sub-day instant.

Verification

Tests ran on fc3fc97f3. The final head 4c5f259e5 adds only a merge of origin/main that 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; typecheck passed.
  • @objectstack/objectql (dist rebuilt): vitest run --project local: 350 files / 6860 tests passed; --project repo 1 / 5 passed; typecheck (incl. check:test-typecheck) passed.
  • @objectstack/rest: typecheck passed. The new pin plus aggregation-filter-array-membership, data-temporal-year-range and rest-aggregate-positions passed (21 passed, 26 named skips for unprovisioned live PG / MySQL cells). Earlier, at 574bcbb51, the 27 rest suites that mention tokens, temporal values or years: 755 passed, 65 skipped. The full rest suite is declared to CI.
  • Gates, derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 4c5f259e5 (67 commands, all run at that head, exits recorded). 65 exited 0 and --ran reconciles 67 / 67. NOT MEASURED: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt, reason: exit 3 PREREQUISITE NOT MET. Both read every workspace package's built dist/, and only the core / objectql / rest closure was built here. This diff changes no build config, no exports and 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.mjs in wrap mode, with a trap restore. A suite resolving through dist/ got a rebuild and scripts/ablation-dist-preflight.mjs on both legs. Each restore was proven by blob equality and an empty git diff HEAD, and the tree was clean after. All three went red, the usual direction.

  1. The stage never calls its judge (engine.ts, at d5a8e100b). The objectql pin went 7 / 9 red and the REST pin 1 / 2 red, with the marker in objectql's dist/ (4 files). Restored: 9 / 9 and 2 / 2 green, marker absent.
  2. Core's expanded-year spelling reverted (filter-tokens.ts, at 4dcb910f1). The core pin went 12 / 24 red (every outside-range row, both hosts). The objectql pin went 6 / 8 red through core's rebuilt dist/, including $in with {2027_years_ago} answered rather than refused. Restored: 24 / 24 and 8 / 8 green, with the original marker back in dist/ (2 files).
  3. The time branch answers "inside" (temporal-comparand-door.ts, at d5a8e100b). The first attempt was a no-op that ablation-replace refused: 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 the time test). Restored: 9 / 9.

Acceptance notes

  • Memory cell. The refusal is pinned by the engine suite's recording driver, because it answers before a driver is resolved. That follows the lane convention stated in data-no-operator-object-door.test.ts's header: neither objectql nor rest depends on driver-memory. The memory measurements in the table are from the scratch probe through engine.find on InMemoryDriver.
  • PostgreSQL / MySQL were not run (no live servers here). The refusal precedes the driver, so a dialect adds nothing to it.
  • A placeholder inside a nested-relation condition ({ 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.
  • Scope beyond the card's table. The time class 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.
  • Not addressed here, and reported for the seat to file (same family): a date macro whose offset lands past the instants a JavaScript Date holds. A day macro resolves to the text Invalid Date, so opened_at $lt {300000_years_ago} answers 200 with both rows on memory and SQLite through POST /api/v1/data/:object/query. A sub-day macro throws an uncoded RangeError inside the resolver, and the same door answers 500 INTERNAL_ERROR for {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 every CHANGELOG.md are untouched. The changeset is .changeset/20844-resolved-token-year-range.md (@objectstack/core minor, @objectstack/objectql minor, BREAKING). check:adr-0087-registration reads its disposition as not-required (no-migration-prescription). The 05a7547c9 precedent registered no ADR-0087 id, so already-registered has nothing to name.

Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/objectql, touching 34 documentable anchor(s).

6 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))
  • content/docs/protocol/objectql/query-syntax.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 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 — 34 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 8f784959cf7e597e4582a70b93633b10eddb2dcf → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 35366c09110603c52f2c764a7e59f7828f05d78b — the merge of head 4c5f259e5cb5455f712f792b06edb95058174cf9 into base 8f784959cf7e597e4582a70b93633b10eddb2dcf, 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 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

⚠️ 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 8f784959cf7e597e4582a70b93633b10eddb2dcf → 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: 4c5f259e5cb5455f712f792b06edb95058174cf9
Local-runs: none

① Derived judgments

Inputs: card #20844 (body; triage 5910693971, release 5913754836, claim 5923455068, os-dev-report 5924544832), PR #21065's body and file list, the net diff of claude/issue-20844-resolved-token-year-range against its merge base with main (576afc17b; 7 files, +912 / -52), and the 33 check-runs on this head, read twice during the review. Nothing was built, run or re-run.

Accept-set changes — all narrowings, all before any driver read, all INVALID_FILTER / 400, no new error code:

  1. where on find, findOne, count, aggregate, multi-row update and delete: a comparand written as a date macro (spec classifyFilterToken kind date-macro) whose resolved value core's isOutsideTemporalYearRange puts outside the declared column's years (date 0001..9999, datetime 1000..9999) is refused. RIGHT — triage's chosen arm (resolution refuses, not a second pass of the door), the one range function (the door imports core's; SUPPORTED_TEMPORAL_YEARS is re-declared nowhere in the diff), the error code reused.
  2. The per-aggregation filter (refusal rooted at aggregations[i].filter) and having (by aggregatedRowColumnClasses, the same reader the having temporal door takes on the same verb; text operators stepped over as that door does). RIGHT — H4 was measured, and the same stage function resolveThenLowerWhere serves execution and the judge, so they cannot drift.
  3. judgeFilter answers ok: false with execution's code, status and message. RIGHT — judgeWhereAdmission calls the same resolveWhereFilterTokens with the same whereResolvedTokenJudge; pinned.
  4. A time column: a date macro resolved to an instant whose UTC year has no four-digit spelling is refused in the door's existing [#20480] class words. A resolved value inside the four-digit years on a time column is NOT newly judged — the judge asks the year alone (isUninterpretableTemporalComparand('time', v) AND isInstantOutsideFourDigitYears(v)), so {now} and {today} on a time column answer as before. RIGHT as a bounded in-place fix; the four conditions are checked in ③ (c).
  5. The datetime floor of 1000 applies to a resolved placeholder: {1977_years_ago} resolves to year 49, refused on a datetime, reaching the driver on a date. RIGHT — triage's third pin; the kind-named words of f6ccca4a4 are kept (yearClassOutside is the old yearClassOf body factored out, not re-derived).
  6. In-range placeholders answer as before on every position; a placeholder on a non-temporal column and a context placeholder ({current_user_id}) are not judged. RIGHT — the judge returns null unless classifyFilterToken(written) is date-macro, and kindOf is null for a non-temporal or undeclared key.

Public-surface changes:

  1. @objectstack/core resolveFilterToken / resolveFilterTokens (published through export * from './utils/filter-tokens.js'): a day-or-coarser macro landing outside 0001..9999 now resolves to ECMAScript's expanded-year form (+010026-10-01, -000001-10-01, 0000-10-01 for year 0) instead of the storage rule's unpadded text (10026-10-01, -1-10-01, 0-10-01). A day inside 0001..9999, a sub-day macro's instant (toISOString already) and an invalid Date are spelled as before — the last one checked against core: isOutsideTemporalYearRange answers false for an Invalid Date (instantMs yields undefined), so asYmd keeps the old branch and the {300000_years_ago} family is neither fixed nor worsened by this diff. RIGHT — the old text was read by no reader as the day it names (Date.parse takes -1-10-01 through the host's legacy parser as 2001-01-10, so core's own range judged {2027_years_ago} inside the range for both kinds), fixing the producer is the contract-first route, and a consumer-side parse of the unpadded text would be the lenient fallback AGENTS.md rules out. Signatures unchanged; the core pin covers both sides of the range, year 0, the edges and a sub-day instant on UTC and Asia/Shanghai hosts.
  2. The claim's line "filter-tokens.ts stays field-agnostic": HOLDS. The module gains no field map, no kind branch and no refusal. Its one kind literal, isOutsideTemporalYearRange(d, 'date') in asYmd, asks whether the day has a YYYY spelling — SUPPORTED_TEMPORAL_YEARS.date (0001..9999) is also the bound canonicalCalendarDay pads from inside temporal-storage-form.ts, so this is the storage rule's own coupling, not a per-field decision. Which years a FIELD takes is asked only in the engine, by the column's declared kind. The claim's stop condition (the per-kind check cannot live in the engine) did not fire: it lives there.
  3. @objectstack/objectql gains four module-level exports in temporal-comparand-door.ts (ResolvedTokenOutsideYears, ResolvedTokenJudge, assertResolvedTemporalTokensInRange, assertHavingResolvedTemporalTokensInRange). packages/objectql/src/index.ts does not re-export that module, so the package publishes no new symbol; a widening tell (T3) is a row in a published entry point's listing, and there is none. Clause-②: no stands. RIGHT.
  4. The door's refactor (WalkScope to PositionScope plus a generic twin walk; judgeAsWritten handing the tree as its own twin; instantMsOf; yearClassOutside): the literal doors' verdicts and words are unchanged by construction. The twin walk is sound because resolveFilterTokens is a one-for-one structural copy (replaces placeholder strings, maps arrays, copies object keys, returns a Date by reference), so every path in written exists in resolved. RIGHT.
  5. resolveWhereFilterTokens takes a required judge, and every call site passes one: judgeWhereAdmission, resolveRelateThenLowerWhere, and through resolveThenLowerWhere both the having branch of resolveWhereTokens and the per-aggregation filter map. No position resolves unjudged, and the compiler holds that (the four Type Check jobs are green on this head). RIGHT.
  6. REST POST /api/v1/data/:object/query and every door that reads through the engine: 200 becomes 400 for those placeholders, and the message names the field, the placeholder as written, its position, the resolved value and its year, the kind's years and a remedy. RIGHT. @objectstack/rest publishes nothing here (one test file) and is correctly not bumped; content/docs/releases/ and every CHANGELOG.md are untouched, as the guardrail requires.

Wording note, not a defect: the changeset's sentence "Measured before this on InMemoryDriver and SqlDriver on SQLite" also covers the time bullet, which the PR body says was measured on the branch before the time commit and read from the code for the base. The base behaviour it states follows from the code (the unpadded text compares as text above every HH:MM:SS too).

② Semver level

The changeset is .changeset/20844-resolved-token-year-range.md: @objectstack/core minor, @objectstack/objectql minor, BREAKING, Clause-②: no (narrowing), one adr-0087 marker reading not-required (no-migration-prescription). The PR title is fix(core,objectql)!:; the PR body's second line and the claim (5923455068) carry the same Clause-②: no (narrowing).

  • What the diff publishes: from @objectstack/objectql, an accept-set narrowing (① 1–6, 12) with no new export; from @objectstack/core, a changed answer of a published function for one class of inputs (① 7) with unchanged signatures and no new export. Nothing from rest or spec.
  • Level: (narrowing) is BREAKING (AGENTS.md, Post-Task Checklist 3), and BREAKING ships as minor under the launch-window guard (check-changeset-no-major.mjs forbids major) — the same package pair and level the serial predecessor 05a7547c9 (driver-sql on MySQL reads a year 0..99 back a century late — REST create stores placed_on: "0009-03-04" correctly, and …/query returns "1909-03-04"; a datetime 0009-03-04T10:00Z returns 2004-09-03T10:00Z #20280) used. Core at minor is right: patch would under-declare a public answer change, and the changeset's "The resolver's spelling" paragraph tells a consumer exactly which inputs change. Not skip-changeset: both packages publish. RIGHT.
  • Clause-②: line: no (narrowing) is right — the diff adds no key, export or registration (① 9) and narrows what the engine answers. Consistent across claim, PR body and changeset.
  • adr-0087 disposition: RIGHT. Nothing an author can write is removed or renamed (the date-macro vocabulary is unchanged), so no migration prescription is owed and the body carries none (the gate refuses that arm when one is present); no registered id covers a value range, so already-registered has nothing to name, consistent with the predecessor registering none.
  • Gate verdicts on this head: Check Changeset (empty-changeset, adr-0087 registration, no-major) is success. Lint & Repo Gates is in_progress and is not a verdict.

③ Boundary flags

open_questions: none in the report. The report's six deviations, its one out-of-scope finding, and the two couplings this review was asked to name:

(a) packages/core/src/utils/filter-tokens.ts edited (the H2 route changed). ANSWERED: inside the claim's file surface, which listed the file as "stays field-agnostic", not read-only; field-agnostic holds (① 8); the stop-and-report trigger did not fire. No breach.

(b) engine.ts aggregate region touched in two places. ANSWERED: both are the resolve-then-lower stage's own sites — the per-aggregation filter call now passes its judge and the having resolution comment is corrected — at branch lines 17204–17245; the aggregate door block (branch 16927–16933) is untouched. Coupling with PR #21037 (#20914, in the merge queue): both PRs start from the same engine.ts blob 743ff917c; #21037's hunks are the import at base line 212 and the door call at 16880, this PR's sit at 49, 1158, 1194, 1269, 1307, 11253, 11284, 17167 and 17182 — textually disjoint (the nearest pairs about 150 and 270 lines apart), so whichever lands second merges clean. Semantically: no shared symbol (#21037 renames the count-distinct door to aggregate-field-type-door.ts and edits spec's compatibility table; this PR touches temporal-comparand-door.ts and core's resolver); inside aggregate the field-type door runs before the resolution stage, so a query carrying both faults gets INVALID_FIELD first, and neither PR pins a combined case; both changesets bump @objectstack/objectql minor with no (narrowing) and accumulate. No other open PR may claim the same single-writer path is green on this head.

(c) The time column, beyond the card's table. ANSWERED, accepted under the bounded in-place exemption, all four conditions holding: same defect class (a resolved placeholder bypassing the door's year class); mechanical, with the shape already pinned (the [#20480] class words exist for a literal and are reused as they stand); no other claim holds temporal-comparand-door.ts (the claim's serial read); same gate family with no new verification surface (pinned inside the same objectql suite). The two items such a fix owes: the file was already in the claim's surface, and the PR body names the fix with its measurement.

(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 (data-no-operator-object-door.test.ts header: InMemoryDriver's row is @objectstack/objectql's; neither objectql nor rest lists driver-memory in its manifest); the refusal precedes driver resolution, so the recording driver pins it for every driver; the in-range control is pinned on SQLite (rest) and as the day that reaches the driver (objectql), and driver-memory's own memory-20264-temporal-year-range.test.ts holds the other half (a day inside the range is compared as the day it names). Triage's "memory and SQLite" pins are met by that composition.

(e) Write-budget handling (the until-loops, then one write). Process, not contract; noted. mcp_calls: 0; three REST writes, all through the relay.

(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) out_of_scope_findings[0] — a date macro whose offset lands past the instants a Date holds ({300000_years_ago} answers 200 with every row; {99999999999999999999_minutes_ago} answers 500). ESCALATED to the seat, to file or to fold into this family's card: class (a) with a measured REST reach, a different mechanism (Invalid Date text and an uncoded RangeError), and no pinned refusal shape. This PR neither fixes nor worsens it (① 7). Dedupe words are in the report.

(h) Check-runs on the head: 26 success, 3 skipped by their own path or opt-in filters (Console Pin Gate, Build Docs, Packed-tarball smoke), 4 in_progress — Test Core (1/6), Test Core (3/6), Test Core (5/6), Lint & Repo Gates. Those four are not verdicts: the pins' CI confirmation sits in the Test Core shards, and the dev's local counts are the dev's claims. Temporal Conformance (live PG + MySQL), Build Core, the four Type Check jobs, Check Changeset, Governed Surface Queue Guard and the claim and single-writer guards are success. Landing waits for every check green by the queue's own rule; this record's verdict is on ①–③.

Implemented-by: claude/issue-20844-resolved-token-year-range
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants