feat(spec,objectql,plugin-security): one shared filter lowering, run once at the engine and RLS seams (#5930 step 2) - #20794
Conversation
…ded) Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…y filter position Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…, typed by the declared datetime columns Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…d driver input Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
…ng export Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 141 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 4f8720eea10306a776162b9fc1c68eaca689729c && git checkout 4f8720eea10306a776162b9fc1c68eaca689729c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8acdae9d8f0fc71cabe1671e7ec622213cc4f546 36e5ce8828a20a32ba415108d429c6f2141ca738 && git checkout -B drift-repro 8acdae9d8f0fc71cabe1671e7ec622213cc4f546 && git merge --no-ff 36e5ce8828a20a32ba415108d429c6f2141ca738
node scripts/docs-audit/affected-docs.mjs --json 8acdae9d8f0fc71cabe1671e7ec622213cc4f546
|
Contract reviewServed-tier: PR #20794 ( ① Derived judgmentsConformance to the amended D-D1, item by item (1–7, 9):
Published surfaces the diff implies:
Review faces, sentence by sentence: the changeset, the module TSDoc, the anchor and the PR body are true against the code as read — the rule list; the copy-on-write / idempotent / never-refuses / provenance / closed-vocabulary contract ( Check-runs on the head, as read (not waited for): success — Auto Label, filter, Check Changeset, Check PR Size, Governed Surface Queue Guard, the three card / branch / single-writer guards, Spec property liveness, Flag docs affected by code changes, Check Documentation Links, Type Check · source gates, Type Check · debt ledger, Dogfood Regression Gate (1/3), and the Vercel status; skipped (rostered) — Console Pin Gate, Build Docs, Packed-tarball smoke; in_progress — Build Core, Test Core (1/6 through 6/6), Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (2/3 and 3/3), Dogfood Verify CLI, Lint & Repo Gates, Type Check · consumer gates, Type Check · workspace. None failing. Their conclusions are the gate verdicts; the seat owns convergence. ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL One item: ② Generated by Claude Code |
…Guard.datetime widens a published accept set Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review of PR #20794 ( ① Derived judgmentsThe FAIL item is fixed as prescribed — right. The delta is one commit, No sentence of the changeset needed to change with the level — right. Read in full at the head: no sentence names a level. The two sentences the widening touches already name it — " The addendum agrees with the diff. 5905899813 reports Check-runs on the head, as they stood when read (this record posted 2026-09-30T07:04Z; not waited for): success — Auto Label, filter, Check Changeset, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Governed Surface Queue Guard, Spec property liveness, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch (12); skipped, rostered — Build Docs, Console Pin Gate, Packed-tarball smoke (3); in_progress — Build Core, Test Core (1/6 through 6/6), Temporal Conformance (live PG + MySQL), Dogfood Regression Gate (1/3 through 3/3), Dogfood Verify CLI, Lint & Repo Gates, Type Check · source gates, Type Check · debt ledger, Type Check · consumer gates, Type Check · workspace (17); the Vercel status pending. 32 check-runs, none failing. Their conclusions are the gate verdicts; the seat owns convergence. Three that were ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS The one FAIL item of 5905611995 is fixed as prescribed and nothing else moved; every other judgment of that record carries over to this head unchanged. Generated by Claude Code |
Part of #5930 — step 2 of ruling 5902355785 (the seam lowering in the engine / RLS seams). Steps 3 (the analytics-face seams and the F5 / F11 vocabulary) and 4 (the per-face deletions) remain, so the card stays open.
Clause-②: yes
What this does
This implements ADR-0053 D-D1 as amended on 2026-09-30, items 1–7 and 9. The bare-day upper bound, the
$betweensplit and the NULL-polarity guards are applied once, by one sharedFilterCondition → FilterConditionlowering. It runs at the engine and RLS seams, after the comparand doors and after filter-token resolution. Drivers receive the lowered filter.@objectstack/spec/data: new pure modulefilter-lowering.ts, exportinglowerFilterCondition(filter, options?)andFilterLoweringOptions. It is exported from the./datasubpath only, never the root entry (the ruling's D3). The rules:$betweenbecomes$gtemin and$ltemax. A range holding a{ $field }or a non-pair is left whole, for the face that refuses it.$lteon a bareYYYY-MM-DDbecomes$ltnextUtcCalendarDay(day), in the calendar-string domain, never a storage form (D-A1).UNBOUNDED_ABOVEturns a lone$lteinto{ $null: false }, and a$betweenkeeps its minimum. Instants andDates are never widened.$neof a value,$ninand$notContainsget{ $or: [{ f: { $null: true } }, { f: op }] }(非否定路径上的$ne/$nin/$notContains:driver-sql 排除 NULL 行,driver-memory / formula 返回它们(#5146 只裁定了$not) #5298). Every leaf of a$notoperand is made total ($not的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {}一个是 TRUE 一个是 FALSE #5146).options.isDatetimeColumnscopes rules 1–2 on a typed seam (item 7).@objectstack/objectqlengine.ts: one stage function,resolveThenLowerWhere(resolve, then lower), is the only way any filter position resolves. That coversfind,findOneandcount(resolveWhereTokens);updateanddelete(withResolvedWhere); andaggregate'swhere, eachaggregations[i].filterandhaving.resolveWhereTokensandwithResolvedWherenow require the lowering options, so no verb can resolve without lowering. The judge (judgeWhereAdmission) runs the same stage. The type reader forwhereandaggregations[i].filteris the object's declaredtype === 'datetime', the same testSqlDriverindexesdatetimeFieldsby. Forhavingit is the aggregated row's column types (aggregatedRowColumnTypes, wheremax(datetime)isdatetime).@objectstack/plugin-security:judgeCompiledComparands(the RLS compile seam, servingusingandcheck) lowers every compiled policy filter right after the two faces.RlsFieldGuardgains an optionaldatetimeset.SecurityPluginfills it from the same declaration pass as the field-name set (loadObjectFieldNames) and hands it in at both compile sites. A guard without types reads no column asdatetime.scripts/adr-anchors/packages__spec__src__data__filter-lowering.ts.json: pins ADR-0053 to the module (Prime Directive [WIP] Add Chinese version of the documentation #13)..changeset/5930-shared-filter-lowering.md:@objectstack/specminor,@objectstack/objectqland@objectstack/plugin-securitypatch. It cites ADR-0053 D-D1 (amended).No face copy is deleted, no driver file is touched, and the analytics
where/ preview door, the read scope and the memory cube face are untouched (step 3).The stop line was reached: one evaluator's answers move, on rows with no value
The acceptance is answer invariance. Measured, the answers of every driver face stay the same. The engine's own in-process evaluator for
aggregations[i].filterandhaving(F8,having-filter.ts) moves, and only on rows or groups with no value. Before this change F8 was the only face that disagreed with the others on those rows. After it, all faces agree.A/B probe, not committed. Each face answers the filter as written and the lowered filter, on sqlite
SqlDriver(F1),InMemoryDriver(F3),matchesFilterCondition(F7) andmatchesAggregationFilter(F8):FILTER_LOGIC_CASESoverFILTER_LOGIC_ROWSTEMPORAL_CASES(plus the resolvedtokenFilters) overTEMPORAL_ROWS$not, plus$between/$ne/$nin/$notContainsprobesAll 14 moved cells are a
$betweenon adatetimecolumn with a row whose value is null. F8 kept that row in the range (7 cells) and dropped it under$not(7 cells). F1, F3 and F7 exclude it from the range and keep it under$not, which is the #5146 / #5298 reading. A second probe found the same class on a number column:{ $not: { amount: { $lt: 5 } } }dropped a nullamountin F8, because JS comparesnull < 5as true. Everywhere else the null row is kept. That probe covered the per-aggregation filter andhaving, one cell each.The decision on whether to keep the two aggregate seams wired is in the report on #5930, with the four-axis frame. This PR carries option A (keep them). Dropping them (option B) removes the two
aggregatehunks and their two pin rows.Mechanism hypotheses — which held
lowerWhereFilterArrayand tokens resolve after it on every verb. One helper (resolveThenLowerWhere) holds "resolve, then lower", and every verb has its own pin.record.signed_on <= '{today}'compiles to{ signed_on: { $lte: '{today}' } }, passes both faces, and reachesusing's drivers andcheck'smatchesFilterConditionverbatim. Nothing resolves a placeholder on either RLS clause. The RLS lowering therefore runs after the faces (item 3) and reads'{today}'as a non-day string it leaves as written, the same as every face does today. This is pinned.RlsFieldGuardcarried names only, but the types are in the same declarationloadObjectFieldNamesreads. (b) No in-repo or example policy compares any column against a bare day or a date token: 72 non-testusing/checkpredicate lines, all==,in,== null,!= nullor1 == 1. So no real policy's rows or admitted writes change under either reading. The seam takes the typed reading, because the type-blind one would moveusinganswers on SQL for a non-datetimecolumn (a text column holding day-prefixed strings, and$lte '9999-12-31'on text) in constructible policies.mainat085ca6bc1chasTest Core(6/6),Temporal Conformance (live PG + MySQL)andDogfood Regression Gategreen.$and,$or,$lt,$gte,$lteand$null. The unit table pins that closure overFILTER_LOGIC_CASES,TEMPORAL_CASESand every row. F1, F2, F3, F6, F7 and F8 already compile those.$nottotaliser are the SQL copies' tables cell for cell. The copies stay.Evidence (all on head
9ca3698b67)packages/spec/src/data/filter-lowering.test.ts: 46 tests. The rule table, the item-7 scope, idempotence over the table and both case sets, vocabulary closure, copy-on-write, provenance, and pass-through.packages/objectql/src/engine-shared-filter-lowering-seam.test.ts: 11 tests, one per verb and position, plus{today}resolved-then-widened, the typed scope, the last supported day, copy-on-write and the judge.packages/plugins/plugin-security/src/rls-shared-lowering-seam.test.ts: 14 tests. Both clauses throughRLSCompiler, plusSecurityPlugin.getReadFilterandcomputeWriteCheckFilterfed the declareddatetimeset.rls-compiled-comparand-facesgets the same treatment.rls-empty-membership-polarity'snot inshape is updated, and its admitted-row count stays 3.typecheckis green for spec, objectql, plugin-security, the five drivers and formula.check:driver-conformanceis OK (50 cells).scripts/ablation-replace.mjsand restored (blob equals HEAD,git diff HEADempty):resolveThenLowerWherewithout the lowering: 8 of 11 seam pins red, across every verb and all threeaggregatepositions.judgeCompiledComparandswithout the lowering: 8 of 14 red.SecurityPluginwithout thedatetimehand-off: the 2 plugin-level pins red.node scripts/pm/dispatch-gates.mjs --commandsderived 103 families; all 103 were run with exit 0, and--ranreports 0 NOT-MEASURED and 0 UNRUN. Three gates (check:dual-build-cjs-loads,check:i18n,check:type-check-debt) first refused withPREREQUISITE NOT MET. They were re-run green afterturbo run build --filter='./packages/*' --filter='./packages/*/*'.check:generatedshows 15 of 15 up to date after regeneratingapi-surface/andexport-origins/.eslint.config.mjsblockfiles: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'].eslint --no-inline-config --format jsonover the 13 changed source files reports 13 files, 0 errors and 0 warnings.parserOptionsisecmaVersion/sourceTypeonly), so this diff cannot move a verdict on an untouched file.Acceptance notes
datetimewhen the object's declaration is missing (a registry-less host, or a guard without types). They do not apply the rule type-blind. This keeps every driver's answer where it was (SqlDriveralso widens nothing without a declaration). The step-4 deletion cards should re-read this: once a face's copy is gone, a declaration-less path gets no whole-day bound.security-plugin.tschanges in two places: thedatetimeset is read in the existing declaration pass, and it is handed in at the two compile sites. The ADR anchor is one new JSON file. Neither adds a seam.checkwith a date token.os validaterefuses a{placeholder}in a read-scopeusingclause (validate-rls-predicate-enforceability.tsjudges it through the engine). Acheckclause is not judged there, and nothing resolves the token at run time. Acheckofrecord.signed_on <= '{today}'therefore compares against the literal text, and every ISO value sorts below{. Public-door reach is not measured. Carrier: none.usingthroughgetReadFilter, so from this PR on it receives the RLS seam's lowered policy. For a policy with a bare-day$lteon adatetimecolumn (none in-repo), it now keeps the whole day, which is [finding] service-analytics read scope: compileScopedFilterToSql applies no whole-day upper bound and binds a temporal comparand as written, so an RLS $lte on a bare day drops the rest of that day in NativeSQL analytics #20733's direction. [finding] service-analytics read scope: compileScopedFilterToSql applies no whole-day upper bound and binds a temporal comparand as written, so an RLS $lte on a bare day drops the rest of that day in NativeSQL analytics #20733 itself (the scope's own bound on a caller's filter) is step 3 and is not addressed here.engine.tsandfilter.zod.tsprose, and whichever lands second mergesmain.Generated by Claude Code