Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): refuse an uninterpretable temporal filter comparand at the engine door (#8690) - #8808
Conversation
… the engine door (#8690) A bare string a temporal field cannot interpret — `last_30_days`, `not-a-date-at-all` — was bound as written, compared false for every row, and answered 200 with an empty result and no diagnostic, while an unknown `{placeholder}` was refused loudly one branch over. Refuse it at the ObjectQL engine's single filter collection point, per the maintainer ruling of 2026-08-15 (option B): `lowerWhereFilterArray` is the one seam holding the caller's comparand and the field's declared type at the same moment, on every verb and through both doors. - `@objectstack/core`: the value-half predicate, shared so the rule cannot exist twice; interpretability is defined by the drivers' own totals. - `@objectstack/objectql`: the door, `INVALID_FILTER` / 400. - `@objectstack/service-analytics`: `NativeSQLStrategy.canHandle` declines an uninterpretable temporal comparand so raw-SQL paths fall through to the door instead of binding it directly. Scoped to non-empty strings by ruling: the empty-string cell stays its own card, and `{placeholder}` strings keep their existing loud refusal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…poral-comparand-refusal
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 29 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also reference the affected code. These are read-only:
|
…in the temporal-door pin (#8690) `reads.at(-1)` is TS2550 under this package's lib target, and objectql's tsconfig hides `**/*.test.ts` from its own `typecheck` script — so the error was invisible to `tsc --noEmit` and surfaced only in the TEST_DEBT re-measure, pushing the shrink-only ledger 355 -> 356. Indexed access instead. Same assertion, same read, ledger back at 355. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…poral-comparand-refusal
Uh oh!
There was an error while loading. Please reload this page.
…he +2 TEST_DEBT drift (#8793) Same class as #8808's TS2550: invisible to 'pnpm --filter @objectstack/lint typecheck' because tsconfig excludes **/*.test.ts; only the TEST_DEBT re-measure compiles the file. The two TS7006s were downstream of the one unresolved import (TS2835). Ledger not raised; re-measured 19 vs recorded 20 on the fully built workspace closure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt
Part of #8690 — the B half only. Merging this does not close the card: the C half (refuse the declared preset vocabulary at publish time,
packages/spec+@objectstack/lint,claude-fable-5tier by the ruling's own last line) is carved out of this dispatch and tracked on #8793. #8690 remains open after this lands.Implements the maintainer ruling of 2026-08-15 (delegated adjudication), option B, explicitly not A.
The defect
A
datetimefield filtered with a bare string the API cannot take literally was bound as written all the way to the driver, where the comparison is false for every row —HTTP 200, empty result set, no diagnostic. An unknown{placeholder}in the same position was already refused loudly, so one API answered two shapes of unusable comparand two different ways.Reachable rather than theoretical:
last_7_days/last_30_days/last_90_daysare declared preset names in the dashboard schema. The console lowers them to{N_days_ago}macros, so the console path was always safe — a saved report, an integration, an MCP client or an AI-authored query sends the preset name itself.Where the refusal lands, and why
lowerWhereFilterArrayis the engine's single filter collection point and the one seam holding the caller's comparand and the field's declared type at the same moment. It is reached by every verb (find/findOne/count/aggregate/update/delete) through both spellings (the array sugar and the already-lowered condition the protocol face hands over), so all four backends inherit one answer.The two seams triage originally named were measured and cannot host it:
packages/core's token resolver is field-agnostic by construction, andpackages/restbinds no comparands at all. The driver layer holds both facts but is four frozen packages whose pass-through is a deliberate, counter-pinned contract shared with the write path — rejected by name.PM mechanism assumption: confirmed by measurement.
lowerWhereFilterArray(object, operation, bag, schema)is already handedthis._registry.getObject(object)at all six call sites, and its existing neighbourassertFilterIsMaterializablealready readsschema.fields[name].type. Nothing new had to be threaded.@objectstack/coreisUninterpretableTemporalComparand(kind, value)— the VALUE half, shared so the rule cannot exist twice in two packages that do not depend on each other. Interpretability is defined by the drivers' own total functions, so the door refuses exactly what a driver would hand back unchanged.@objectstack/objectqlINVALID_FILTER/ 400, naming field, declared kind, value, key path and the spellings that work.@objectstack/service-analyticsNativeSQLStrategy.canHandledeclines an uninterpretable temporal comparand so raw-SQL paths fall through to the door instead of binding it into their own statement.Deviation to flag, deliberately not silent
The ruling says the analytics decline should arrive "via a new
StrategyContexthook". Measured,StrategyContextis declared inpackages/spec(contracts/analytics-service.ts) — which this dispatch forbids, and which is where the carved-out C half lands. Rather than edit spec or declare an undeclared hook on a shared contract, the decline classifies on metadataStrategyContextalready carries: a cube dimension declarestype: 'time', resolved through the samelookupMemberevery other member lookup in the strategy uses. Same shape as the 2026-08-12 Q1=B ruling one seam over, which rejected newStrategyContexthooks for exactly this decision. Cost, recorded in the code: a temporal column filtered without being a declared time dimension is not classified, so it keeps today's behaviour on the raw-SQL path — strictly smaller than "every raw-SQL query bypasses the door", and it fails in the safe direction, since a missed decline degrades to today's behaviour rather than a new wrong answer. Reviewed and accepted; the residual precision is carried onto #8793, which is already openingpackages/spec.Scope boundaries, each by ruling
''and returns 51 of 51 — the card table's38is a transcription error its own prose corrects).{placeholder}strings are stepped around. The door runs before token resolution because the refusal must precede the driver, so judging one would refuse{30_days_ago}. Unknown tokens keepFILTER_TOKEN_UNKNOWN/ 400.Dateis an instant.packages/driverschange. ⛔ Nopackages/restconsumer-side patch. ⛔ Nopackages/specedit.Verification
Union re-run after the final commit, at
fd917ec42(mainmerged in), clean tree.@objectstack/objectql— 209 files, 3664 tests passed;typecheck(tsc --noEmit) clean.@objectstack/core— 34 files, 831 passed.@objectstack/service-analytics— 77 files, 1722 passed (includes [finding] service-analytics carries its own copies of the comparand-type allow-list the #7872 door now single-sources — reconcile membership and message wording to the door #8186'scomparand-door-single-sourcesuite, which arrived onmainand touches the same file this PR extends).code === 'INVALID_FILTER'andstatus === 400with zero driver reads, and the sameit()asserts{30_days_ago}still returns 38 rows on the card's 51-row / 38-in-window dataset shape, with the resolved floor read back off the driver AST.executeRawSql, and an over-decline control proves2026-07-15, an ISO instant and{30_days_ago}all keep the P1 fast path.expected null not to be null, i.e. the silent zero returned), while the three controls (token resolver, scoped-out cells, registry-less no-verdict) stayed green because none of them is the door's doing. Restored withgit checkout HEAD -- packages/objectql/src/engine.tsfrom the commit that already carried the fix.check:nul-bytes,check:error-code-casing,check:durability-log-level,check:kernel-hook-pairs,check:stack-collection-maps,check:test-source-alias,check:type-source-resolution,check:query-options-erasure,check:type-check-coverage,check:changeset-gate-self-tests,check:objectui-changeset, pluscheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-engine-split-ratio— all PASS.check:type-check-debt— the red this PR was kicked for, and the fixThe first push went red on
@objectstack/objectql's TEST_DEBT: recorded 355, measured 356. The+1wasreads.at(-1)in the new pin — TS2550, because this package'slibtarget predatesArray.prototype.at. It was invisible topnpm --filter @objectstack/objectql typecheckbecause objectql'stsconfig.jsonexcludes**/*.test.ts, sotsc --noEmitnever compiled the file; only the TEST_DEBT re-measure, which drops that exclusion, sees it.Fixed at the source — indexed access, same assertion, no
@ts-expect-error, no skipped case, ledger not raised.All three implicated entries re-measured at
fd917ec42, each with the same project shape the gate constructs (fidelity confirmed: this replication reproduced CI's 356 exactly before the fix):@objectstack/objectql(TEST_DEBT)@objectstack/core(DEBT)@objectstack/service-analytics(DEBT)Both new test files were confirmed inside the programs those numbers are measured from (
tsc --listFiles), so the zeros are real coverage rather than a file nothing read. The full--re-measuresweep runs every ledger entry sequentially and exceeds one call window locally; CI runs it whole.Generated by Claude Code