Skip to content

feat(spec,objectql,plugin-security): one shared filter lowering, run once at the engine and RLS seams (#5930 step 2) - #20794

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-5930-step2-seam-lowering
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-5930-step2-seam-lowering

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 $between split and the NULL-polarity guards are applied once, by one shared FilterCondition → FilterCondition lowering. 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 module filter-lowering.ts, exporting lowerFilterCondition(filter, options?) and FilterLoweringOptions. It is exported from the ./data subpath only, never the root entry (the ruling's D3). The rules:
    1. $between becomes $gte min and $lte max. A range holding a { $field } or a non-pair is left whole, for the face that refuses it.
    2. A $lte on a bare YYYY-MM-DD becomes $lt nextUtcCalendarDay(day), in the calendar-string domain, never a storage form (D-A1). UNBOUNDED_ABOVE turns a lone $lte into { $null: false }, and a $between keeps its minimum. Instants and Dates are never widened.
    3. NULL polarity, cell for cell from the four hand copies' tables. $ne of a value, $nin and $notContains get { $or: [{ f: { $null: true } }, { f: op }] } (非否定路径上的 $ne / $nin / $notContains:driver-sql 排除 NULL 行,driver-memory / formula 返回它们(#5146 只裁定了 $not) #5298). Every leaf of a $not operand is made total ($not 的语义在 driver-sql 与 driver-memory / formula 之间分叉:NULL 行的去留相反,$not: {} 一个是 TRUE 一个是 FALSE #5146).
  • @objectstack/objectql engine.ts: one stage function, resolveThenLowerWhere (resolve, then lower), is the only way any filter position resolves. That covers find, findOne and count (resolveWhereTokens); update and delete (withResolvedWhere); and aggregate's where, each aggregations[i].filter and having. resolveWhereTokens and withResolvedWhere now require the lowering options, so no verb can resolve without lowering. The judge (judgeWhereAdmission) runs the same stage. The type reader for where and aggregations[i].filter is the object's declared type === 'datetime', the same test SqlDriver indexes datetimeFields by. For having it is the aggregated row's column types (aggregatedRowColumnTypes, where max(datetime) is datetime).
  • @objectstack/plugin-security: judgeCompiledComparands (the RLS compile seam, serving using and check) lowers every compiled policy filter right after the two faces. RlsFieldGuard gains an optional datetime set. SecurityPlugin fills 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 as datetime.
  • 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 .changeset/5930-shared-filter-lowering.md: @objectstack/spec minor, @objectstack/objectql and @objectstack/plugin-security patch. 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].filter and having (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) and matchesAggregationFilter (F8):

Case set Filters Cells Moved Filters where the faces disagree, before → after
FILTER_LOGIC_CASES over FILTER_LOGIC_ROWS 36 144 0 0 → 0
TEMPORAL_CASES (plus the resolved tokenFilters) over TEMPORAL_ROWS 32 128 0 0 → 0
the same plus a row with no value, each filter also under $not, plus $between / $ne / $nin / $notContains probes 70 280 14, all F8 14 → 0

All 14 moved cells are a $between on a datetime column 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 null amount in F8, because JS compares null < 5 as true. Everywhere else the null row is kept. That probe covered the per-aggregation filter and having, 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 aggregate hunks and their two pin rows.

Mechanism hypotheses — which held

  • H1 held: the doors run in lowerWhereFilterArray and tokens resolve after it on every verb. One helper (resolveThenLowerWhere) holds "resolve, then lower", and every verb has its own pin.
  • H2 — measured: yes, a compiled policy CAN carry an unresolved date token. record.signed_on <= '{today}' compiles to { signed_on: { $lte: '{today}' } }, passes both faces, and reaches using's drivers and check's matchesFilterCondition verbatim. 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.
  • H3 — measured. (a) The RLS seam could not read declared types: RlsFieldGuard carried names only, but the types are in the same declaration loadObjectFieldNames reads. (b) No in-repo or example policy compares any column against a bare day or a date token: 72 non-test using/check predicate lines, all ==, in, == null, != null or 1 == 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 move using answers on SQL for a non-datetime column (a text column holding day-prefixed strings, and $lte '9999-12-31' on text) in constructible policies.
  • H4 held on F1/F3/F7; F8 is the stop-line item above. Face suites are green after the change. Before: main at 085ca6bc1c has Test Core (6/6), Temporal Conformance (live PG + MySQL) and Dogfood Regression Gate green.
  • H5 held: the output introduces only $and, $or, $lt, $gte, $lte and $null. The unit table pins that closure over FILTER_LOGIC_CASES, TEMPORAL_CASES and every row. F1, F2, F3, F6, F7 and F8 already compile those.
  • H6 held: the three polarity functions and the $not totaliser are the SQL copies' tables cell for cell. The copies stay.

Evidence (all on head 9ca3698b67)

  • New pins:
    • 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 through RLSCompiler, plus SecurityPlugin.getReadFilter and computeWriteCheckFilter fed the declared datetime set.
  • Existing shape pins updated to expect the lowered driver input. No asserted row count changed. The door suites for number comparands, text operators and filter arrays now compare against the lowering of the door's output. rls-compiled-comparand-faces gets the same treatment. rls-empty-membership-polarity's not in shape is updated, and its admitted-row count stays 3.
  • Suites after the change:
    • spec: 578 files, 17065 passed.
    • objectql: 341 files, 6729 passed.
    • plugin-security: 148 files, 3216 passed.
    • driver-sql: 200 files passed and 11 skipped (live PG/MySQL cells).
    • driver-memory: 65 files, 1470 passed.
    • driver-turso: 80 files, 2195 passed.
    • driver-sqlite-wasm: 36 files, 675 passed.
    • driver-mongodb: 29 files passed; 5 skipped, because they need a real mongod.
    • formula: 42 files, 1240 passed.
    • typecheck is green for spec, objectql, plugin-security, the five drivers and formula. check:driver-conformance is OK (50 cells).
  • Ablations, each with the seam committed and then mutated on disk through scripts/ablation-replace.mjs and restored (blob equals HEAD, git diff HEAD empty):
    • engine resolveThenLowerWhere without the lowering: 8 of 11 seam pins red, across every verb and all three aggregate positions.
    • RLS judgeCompiledComparands without the lowering: 8 of 14 red.
    • SecurityPlugin without the datetime hand-off: the 2 plugin-level pins red.
    • The pins that stay green are the pass-through rows, which hold with or without the lowering.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands derived 103 families; all 103 were run with exit 0, and --ran reports 0 NOT-MEASURED and 0 UNRUN. Three gates (check:dual-build-cjs-loads, check:i18n, check:type-check-debt) first refused with PREREQUISITE NOT MET. They were re-run green after turbo run build --filter='./packages/*' --filter='./packages/*/*'. check:generated shows 15 of 15 up to date after regenerating api-surface/ and export-origins/.
  • Lint, as a narrowed run:
    • The population is the eslint.config.mjs block files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'].
    • eslint --no-inline-config --format json over the 13 changed source files reports 13 files, 0 errors and 0 warnings.
    • The config enables no type-aware linting (every parserOptions is ecmaVersion / sourceType only), so this diff cannot move a verdict on an untouched file.
  • NOT MEASURED locally: live PostgreSQL / MySQL (the CI job covers them), a real mongod, and Turso remote against a live server.
  • Changed lines: 1318 (+1289 / −29, 17 files).

Acceptance notes


Generated by Claude Code

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/plugin-security, @objectstack/spec, touching 43 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 8acdae9d8f0fc71cabe1671e7ec622213cc4f546.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 70 pages)
  • 5 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 — 141 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 8acdae9d8f0fc71cabe1671e7ec622213cc4f546 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4f8720eea10306a776162b9fc1c68eaca689729c — the merge of head 36e5ce8828a20a32ba415108d429c6f2141ca738 into base 8acdae9d8f0fc71cabe1671e7ec622213cc4f546, 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 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

