Skip to content

feat(service-analytics,driver-memory): the shared filter lowering at the analytics seams and the cube face's new door (#5930 step 3) - #20857

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20810-analytics-seam-lowering
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20810-analytics-seam-lowering

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20810
Clause-②: yes (narrowing)

#5930 step 3, under ADR-0053 D-D1 as amended by #20754: the one shared FilterCondition → FilterCondition lowering (lowerFilterCondition, @objectstack/spec/data, landed in step 2 as #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 #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 (#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 (service-analytics: the NativeSQL read-scope compiler and the /analytics/sql echo compile two scope shapes the shared comparand faces refuse (plain-object comparand under $eq, null member in $in): one scope, two answers across faces #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 空组合子在同仓有两个对立答案:五个后端归约成布尔单位元,service-analytics 的两个编译器 fail-closed 抛错 —— #5239 的一致性表四条因此进不了表 #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:

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

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, [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 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

… at the analytics seams and the cube face's door (red)

Pins first, before the fix. Three seams, one file each:

- read scope (compileScopedFilterToSql entry): a bare-day upper bound on a
  declared datetime answers SqlDriver.find's rows, for a caller filter and
  for an RLS using bound in both spellings; the last supported day; a
  declared date control; the typed scope as SQL.
- analytics where / preview door: the where -> tree face compiles the
  lowered condition (typed on datetime members), the ObjectQL hand-off and
  echo carry it, and the draft preview evaluates it (with $null).
- the cube face's new door: the two shared comparand doors refuse an
  undefined comparand on both exits; the face compiles the lowered
  condition and the $or / $null vocabulary, and still refuses $not.

Red on this commit for exactly those rows.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
… at the analytics seams and the cube face's new door

ADR-0053 D-D1 as amended: one shared FilterCondition -> FilterCondition
lowering (lowerFilterCondition, @objectstack/spec/data), run at each
analytics seam after the comparand doors and after filter-token
resolution.

- analytics where / preview door: normalizeAnalyticsFilterTree lowers the
  condition the door admitted before buildNode reads it (F10), with a
  REQUIRED column-type reader: both strategies pass
  declaredDatetimeLowering (a member is datetime when its column is
  declared so); member readers and a context with no hook pass
  NO_DATETIME_COLUMNS. The draft preview lowers after
  normalizeWhereComparands with NO_DATETIME_COLUMNS (F11) and evaluates
  $null.
- read scope: compileScopedFilterToSql lowers at its entry, after the
  placeholders resolve, typed by the declaredValueShape both consumers
  already hand it.
- the cube face (F5): normalizeFilters runs assertListComparandShapes and
  normalizeFilterComparandTypes, then the lowering (type-blind: the face
  cannot read declarations), then its own gate on the lowered condition.
  It compiles $or (as a disjunction entry both exits render) and $null,
  and still refuses $not.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…red condition; spell nested relations dotted at the where door

The analytics where door now spells a nested-relation field spec as the
dotted members fieldLeaves compiles, before the shared lowering reads the
condition, so a guard the lowering lays inside a $not names the member the
leaf binds and never the relation key itself.

Existing pins updated to the lowered structure, rows unchanged:
- SQL-text / tree / engine-where shape pins for $ne, $nin, $notContains
  and $not now show the lowering's NULL guard around each face's own
  interim copy of it (idempotent in rows until the copies' deletion
  cards); every row-count assertion beside them is unchanged.
- the where-door tests pass the required column-type reader
  (NO_DATETIME_COLUMNS).
- the cube face's refusal and vocabulary pins: $or and $null are
  compiled, a $between reaches the face as its two bounds, and each is
  held to find()'s rows; $not, $startsWith, $endsWith and $empty stay
  refused.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-memory, @objectstack/service-analytics, touching 27 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/analytics.mdx (via account.region (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/rls.mdx (via account.region (literal, a string literal in a comment on a changed line))
  • content/docs/ui/dashboards.mdx (via account.region (literal, a string literal in a comment on a changed line))

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

  • content/docs/releases/v14.mdx (via generateSql (symbol, a method of class MemoryAnalyticsService; a method of class NativeSQLStrategy; a method of class ObjectQLStrategy))
  • content/docs/releases/v17/17-5.mdx (via generateSql (symbol, a method of class MemoryAnalyticsService; a method of class NativeSQLStrategy; a method of class ObjectQLStrategy))

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
  • 4 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 — 15 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 660a9b247e824f7747d63f3b778dccc9cb4751d6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 67ba542ae915d3adb3816698e978508ae74522fe — the merge of head edb3e2de482cf74a6d1c438b4ecd354fec64e799 into base 660a9b247e824f7747d63f3b778dccc9cb4751d6, 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 67ba542ae915d3adb3816698e978508ae74522fe && git checkout 67ba542ae915d3adb3816698e978508ae74522fe
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 660a9b247e824f7747d63f3b778dccc9cb4751d6 edb3e2de482cf74a6d1c438b4ecd354fec64e799 && git checkout -B drift-repro 660a9b247e824f7747d63f3b778dccc9cb4751d6 && git merge --no-ff edb3e2de482cf74a6d1c438b4ecd354fec64e799

node scripts/docs-audit/affected-docs.mjs --json 660a9b247e824f7747d63f3b778dccc9cb4751d6

⚠️ 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 660a9b247e824f7747d63f3b778dccc9cb4751d6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ened vocabulary

The claim's surface gained this file (amendment 5911719808) for exactly
this change. Three pins encoded the cube face's vocabulary and shape from
before the ruled widening; each is re-spelled, none removed:

- 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.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: edb3e2de482cf74a6d1c438b4ecd354fec64e799
Local-runs: none

Read at 2026-09-30T13:45Z: card #20810 (its body and all six comments: the triage grade, the claim and its surface amendment, the two dev reports and the ACCEPT), PR #20857 (its body, its 41-file list, and the net diff against main from merge base c90f9fb: 1490 lines added, 234 removed), and the 41 check-runs on this head. Origin main at the reading (72f8c38) touches none of the 41 files after the merge base, so the net diff is the branch's whole delta. No spec path and no governed path in the file list.

Check-runs on this head, latest run per name: every run has completed; none was still in progress at the reading. The seven required contexts all conclude success: Lint and Repo Gates, TypeScript Type Check, Test Core (and its six shards), Dogfood Regression Gate (and its three), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Check Changeset concludes success in both generations. A second pr-automation generation ran on the PR-body edit; in it Auto Label and Check PR Size conclude skipped (their first-generation runs concluded success; both are advisory). Console Pin Gate, Build Docs and the packed-tarball smoke are skipped by their filters. The Docs Drift Check (advisory) lists three hand-written pages through the string literal account.region on a changed comment line; the nested-relation spelling compiles to the same member, the same SQL and the same rows as before, so none of the three pages moves.

① Derived judgments

Judged against ADR-0053 D-D1 as amended in place at this head (items 1 to 10) and docs/design/predicate-compilation-convergence.md sections 3.4, 3.6 and 4.1. The shared lowering (packages/spec/src/data/filter-lowering.ts, step 2's) is untouched. Every seam calls it after the shared comparand doors, and every path that reaches the analytics door or the read scope has its filter tokens resolved upstream (resolveQueryTokens for the query's where and windows, resolvedDatasetScopeGetter for the dataset scope's filter and measure filters, DatasetExecutor.resolveSelectionTokens for the preview; the read scope resolves placeholders as its first act). Item 3 holds at every position this diff adds.

  1. Cube face F5: MemoryAnalyticsService.query and generateSql, public exports of @objectstack/driver-memory — RIGHT. normalizeFilters now runs assertListComparandShapes, then normalizeFilterComparandTypes (its return read from there on), then lowerFilterCondition type-blind, then the face's own vocabulary gate on the lowered condition. Widening: $or (a NormalizedCubeDisjunction both exits render, with the 空组合子在同仓有两个对立答案:五个后端归约成布尔单位元,service-analytics 的两个编译器 fail-closed 抛错 —— #5239 的一致性表四条因此进不了表 #5322 identities: an empty branch is TRUE and drops the disjunction, $or: [] is FALSE as $expr: false and as 1 = 0), $null (isNull on both exits), $between (lowered away before the gate), a bigint within 2 to the 53rd. Narrowing: undefined in any comparand position, a null list member, null under an ordering operator, a scalar under $in or $nin, a plain object, a Map or a binary comparand, each refused INVALID_FILTER / 400 as every other face refuses them. That is exactly the two doors item 2 names for this face and the section 3.4 vocabulary the card asks for ($and, $or, $lt, $null); $not stays refused, and the lowering's closed output vocabulary never emits it. Type-blind is the reading item 7 gives a seam that cannot read declarations, and it is the reading this face's own copy and find() already give, so no answer moves by it. Pinned: memory-analytics-shared-lowering-door.test.ts (13 rows, both exits), the refusal, echo-coverage and whole-day-first files moved to the widened vocabulary, and the three conformance pins the surface amendment admitted (every $or case now held to find()'s rows case by case, $null on the declared-operator roster, the $notContains dump showing the NULL escape). Ablations: doors removed, 4 of 13 red; lowering removed, 2 of 13 red.
    • Note, not a refusal: of the narrowing rows, only the four undefined shapes are face-pinned in this diff; the null-list, scalar-$in, plain-object, Map, binary and bigint rows rest on the spec doors' own pins plus the door-installed ablation. The changeset's BREAKING list names them all, which is the right place for the table.
    • ANALYTICS_FILTER_CAPABILITIES (a module export, not a package export) gains $null and the $or combinator, and the echo-coverage file holds it equal to the compiled set.
  2. Read scope: compileScopedFilterToSql, the one public export of @objectstack/service-analytics this diff touches — RIGHT. Signature unchanged. The lowering runs at the entry, after resolveReadScopePlaceholders and before compileNode, with isDatetimeColumn read from the declaredValueShape option both consumers already pass. Accept set unchanged: the lowering never refuses, and the shared doors still judge the scope as written after compilation (service-analytics: the NativeSQL read-scope compiler and the /analytics/sql echo compile two scope shapes the shared comparand faces refuse (plain-object comparand under $eq, null member in $in): one scope, two answers across faces #20018's order). Corrected answers: a bare-day $lte and a one-day $between on a declared datetime now keep the named day (half-open against the next day, in the calendar-string domain), the last supported day keeps IS NOT NULL, and a date macro is widened as the day it resolves to; a declared date column compiles byte-identical. That 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 caller-filter half, with the RLS using half pinned in both spellings it can arrive in. Pinned: read-scope-shared-lowering-seam.test.ts (12 rows; the export, NativeSQLStrategy.applyReadScope and SqlDriver.find held equal on SQLite); ablation 5 of 12 red. Compiled SQL for $ne, $nin, $notContains and a $not operand now carries the lowering's guard around the face's own (same rows; item 9's interim state), and the existing shape pins moved with it.
  3. Analytics where door F10: normalizeAnalyticsFilterTree (internal; not exported from the package index) — RIGHT. The lowering runs on what lowerAnalyticsWhere admitted, before buildNode, on both spellings (the parseFilterAST output included). The new REQUIRED lowering argument is an internal signature change: both strategies pass declaredDatetimeLowering (a member is datetime when the context's declaredFieldType hook says its resolved column is; resolveStorageTarget cannot throw, so neither can the reader), the echo passes the same reader, and the member-only readers (the where-field gate in analytics-service.ts, the cross-object envelope, the leaf census) pass NO_DATETIME_COLUMNS. Rows unchanged on every strategy: the engine seam lowers the hand-off again idempotently, and NativeSQL's own lte arm never rewrites a $lt. The /analytics/sql echo now prints the half-open bound the engine runs. Pinned: where-door-shared-lowering-seam.test.ts (18 rows across F10, the ObjectQL hand-off, the echo and F11); ablation 6 of 18 red. lowerAnalyticsWhere itself stays un-lowered for the readers that want the authored condition (ad-hoc cube minting, the routing detectors); the ruling places the seam at the door, and normalizeAnalyticsFilterTree is the door's one compile entry, so the refinement of A1 the body records is the right placement.
  4. The nested-relation pre-spelling at the where door, spellNestedRelationsDotted — RIGHT, and the one change beyond the ruling's text. It rewrites only the spelling ({ account: { region: 'NA' } } to the dotted member fieldLeaves has always compiled it to), so the guard rule 3 adds under $not names account.region and never account. Copy-on-write; an empty nested spec is left for fieldLeaves' zero-operator refusal; a duplicated member becomes an $and conjunct as before; a non-array $and is left whole for buildNode's refusal. No accepted or refused shape moves. The rebuilt node does not carry a filter-subtree provenance mark, and no reader depends on one here: the strategies stamp their author and policy marks on the engine-bound outputs they build after this door, not on its input. Pinned (two rows; ablation 2 red).
  5. Draft preview F11 (queryDataset with previewDrafts; the evaluator is not a package export) — RIGHT. Lowered right after normalizeWhereComparands, with NO_DATETIME_COLUMNS, before assertPreviewCanEvaluate. Widening: $null is evaluated (value == null reads a null and an absent key alike, the reading driver-memory and formula give). Corrected answers: a row with no value satisfies $ne, $nin and a negated equality when the comparand is the text "null" or "undefined". Nothing narrows: the lowering emits only $and, $or, $lt, $gte, $lte and $null, all in the preview's table; $exists and $empty stay refused. Its own lteBound keeps the whole-day rule; ablation 3 of 18 red.
  6. Item 7 on the typed seams handed no declarations — RIGHT as the interim reading, escalated in ③. The read scope with no declaredValueShape, F10 on a context with no declaredFieldType hook, and F11 always, read no member as datetime rather than lowering type-blind. Step 2 chose this reading for the RLS guard without types, and it moves no answer today because every face's own copy still applies (item 9); the type-blind alternative would have moved one F11 cell away from the typed drivers ($lte on the last supported day over a non-temporal value). It is not a decision this card owns, and it is recorded for the step-4 deletion cards.
  7. Untouched, as claimed — RIGHT. packages/spec/src/** (no path in the diff), the NativeSQL, preview and cube window arms (item 8), and [finding] analytics: a cube / dataset dimension on a json field, compiled by NativeSQLStrategy, answers one group per serialized document on SQLite and 500 on PostgreSQL; the engine door #20783 closes does not see it #20807's position. The docs-drift rows are literal hits on a comment line, not behaviour.

② Semver level

  • @objectstack/driver-memory (17.5.0, published): minor with the BREAKING banner, Clause-②: yes (narrowing), and exactly one ADR-0087 marker, not-required (no-migration-prescription). RIGHT: the face narrows a published accept set (the comparand shapes above) and widens it ($or, $null, $between); a breaking change ships as minor under the launch-window convention check-changeset-no-major enforces; the category holds because no spec key, export or stored row changes spelling, and which comparand a refused value meant is not something a ledger entry can prescribe. The body carries the FROM and TO an upgrading author needs: the rows that used to answer, and the comparand to write instead.
  • @objectstack/service-analytics (17.5.0, published): minor, Clause-②: yes. RIGHT: the preview's $null is an accept-set widening, so at least minor; nothing in this package narrows, since compileScopedFilterToSql keeps its signature and its refusal set, normalizeAnalyticsFilterTree is not a public export, and the lowering never refuses. The corrected read-scope rows are stated in the changeset, as the card's acceptance requires.
  • PR body line 2, Clause-②: yes (narrowing): the PR-scoped declaration reads both facts, and the arm sits in the changeset that carries the narrowing. RIGHT. Check Changeset concludes success on this head.
  • No skip-changeset; nothing else publishes.

③ Boundary flags

The dev report lists open_questions: []. Its deviations, its out-of-scope findings and the PR's acceptance notes, each answered or escalated:

  1. REST reads of two [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 comments were refused by the local permission layer and not re-routed — ANSWERED. The governing text is the amended D-D1 at this head, which carries the ruling's order of work and its provenance in place; I read it there, and the diff conforms to it. The substitute was sufficient.
  2. Two small runs outside the verify lock; the worktree recreated at the pushed head — ANSWERED. Evidence hygiene, not a change to what landed: the check-runs on this head are the verdict, and the patch-round commit is one file, 11 lines added and 4 removed, parented on the previous head, as the card records.
  3. The named gap (three conformance pins outside the claim's surface) — CLOSED. The surface amendment on the card admitted the file, and the commit re-spells the three pins truthfully: every $not case still refuses, the $or cases move to the answered column under the file's case-by-case find() invariant, $null joins the probe roster, and the $notContains dump shows the escape. Test Core is green on this head.
  4. The cube face drops an array where and answers every row (class a, pre-existing; the new door reads only a non-array where) — ESCALATED. The ACCEPT record names [finding] driver-memory analytics: MemoryAnalyticsService drops an array (FilterArray) where and answers every row, where the object spelling filters and the engine refuses 400 #20859 as filed for it, serial after this PR. Not a regression of this diff: the array spelling was dropped before the door existed.
  5. Faces with no typed reader lose the whole-day bound once their own copy is deleted (F11; F10 without the hook; the read scope without declaredValueShape) — ESCALATED. The ACCEPT records a pointer on [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 for the step-4 deletion cards; each deletion card must either hand its face a typed reader or adopt the type-blind reading item 7 allows, before the copy goes. Until then no answer moves.
  6. The read scope binds the lowered calendar string as written (D-A1 coercion); PostgreSQL and MySQL not measured locally — ANSWERED as far as this head can answer it. The Temporal Conformance (live PG + MySQL) check-run concludes success on this head; the read scope's own live-dialect row stays the acceptance note the body records, carrier none.
  7. The spec lowering's rule 3 reads a nested-relation spec under $not as a column constraint — ESCALATED to the module's owner by this record. No face meets it after this PR (the door spells it dotted first; the engine, the read scope and the preview refuse the spelling; the cube face refuses $not), so it has no reach today and is rightly not a card. A future seam that admits the nested spelling must spell it dotted before lowering, as this door does.
  8. Double guards (the lowering's NULL guard around each face's own copy) — ANSWERED. Item 9's interim state: idempotent in rows, a longer statement, removed by the deletion cards that also update these pins.
  9. Narrowing rows without a face pin (① item 1) — NOTED for the seat. The door-installed invariant is pinned and ablated, and the row-by-row table is the changeset's. Holding each row on the face is a follow-up test card if the seat wants it, not a blocker.
  10. The dispatch's Clause-②: yes was taken conservatively; the dev measured yes (narrowing) — ANSWERED. The measured grammar is the right one (②).

Implemented-by: claude/issue-20810-analytics-seam-lowering
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 13:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 793fb83 Sep 30, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20810-analytics-seam-lowering branch September 30, 2026 14:08
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/xl tests tooling

Projects

None yet

2 participants