⚠️ 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 8acdae9d8f0fc71cabe1671e7ec622213cc4f546 → 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: 9ca3698b6706acbc2f1cbc3f9a4fd485b54d9ed5
Local-runs: none

PR #20794 (Part of #5930, step 2 of ruling 5902355785), read against ADR-0053 D-D1 as amended on origin/main (the ten-item Amended block), the card's body and all 22 comments, the PR body, its 17-file list and the net diff against main (+1289 / −29) at the head above, and the head's check-runs as they stood when read. The head had not moved. Read-only: git reads of the diff and of origin/main, REST reads of the card, the PR and the check-runs; nothing built, run or re-run.

① Derived judgments

Conformance to the amended D-D1, item by item (1–7, 9):

  • Item 1 (the rules, one lowering) — right. packages/spec/src/data/filter-lowering.ts: a $between with two literal ends becomes $gte its minimum and $lte its maximum, and the same $lte arm then reads the maximum (lowerBounds); a bare-day $lte becomes $lt nextUtcCalendarDay(day); on UNBOUNDED_ABOVE a lone $lte keeps { $null: false } and a $between keeps its $gte alone; an instant, a Date, an impossible day and a { $field } end are left as written (nextUtcCalendarDay answers null; isLiteralRange refuses an object end); $gte / $gt / $lt keep their anchor. The three NULL-polarity tables (nullValueSatisfiesOperator, operatorIsNullTotal, nullGuardForFieldSpec) are cell for cell sql-driver.ts 5052–5170 on main, and the other three copies' nullValueSatisfiesOperator arms read identically. The output introduces only $and, $or, $lt, $gte, $lte, $null — pinned over the rule table, FILTER_LOGIC_CASES and TEMPORAL_CASES.
  • Item 2 (the seams this step owns) — right. The engine's where admission on every verb, aggregations[i].filter, having and the judge, all through one resolveThenLowerWhere; the RLS compile seam judgeCompiledComparands, serving using and check at its one call site. The analytics where / preview door, the read scope and the memory cube face are untouched (step 3), as the ruling orders.
  • Item 3 (after the doors AND after token resolution) — right, verified per verb on the head: the doors run in lowerWhereFilterArray at 11320 (find), 11618 (findOne), 13082 (update), 15732 (delete), 16250 (count), 16359 plus the having doors 16567–16600 (aggregate); resolve-then-lower runs at 11400, 11673, 13097, 15737, 16267 and 16631 / 16645 / 16661; the judge at 1267 then 1268. resolveWhereTokens and withResolvedWhere take the lowering options as a required argument, and the only resolveFilterTokens call left in engine.ts sits inside resolveThenLowerWhere, so no position resolves without lowering. Pinned: {today} resolves to the day and then widens, on find and on update. RLS: the lowering runs after both faces; for the seam's own consumers no token stage exists on either clause (the engine composes using inside the middleware, after its resolve stage; check goes straight to matchesFilterCondition), so "after resolution" holds by construction — with one carry, under H2 in ③.
  • Item 4 (@objectstack/spec/data, never the root entry) — right. data/index.ts re-exports the module; src/index.ts re-exports no data/index; api-surface/root.json and api-assembled.json carry neither symbol; api-surface/data.json and export-origins/data.json gain exactly the two, in sorted position. The module imports ./calendar-day and ./filter-subtree-provenance only — no dependency edge added.
  • Item 5 (drivers receive the lowered filter) — right. Recording-driver pins on all six verbs and a middleware witness on the three aggregate positions; the door suites now compare the driver's input against the lowering of the door's output; no driver file is touched.
  • Item 6 (no storage form; widen before conversion) — right. The rewrite emits nextUtcCalendarDay's YYYY-MM-DD string or { $null: false }, never a temporalStorageForm; it runs at the seam before any face converts, so D-E3's order is structural.
  • Item 7 (column-type scope) — right, with a reading. Engine: declaredDatetimeLowering reads fields[column].type === 'datetime', the test SqlDriver indexes datetimeFields by (11884 / 11975 on main); having reads aggregatedRowColumnTypes (max / min of a datetime field is datetime, a day bucket is date, count a number). RLS: RlsFieldGuard.datetime, filled in the same loadObjectFieldNames pass as declared and handed to both compileFilter sites — the only two in the package. The dev's reading that a typed seam whose declaration is missing at run time reads NO column as datetime (rather than going type-blind) is the reading that keeps the item's own headline true, "the column-type scope is unchanged": SqlDriver widens nothing without a declaration, so type-blind there would widen columns no driver widens and move SQL answers. The ADR's sentence "a seam that cannot … applies the rewrite type-blind" describes the structurally type-blind seams whose faces are type-blind today (step 3's); it does not license a typed seam to widen past its drivers. The module also scopes rule 1 (the $between split) together with rule 2 on a typed seam; that is likewise "the scope SqlDriver holds" (calendarDayBetweenRewrite is datetime-scoped) and answer-invariant. Both readings are right; the step-4 deletion cards need the first one in writing, which the PR's acceptance note carries.
  • Item 9 (interim idempotence) — right. lower(lower(x)) deep-equals lower(x) over the rule table and both case sets, typed and type-blind; an already-lowered filter comes back by reference (the escape and the requirement are recognised); every face's copy stays.

Published surfaces the diff implies:

  • @objectstack/spec/data: lowerFilterCondition (function) and FilterLoweringOptions (interface) added — right, and Clause-②: yes is the true declaration for it.
  • @objectstack/plugin-security: RlsFieldGuard IS a published type — not named on index.ts, but reachable from the entry type graph through RLSCompiler (index.ts line 12) and its public compileFilter(policies, executionContext?, clause?, fieldGuard?: RlsFieldGuard). The optional datetime?: ReadonlySet member is a widening of that accept set: every guard a caller passed before still compiles, and a new key is accepted. As a change it is right; its grade is judged in ②. compileFilter's output values change (lowered) with no type change, and its consumers already compile the closed vocabulary.
  • @objectstack/objectql: no public signature moves (resolveWhereTokens / withResolvedWhere are private; judgeFilter gains no verdict, pinned). What moves is the driver's input on every verb (shape only) and the in-process F8 evaluator's answers on rows with no value — judged in ②.
  • The filter accept set is unchanged at every seam: the lowering never refuses, and the doors still run first.

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 (carryProvenance marks every replaced node, and filterSubtreeProvenanceOf is own-key, so the pin in filter-lowering.test.ts is the right witness); the seam list; "both compile sites"; "no face copy is deleted, no driver file is touched"; the counts (17 files, +1289 / −29; 13 source files; 46 / 11 / 14 pins — the spec table is 36 rows plus 10); the rls-empty-membership-polarity admitted count staying 3. Anchor: filename derived from the path, the file / adrs / invariant shape, and the module cites ADR-0053. One count differs harmlessly: my own grep of using: / check: predicate lines under packages/, examples/ and apps/ (non-test) finds 88, not 72, and 0 of them compare a column with an ordering operator or a {token} — the claim that matters holds under the wider net.

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

  • Stop line (seat answered A) — answered, conforms: ADR item 2 names aggregations[i].filter and having among the seams; the moved F8 answers are the ruled NULL polarity; graded patch and stated in the changeset. Not escalated.
  • H2 (nothing resolves a placeholder on either RLS clause) — answered for this PR's consumers: right, and the RLS lowering's place after the faces is item 3's order. One carry: a third consumer of using DOES resolve tokens after the seam — the analytics read scope (read-scope-sql.ts line 1077 and analytics-service.ts line 1371 on main). A policy {today} bound therefore reaches F9 lowered-as-literal and is resolved to a bare day there; F9 widens nothing today ([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). Not this PR's seam: the step-3 card's lowering at compileScopedFilterToSql's entry must sit after that resolver (item 3) — carry to step 3. The check-clause literal compare against '{today}' is pre-existing, measured at the compile seam only, carrier none — recorded as the seat recorded it.
  • H3 (the typed reading at the RLS seam) — answered: right. My own grep: 0 of 88 in-repo predicate lines compare a column against a day or a token, so the "rows or admitted writes change" trigger for the maintainer is not met and the choice is in-lane; typed moves no driver's answer, type-blind would move SQL using answers on text columns.
  • security-plugin.ts outside the claim — answered: accepted as the seam's input, not a seam; both compileFilter sites (the only two) receive the set; the cache is filled only where fieldNamesCache is, so a non-null declared always has its datetime twin. It carries the one FAIL item above.
  • ADR anchor and api-surface regeneration — answered: right (see ①); the root and assembled surfaces are correctly unchanged.
  • Item 7 "no declaration ⇒ no datetime" — answered: right (see ①); carry the sentence to the step-4 deletion cards, as the PR's acceptance note already does.
  • NOT MEASURED live PostgreSQL / MySQL / mongod / Turso remote — answered: PG and MySQL are CI's Temporal Conformance job on this head (in_progress as read; its conclusion is the verdict); mongod and Turso remote have no CI cell and rest on item 9 plus the closed vocabulary (F2 / F6 compile $or, $null, $lt, $gte) — the ruling's recorded confidence gap, unchanged by this PR.
  • Out-of-scope notes — answered: F8's own null coercion stays in having-filter.ts for any caller that reaches applyHaving / matchesAggregationFilter without the seam (F8's step-4 deletion card, under A); the RLS check placeholder, carrier none; F9 receiving the lowered using, step 3 (with the H2 carry above).
  • Serial — fix(objectql,platform-objects,metadata-protocol): read sys_migration flag rows through findOne, ending the paging warning on every upgraded boot (#20648) #20766 merged in; [finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 unlanded, other regions of engine.ts; whichever lands second merges main.
  • The dev's open_questions holds one entry (the stop line) — answered above.

Implemented-by: claude/issue-5930-step2-seam-lowering
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: FAIL

One item: ② @objectstack/plugin-security graded patch for an additive widening of a published accept set (RlsFieldGuard.datetime); minor is owed. Everything else conforms; a re-graded head takes a fresh record.


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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 36e5ce8828a20a32ba415108d429c6f2141ca738
Local-runs: none

Delta review of PR #20794 (Part of #5930, step 2 of ruling 5902355785) after the FAIL record 5905611995 on 9ca3698b6706acbc2f1cbc3f9a4fd485b54d9ed5. Inputs, and nothing else: that record in full (its one FAIL item and the fix it prescribed); the diff 9ca3698b67..36e5ce8828 and the changeset's full text at the head; the os-dev-report addendum 5905899813 on #5930; the head's check-runs as they stood when read. The PR itself was read through REST only to confirm the head, which had not moved. Read-only: git fetch into a ref of my own, then git diff, git show and git log; REST reads of the two comments, the PR and the check-runs; nothing built, run or re-run.

① Derived judgments

The FAIL item is fixed as prescribed — right. The delta is one commit, 36e5ce8828, a fast-forward of 9ca3698b67: git diff --name-status lists one path, .changeset/5930-shared-filter-lowering.md, and the diff is one line, '@objectstack/plugin-security': patch becoming '@objectstack/plugin-security': minor (+1 / −1). That is the fix record 5905611995 prescribed, word for word. minor is the right level and not merely a higher one: the Check Changeset step's WHICH LEVEL prose (.github/workflows/pr-automation.yml; the maintainer's ruling of 2026-09-04, decision batch #35, on #15294) gives an additive widening of a published accept set "at least minor"; the code is unchanged since 9ca3698b67, where 5905611995 verified that nothing is removed or renamed, so no BREAKING banner and no ADR-0087 disposition is owed and major is not in question; and the feat( commit type now agrees with the act instead of sitting above a patch. Check Changeset is success on the head as read.

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 — "SecurityPlugin now hands the compile seam the object's declared datetime columns (RlsFieldGuard.datetime). A guard without that set treats no column as datetime." — and "Nothing is removed or renamed, and there is nothing to migrate." is exactly what minor, as against a breaking level, asserts. Clause-②: yes already covered the widening, since yes takes at least minor; the per-package line now says the same thing. Every sentence 5905611995 read as true is byte-identical and stays true.

The addendum agrees with the diff. 5905899813 reports patch_round: 1, previous_head: 9ca3698b67, head: 36e5ce8828, files_changed exactly the changeset, "1 file, +1/-1", "No sentence in the body names a level … so nothing else changed", the code unchanged under the seat's answer A, open_questions: [] and out_of_scope_findings: []. Its gate lines (103 derived families with exit 0, check:generated 15 of 15, the three changeset gates clean) are the dev's own runs; the gate verdicts are the check-runs below. Its two deviations (the worktree re-created from the pushed head; three commands from a time-budgeted batch re-run on their own, each with an exit code) change nothing about the diff.

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 success on 9ca3698b67 (Type Check · source gates, Type Check · debt ledger, Dogfood Regression Gate 1/3) are in_progress here only because this head's run is younger.

② Semver level

  • @objectstack/plugin-security minor — right; the FAIL item is closed (①).
  • @objectstack/spec minor — unchanged from 9ca3698b67; right, as 5905611995 judged (two new ./data exports).
  • @objectstack/objectql patch — unchanged; right, as judged (no public signature moves; the F8 answer change is the ruled NULL polarity applied at a seam item 2 names, a fix).
  • Clause-②: yes — unchanged, present, no arm; right, as judged.
  • Changeset truthfulness — every sentence unchanged and still true as read; the three levels now agree with the acts they grade.

③ Boundary flags

Implemented-by: claude/issue-5930-step2-seam-lowering
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

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

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…sys_migration DATABASE_ERROR (objectstack-ai#20768) (objectstack-ai#20818)

Fixes objectstack-ai#20768

Clause-②: no

On the first boot of a new database, the SQL driver printed one
`[sql-driver] DATABASE_ERROR ... no such table: sys_migration` line on
its warn channel. The read behind it is the engine's migration-gate
read. The driver's own pre-DDL question reaches that read before the
schema pass has created any table. The driver now asks that question
inside an async scope. Inside the scope, one class of refusal goes to
`debug` instead of `warn`: a missing table, recognised by the shared
`isMissingTableError` over the envelope's declared target. Every other
refusal still warns, and so does a missing table read anywhere else. No
gate's answer changes.

## Reproduction on the base

`objectstack dev --database file:NEW.sqlite -p PORT` on
`examples/app-crm`, base `96e724475c` (every package in the app's
closure built), stdout and stderr captured separately:

| run | `DATABASE_ERROR` lines | channel |
|:--|:--|:--|
| first boot of a new file | **1** | stderr (the driver's default
`console.warn` sink) |
| second boot of the same file | 0 | none |

The line, verbatim:

```text
[sql-driver] DATABASE_ERROR — the backend refused a read on 'sys_migration' (SQLITE_ERROR). The dialect message below is kept server-side: it carries the compiled statement, and on the dialects that inline them the bound literals too (objectstack-ai#7929, objectstack-ai#8931): select * from `sys_migration` where `id` = 'adr-0104-file-references' limit 1 - no such table: sys_migration
```

## The mechanism: which dispatch hypotheses held

**H1 held.** For one boot, I added a temporary stack-trace line to the
built `packages/objectql/dist` (restored afterwards, `cmp`
byte-identical on all four bundles, marker count 0). The first gate read
at boot is `ObjectQL.readMigrationFlagVerified`, reached through
`readFileReferencesFlagRow` and `haveFileColumnsMoved`. It is called by
the closure that `registerDriver` hands the driver, which
`SqlDriver.resolveFileColumnsMoved` asks at the start of
`SqlDriver.initObjects`, from the schema pass's first `syncSchema`,
before any DDL. The other gate readers run after the schema pass has
created `sys_migration`, so on the first boot they read without error:
`isValueShapesMigrationVerified` from write hooks, and
`announceOpenMigrationGates` at `kernel:bootstrapped`.

**H2 falsified.** The engine cannot tell whether the table exists
without asking the backend:

- The driver's registered-table set answers "registered in this
process", not "exists". On a second boot the table exists but is not
registered yet when the question is asked. Answering "not moved" there
without a read would change the answer on a deployment whose columns
have moved, which is the failure the resolver exists to prevent.
- The schema pass has no plan at that moment: the question is asked
before the first `hasTable`.
- For an absent table, `conclusive` must be `false`. The same pass
creates the table moments later, and a memoized "not verified" would
freeze a whole boot's posture from a moment when nothing could answer.
That is what the refused read already returns, so it is unchanged.
- A catalog probe (`hasTable`) would give "not asked", but it needs a
new public driver method or a new `IDataDriver` member. That enlarges a
public surface, against this card's `Clause-②: no`, so this PR does not
add one.

**H3 held.** `SqlDriver.backendStatementFault` writes the line before
the engine sees the error. The engine's `findOne` path does not log, and
its catch in `readMigrationFlagVerified` answers `{ verified: false,
conclusive: false, columnsMoved: false }` silently. So the demotion is
in the driver, keyed on `isMissingTableError`, and limited to this read:
the async chain of the driver's own question.

**H4: measured at `c5ae3a6f8e`.** The second boot prints nothing, as on
the base. `os migrate plan --database-url file:X` on the database the
boots created prints no `DATABASE_ERROR`. On a path that does not exist
yet, the plan printed 7 lines on the base, 2 of them on `sys_migration`,
and prints 6 now. The one `sys_migration` line left is the
`adr-0104-value-shapes` read from `announceOpenMigrationGates`, outside
the driver's question. The other 5 are `sys_metadata` (4) and
`sys_metadata_activation` (1). They are not this card. See the
acceptance notes.

## Landing site: `packages/drivers/driver-sql`, not
`packages/objectql/src/engine.ts`

The dispatch expected the engine, with the driver only if a demotion was
needed. As H2 and H3 show, the demotion is needed, and the line's
producer is the driver. The premature read is the driver's own pre-DDL
question. `engine.ts` is unchanged.

## What changed

`packages/drivers/driver-sql/src/sql-driver.ts`:

- A module-level `AsyncLocalStorage` (`PRE_DDL_QUESTION_SCOPE`).
`resolveFileColumnsMoved` runs the resolver inside it, and nothing else
does. It is module-level rather than per instance because the question's
read goes to whichever driver serves the ledger, which on a
multi-datasource composition can be another instance in the same async
chain.
- `backendStatementFault` composes the envelope first. If the scope is
active and `isMissingTableError(envelope, object)` holds, it writes the
same dialect text to `this.logger.debug?.(...)` and returns the
envelope. Otherwise it warns as before. The throw, the envelope, and
what the resolver hears are unchanged.
- The logger shape gains an optional `debug` channel. The default sink
has none.
- `isMissingTableError` is imported from its home, `@objectstack/types`.
`@objectstack/metadata/errors` re-exports the same symbol, and
driver-sql cannot depend on `@objectstack/metadata`. No second message
regex.

`.changeset/20768-first-boot-migration-gate-read.md`:
`@objectstack/driver-sql` patch. `@objectstack/runtime` gains only a
test file, which its `files[]` (`dist`, `README.md`, `CHANGELOG.md`)
does not ship, so it gets no changeset entry.

## After: this branch at `c5ae3a6f8e`, built

| run | `DATABASE_ERROR` lines | `sys_migration` mentions | server ready
|
|:--|:--|:--|:--|
| `objectstack dev --database file:NEW.sqlite` (`examples/app-crm`),
first boot | 0 | 0 | yes |
| second boot of that file | 0 | 0 | yes |
| `os migrate plan` on that file | 0 | 0 | exit 0 |

Normalised for timestamps, port and file path, the first-boot stderr
differs from the base run in exactly one line: the removed one. Both
ledgers end in the same state after the first boot: both flags verified
by the fresh-datastore attestation, and `columns_moved_at` null.

## Tests

-
**`packages/drivers/driver-sql/src/sql-driver-20768-pre-ddl-question-missing-table.test.ts`**
(new, SQLite on a new temp file). The resolver is a stand-in that reads
the ledger the way the engine does.
- ① First boot: no warn line, one `debug` line (`no such table`). The
resolver gets the envelope (`code: 'DATABASE_ERROR'`, `status: 500`),
and the arm stays "not moved".
  - ② Same result when the ledger is served by a second driver instance.
- ③ Second boot: a `columns_moved_at` row written between the boots
makes the arm "moved", so the read reached the row. Nothing is logged.
- Controls, each still `warn`: ④ a malformed read on an existing table
inside the question (40,000 bound variables, SQLite refuses the
statement); ⑤ a view over a dropped table inside the question (a missing
table named by another relation); ⑥ a missing table read outside the
question.
-
**`packages/runtime/src/first-boot-migration-gate-read.integration.test.ts`**
(new). A real `ObjectQL` engine over a real `SqlDriver` on a new file,
`SysMigration` registered, and `engine.syncSchemas()`, which is the same
`syncSchema`, `initObjects`, resolver, gate-read chain.
- First and second boot: no `sys_migration` `DATABASE_ERROR` on warn,
and one demoted line on the first boot only.
- Gate answers asserted on both boots: not moved and not verified on the
first; verified after a row is written between boots.
- Control: a malformed read on an existing table still warns, and so
does a missing table read after the boot. It counts only its own reads'
lines.
- Suites at `c5ae3a6f8e`:
- driver-sql: 201 files passed, 11 skipped (the live PG and MySQL cells,
not provisioned here); 3254 tests passed, 188 skipped.
  - runtime `--project local`: 293 files, 4207 tests passed, 1 skipped.
- Typecheck green for driver-sql and runtime. Runtime's
`check:test-typecheck` debt is unchanged, and `tsc --listFiles` shows
both new tests inside their packages' programs.

## Reverse verification (from the committed fix)

`packages/drivers/driver-sql/src/sql-driver.ts` was restored to its base
blob with `git restore --source=96e724475c`: on-disk blob `2462eccd` =
base, marker count 0. driver-sql was rebuilt, and
`ablation-dist-preflight --absent PRE_DDL_QUESTION_SCOPE` found the
marker absent from all 6 built files.

- Driver pin: **red** on ① and ② (`expected [ Array(1) ] to deeply equal
[]`). ③ to ⑥ stayed green.
- Runtime pin: the boot test **red** (same message). The control stayed
green.

Restore: `git checkout HEAD --
packages/drivers/driver-sql/src/sql-driver.ts`. Blob `a3647fd1` = HEAD,
and `git diff HEAD` was empty. After a rebuild, the preflight found the
marker in 2 built files and a clean tree, and both pins were green
again.

The first run of this verification also turned the runtime control red:
it had counted the boot's own `sys_migration` line together with its
own. It now counts only its own reads, and the second run above is from
that commit.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands` at `c5ae3a6f8e` derives
63 commands, and all 63 ran on that head with exit 0. `--ran`
reconciliation: "63 derived famil(ies) accounted for — 63 run, 0
NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of
them is 3)". On the first pass, `check:dual-build-cjs-loads` answered
PREREQUISITE NOT MET (9 packages had no `dist/`). I built those 9 and
re-ran it, and it exited 0.

Lint, narrowed: `eslint --no-inline-config --format json` over the 3
touched `.ts` files counted 3 files, 0 errors, 0 warnings. The
population is the config's `packages/**/*.{ts,tsx,mts,cts}` and
`**/*.{ts,...}` objects. The config never enables type-aware linting (no
`parserOptions.project`), so this diff cannot change the verdict on an
untouched file.

The branch merged `main` twice. The second merge brought in objectstack-ai#20794, the
serial constraint. Neither merge touched this diff's files, apart from
the tracker-number wording in the same `DATABASE_ERROR` line, which this
branch now carries as `main` spells it.

## Acceptance notes

- `os migrate plan` against a database that does not exist yet still
prints 6 `DATABASE_ERROR` lines at `c5ae3a6f8e`: `sys_metadata` 4,
`sys_metadata_activation` 1, and `sys_migration` 1. The last is the
`adr-0104-value-shapes` read by `announceOpenMigrationGates` at
`kernel:bootstrapped`, which on a deferred-DDL plan runs over tables the
plan never creates. It is the same false-alarm family, on a different
door, and is reported to the seat. It is not fixed here.
- "Demoted, not deleted" holds only where a host injects a logger that
has `debug`. No production composition hands `SqlDriver` a logger today,
so with the default sink the line is dropped. The engine's catch records
nothing either.
- `PRE_DDL_QUESTION_SCOPE` covers any read in the resolver's async
chain, including one a hook starts inside it. That is intended: every
such read is issued for the question, before the schema pass has run.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…the analytics seams and the cube face's new door (objectstack-ai#5930 step 3) (objectstack-ai#20857)

Fixes objectstack-ai#20810
Clause-②: yes (narrowing)

objectstack-ai#5930 step 3, under ADR-0053 D-D1 as amended by objectstack-ai#20754: the one shared
`FilterCondition → FilterCondition` lowering (`lowerFilterCondition`,
`@objectstack/spec/data`, landed in step 2 as objectstack-ai#20794) now runs at the
three analytics seams, after the comparand doors and after filter-token
resolution, and the two faces that could not compile its output now can.
`packages/spec/src/**`, the interim window arms and objectstack-ai#20807's position
are untouched.

## Named gap: three pins outside the claim's file surface


`packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts`
holds three pins of the cube face's PRE-change vocabulary and shape, and
it is outside the claim's `memory-analytics*.test.ts` surface, so this
branch does not edit it. They are red here, and the `driver-memory` Test
Core shard is red until they move:

- "every combinator case is refused rather than dropped" asserts every
`$or` case refuses. The face compiles `$or` now; the same file's
case-by-case invariant (agree with `find()` or refuse) stays green over
every `$or` case.
- "every operator this face DECLARES compiles to a predicate that agrees
with find()" needs a probe for `$null`, which joined the face's table.
- the `$notContains` pipeline-dump row pins the un-lowered `$match`; the
lowering's NULL escape now wraps it.

The three-row patch (15 changed lines) is prepared and was verified on a
copy of the file: 125 of 125 tests pass. It lands once the file is added
to the surface.

## Per seam

| Seam | Position at `bbe03f406` | Pin | Ablation (lowering removed at
that seam) |
|---|---|---|---|
| analytics `where` door, F10 (`where` → tree, both strategies) |
`normalizeWhereComparands` `filter-normalizer.ts:2218`, reached through
`lowerAnalyticsWhere` `:2244`; the lowering sits in
`normalizeAnalyticsFilterTree` `:2356`, the one compile entry both
spellings reach | `where-door-shared-lowering-seam.test.ts`, 18 rows | 6
of 18 red: the tree rows, the ObjectQL hand-off and the echo |
| draft-preview door, F11 | `preview-evaluator.ts:675`, right after its
`normalizeWhereComparands` call | same file (preview block) | 3 of 18
red: the three NULL-escape rows |
| read scope | entry of `compileScopedFilterToSql`
`read-scope-sql.ts:747`, after placeholder resolution `:764`, before
`compileNode` `:769` | `read-scope-shared-lowering-seam.test.ts`, 12
rows | 5 of 12 red: the caller-filter rows, the RLS bound as written,
the SQL row and the date-macro row |
| cube face F5, the new door | `normalizeFilters`
`memory-analytics.ts:1585`, ahead of its gate `:1592` |
`memory-analytics-shared-lowering-door.test.ts`, 13 rows | doors
removed: 4 of 13 red (objectstack-ai#20734's table); lowering removed: 2 of 13 red |
| the where door's nested-relation spelling (below) |
`normalizeAnalyticsFilterTree` | the nested-relation rows in two files |
2 red |

Every ablation mutated the committed file through
`scripts/ablation-replace.mjs` (anchor hit once, blob moved), ran the
pins, and restored: blob equal to HEAD and `git diff HEAD` empty, each
time. The pins import their subjects by relative path, so vitest reads
`src/` and no build sits between the mutation and the reading.

## What each seam does

- **Read scope.** `compileScopedFilterToSql` lowers the scope right
after its placeholders resolve. Column-type scope (item 7): the
`declaredValueShape` option both consumers already pass, so a declared
`datetime` column is rewritten and a `date`, `time` or text column
compiles byte-identical; no declarations handed in reads no column as
`datetime`. The shared comparand doors still judge the scope as written
after compilation (objectstack-ai#20018's order); the lowering never refuses, so no
verdict moves.
- **Analytics `where` door (F10).** `normalizeAnalyticsFilterTree`
lowers what the door admitted before `buildNode` reads it. Its
column-type reader is now a REQUIRED argument: both strategies pass
`declaredDatetimeLowering` (a member is `datetime` when its column is
declared so, through the `declaredFieldType` hook, asked of the same
target each strategy compiles against); a context without the hook, and
the member-only readers (`assertWhereFields`, the cross-object view),
pass `NO_DATETIME_COLUMNS`. `lowerAnalyticsWhere` stays un-lowered: its
other readers (ad-hoc cube dimension minting, the routing detectors)
read the authored condition.
- **Nested relations at the `where` door.** The shared lowering has no
reading of the nested-relation spelling (`{ account: { region: 'NA' }
}`: accepted by the schema, refused by the engine, flattened only by
this door). Inside a `$not` it read `account` as a column and guarded
it. The door now spells nested relations as the dotted members
`fieldLeaves` has always compiled them to, before the lowering reads the
condition, so the guard lands on `account.region`.
- **Draft preview (F11).** Lowered after its `normalizeWhereComparands`
with `NO_DATETIME_COLUMNS`: drafted rows carry no schema, and a
type-blind rewrite would move one cell away from the typed drivers
(`$lte` on the last supported day over a non-temporal value that sorts
above it); its own `lteBound` copy keeps the whole-day rule until its
deletion card. It now evaluates `$null`.
- **Cube face (F5).** `normalizeFilters` runs
`assertListComparandShapes`, then `normalizeFilterComparandTypes` (its
return is the condition read from there on), then the lowering
(type-blind: the face reaches declared types only as a storage-form
conversion, and its own copy and `find()` are type-blind already), then
its own gate on the lowered condition. It compiles `$or` (a disjunction
entry both exits render, with the objectstack-ai#5322 identities) and `$null`; `$not`,
`$startsWith`, `$endsWith` and `$empty` stay refused.

## Every corrected answer

Read scope (`compileScopedFilterToSql`: the NativeSQL statement's scope
and the `/analytics/sql` echo's), each now `SqlDriver.find`'s rows on
the same filter:
- `{ signed_at: { $lte: '2026-07-28' } }` on a declared `datetime` over
rows at 10:00Z on 07-27, 07-28, 07-29 and a row with no value: was
07-27, now 07-27 and 07-28 (`< '2026-07-29'`). objectstack-ai#20733's caller-filter
half.
- `{ signed_at: { $between: ['2026-07-28', '2026-07-28'] } }`: was no
row, now 07-28.
- a `{today}` upper bound is widened as the day it resolves to.

Analytics `where`, ObjectQL path: a bare-day `$lte` on a `datetime`
member reaches the engine as `$lt` the next day, and the
`/analytics/sql` echo prints that half-open bound where it printed `<=`
the named day. Rows unchanged.

Draft preview: `$null` answered (was refused 400). A row with no value
now satisfies `$ne`, `$nin` and the negation of an equality when the
comparand is the text `"null"` or `"undefined"`; the face compared those
as text against the missing value.

Cube face, measured on the published `MemoryAnalyticsService.query` at
the base blob and at this head:

| `where` (fixture: `d` is `v1`, `v2`, null, absent) | base | this head
| `find()` |
|---|---|---|---|
| `{d: undefined}` / `{d: {$eq: undefined}}` | 3, 4 | refused 400 | 3, 4
|
| `{d: {$ne: undefined}}` | 1, 2 | refused 400 | 1, 2 |
| `{d: {$in: ['v1', undefined]}}` | 1 | refused 400 | 1 |
| `{d: {$in: ['v1', null]}}` | 1, 3, 4 | refused 400 | 1, 3, 4 |
| `{d: {$nin: ['v1', null]}}` | 2 | refused 400 | 2 |
| `{d: {$gt: null}}` | none | refused 400 | none |
| `{d: {$in: 'v1'}}` | 1 | refused 400 | uncoded throw |
| `{d: {$eq: {a: 1}}}` | none | refused 400 | none |
| `{d: {$ne: {a: 1}}}` | all four | refused 400 | all four |
| `{d: new Map()}` | all four | refused 400 | none |
| `{n: {$gt: 2n ** 60n}}` | uncoded throw | refused 400 | uncoded throw
|
| `{n: {$gt: 2n}}` | uncoded throw | 3, 4 | uncoded throw |
| `{d: {$between: ['v1', 'v2']}}` | refused 400 | 1, 2 | 1, 2 |
| `{d: {$null: true}}` | refused 400 | 3, 4 | 3, 4 |
| `{$or: [{d: 'v1'}, {n: 4}]}` | refused 400 | 1, 4 | 1, 4 |
| `{d: {$ne: 'v1'}}`, `{$not: {d: 'v1'}}` | unchanged | unchanged | — |

(`find()` here is the driver called directly, without the engine seam
whose doors it relies on.) Every "refused 400" is the ADR-0112
`INVALID_FILTER` envelope every other analytics face already answers.

## Clause-②, measured

- **Widening:** the cube face accepts `$or`, `$null` and `$between`
(each refused at the base, each now `find()`'s rows), and the draft
preview accepts `$null`. `ANALYTICS_FILTER_CAPABILITIES` names `$null`
and `$or`.
- **Narrowing:** the cube face refuses the comparand shapes in the table
above that it used to answer.

Hence `yes (narrowing)`. `@objectstack/driver-memory` ships `minor` with
the BREAKING banner, the migration and an ADR-0087 `not-required
(no-migration-prescription)` disposition;
`@objectstack/service-analytics` ships `minor` (`Clause-②: yes`,
widening only: the read scope and the `where` door refuse nothing new).

## Mechanism assumptions, measured

- **A1 held**, with one refinement: the F10 call sits in
`normalizeAnalyticsFilterTree` rather than inside
`normalizeWhereComparands`, because the array spelling reaches F10
through `parseFilterAST` and never through `normalizeWhereComparands`,
and because `lowerAnalyticsWhere`'s other readers want the authored
condition. F11 lowers right after its own `normalizeWhereComparands`
call.
- **A2 measured.** `where` door: tokens resolve upstream on every path
that reaches it (`AnalyticsService.resolveQueryTokens` from `query()`
and `generateSql()`, the per-request dataset-scope getter,
`DatasetExecutor.resolveSelectionTokens` ahead of the preview). Read
scope: placeholder resolution is the compiler's first act, and the doors
run after `compileNode` on the scope as written (objectstack-ai#20018), so the
lowering sits between resolution and compilation. F5: no token
resolution exists on that face.
- **A3 proved, not assumed.** With the real `SecurityPlugin` and the
real read-scope compiler on SQLite (a one-off run, not committed):
`getReadFilter` returned
`{"$and":[{"signed_at":{"$lt":"2026-07-29"}},{"due_on":{"$lte":"2026-07-28"}}]}`,
the read scope compiled `("t"."signed_at" < ? AND "t"."due_on" <= ?)`
and answered c27 and c28, equal to `SqlDriver.find`. The committed pin
holds the RLS half in both spellings the read scope can be handed
(lowered by the RLS seam, and as written), so its answer no longer
depends on which arrives.
- **A4 measured.** F5 compiled 12 operators with `$and` alone; F11 10
operators with `$and`/`$or`/`$not`. The lowering emits `$and`, `$or`,
`$lt`, `$gte`, `$lte`, `$null`. Widened: F5 `$or` + `$null`; F11
`$null`. Nothing wider.
- **A5 held.** No window arm is touched (NativeSQL, the preview's window
loop, F5's `timeDimensions`).
- **A6** as the table above: objectstack-ai#20733's rows (both halves, the last day,
the declared `date` control) and objectstack-ai#20734's table, plus a pin and an
ablation per seam.
- **A7** above.
- **A8, coupling, re-measured:** objectstack-ai#20280 landed on `main` as `05a7547c9`
while this branch was open. Its datetime year floor lives in the
engine's temporal comparand door (`objectql`
`temporal-comparand-door.ts`) and in `@objectstack/core`'s temporal
storage form; in `packages/spec/src/data` it changed one comment. The
two spec doors the cube face now runs are not changed by it. What the
two share is the storage-form conversion both of the face's exits apply
to a comparand (the driver's `filterComparandStorageForm`, which reads
`@objectstack/core`): objectstack-ai#20280 changed that conversion, and this branch
neither touches nor pre-empts it. Nothing in this PR's file surface
changed on `main`, so the branch is not re-merged; this PR's CI runs on
the merge ref with objectstack-ai#20280 in it, and the merge queue adjudicates the
rest.

## Evidence (head `92a449c2d`, `main` merged at `c90f9fb6e`)

- `@objectstack/service-analytics`: 143 files, 3297 tests passed;
`typecheck` green (baseline at `bbe03f406`: 141 files, 3267).
- `@objectstack/driver-memory`: 66 files, 1482 passed, 3 failed (the
named gap above); `typecheck` green (baseline 65 files, 1470).
- Existing shape pins moved to the lowered structure; no asserted row
count moved. The row-level conformance suites (native-SQL filter-logic
and temporal, read-scope conformance, preview temporal) are unchanged
and green.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` derived 63
families at this head; all 63 were run with their exit codes recorded,
and `--ran` reports 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN (a
derived zero). `check:dual-build-cjs-loads` and `check:type-check-debt`
first answered `PREREQUISITE NOT MET` and were re-run green after `turbo
run build --filter='./packages/*' --filter='./packages/*/*'`. The six
roster families under this card's directories (`check-changeset-fixed`,
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`, `check:object-def-param-keys`,
`check:tenant-chokepoint`) are green too.
- Downstream analytics suites on the rebuilt dists: `@objectstack/rest`
`analytics-*` (10 files, 150 tests), `@objectstack/runtime`
`analytics-*` (3 files, 23), `@objectstack/dogfood`
`analytics-adhoc-query-isolation` (24): green.
- Lint, a narrowed run: the population is the `eslint.config.mjs` block
`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`; `eslint
--no-inline-config --format json` over the 38 changed source files
reports 38 files, 0 errors, 0 warnings at `92a449c2d`; the config
enables no type-aware linting (every `parserOptions` is `ecmaVersion` /
`sourceType`), so this diff cannot move a verdict on an untouched file.
- NOT MEASURED: live PostgreSQL / MySQL (CI's temporal job), a real
mongod.

## Acceptance notes

- **Double guards.** Each face's own interim NULL-polarity copy still
guards what the lowering already guarded, so the SQL, the trees and the
ObjectQL hand-off for `$ne`, `$nin`, `$notContains` and a `$not` operand
carry the guard twice. The same rows; the deletion cards remove the
inner copy and update these pins.
- **For the step-4 deletion cards.** The draft preview reads no member
as `datetime`; the `where` door with no `declaredFieldType` hook, and
the read scope with no `declaredValueShape`, read none. Once a face's
own bound copy is deleted, that path gets no whole-day bound unless its
card gives it a typed reader.
- **The read scope's temporal coercion (ADR-0053 D-A1, objectstack-ai#20733's other
half).** The read scope binds the lowered calendar string as written, as
it bound the authored one. Measured correct on SQLite ISO text;
PostgreSQL / MySQL NOT MEASURED. Carrier: none.
- **The shared lowering and the nested-relation spelling.** The spec
module reads a nested-relation spec under a `$not` as a column
constraint. No face meets that after this change (the analytics door
spells it dotted; the engine, the read scope and the preview refuse the
spelling; the cube face refuses `$not`). Noted for the module's owner;
carrier: none.

## Patch round 1 (appended by the `domain:services` seat)

**The named gap is closed.** The claim's surface gained
`packages/drivers/driver-memory/src/memory-driver-filter-logic-conformance.test.ts`
(amendment `5911719808`), and `edb3e2de4` applies the prepared 15-line
patch to it and to nothing else:

- every `$not` case must still refuse; the `$or` cases moved to the
answered column, which the file's case-by-case invariant holds to
`find()`'s rows;
- `$null` joins the declared-operator probe roster;
- the `$notContains` pipeline dump shows the lowering's NULL escape.

Evidence on head `edb3e2de4`:

- `@objectstack/driver-memory`: 66 files, 1485 passed (the three pins
green, nothing else moved); `typecheck` green.
- `@objectstack/service-analytics`: 143 files, 3297 passed.
- Gates: the same 63 derived families, all exit 0; `--ran` reports 63
derived, 63 run, 0 NOT-MEASURED, 0 UNRUN; the six roster families green.
- `main` was not re-merged: none of the five commits after `c90f9fb6e`
touches this PR's files.

---

_Generated by [Claude
Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_

---------

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 protocol:data size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants