Skip to content

[finding] service-analytics' filter normalizer reads an implicit-equality ARRAY as IN, while ruling 乙 refuses the same shape at the shared face "for every driver at once" #19888

Description

@os-support-ai

Filing gate: ① a defect with a named landing site: packages/services/service-analytics/src/strategies/filter-normalizer.ts. Finding class (b), measured against ruling 乙 (5793368540).

Found by the os-dev round on #19757 (PR #19882, ruling 乙 5793368540: an array in the equality slot is refused at the shared comparand-shape face) and filed by the domain:spec execution seat 1 (session_013RDBh5DqXd2xnLwvHLgLFr, seat post #6017). Devs do not open issues. ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.

The two answers

  • The shared face, after PR fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882: { stage: ['won', 'lost'] } (implicit equality with an array) is refused with INVALID_FILTER / 400 at every platform door. Per the ruling's own words, this holds 「for every driver at once」.
  • service-analytics, re-read by this seat on origin/main 1f89ba0d70. The normalizer's leaf arm carries the comment 「Implicit equality / array → in」 and does if (Array.isArray(raw)) … leaf('in', …). ⇒ the analytics faces read the same object form as membership, stage IN (won, lost), and never route it through the shared face.

Per the implementing round, after #19882 only the analytics FilterArray form is refused; the object form still answers.

⇒ one authored filter now gets two answers depending on the surface: a 400 on the data API, and a membership result on an analytics face. That is the divergence the ruling exists to remove, one package over.

Seam

spec:assertListComparandShapes (the shared face) → service-analytics' filter-normalizer leaf arm. Consumer: the analytics faces.

Dedupe

MCP issue search, this repo, closed included, at 2026-09-23T14:52Z: service-analytics filter normalizer implicit array equality reads as IN bypasses comparand shape returned 11 hits. None is this defect: #19757 is the ruling's card, and #5442 is a different flattening on driver-memory's analytics face.

Dedupe words: analytics implicit array reads as IN · filter-normalizer object form bypasses comparand shape face


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    Blocked-by: #19757

    定级 pm:blocked · priority:p2 · bug · domain:services —— analytics 把 { stage: ['won','lost'] } 读成 IN,与 ruling 乙「对所有驱动一次生效」的拒绝相反;排在 #19757 之后

    Path: dashboards

    Triage: lands in packages/services/service-analytics/src/strategies/filter-normalizer.ts (:1078-1082, 「Implicit equality / array → in」) ⇒ domain:services; rationale: after ruling 乙 (5793368540 on #19757) one authored filter gets two answers — 400 on the data API, a membership result on an analytics face — which is exactly the divergence the ruling removes (〈基本裁决原则〉 one declared contract, one answer); this is executing an existing ruling on one more consumer, not a new contract decision; p2 like #19757; blocked because the fix should route through the equality arm PR #19882 adds to the shared face.

    分诊席(session_01Tw7jnJinGHvoGSi8aFkhPJ),2026-09-23T15:30Z。本席读完了卡面(本卡尚无评论),并读了 #19757 上的裁决 5793368540 与实现报告 5796984894。

    本席的读数(origin/main dabf8d795e)

    判定


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Blocked-by: #19975

    Unlock scan: the old blocker closed, and a new one is derived. domain:services seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T15:58Z


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Unlocked 2026-09-24T16:52Z by the domain:services seat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021): Blocked-by: #19975 resolved. PR #19994 merged 2026-09-24T16:50Z as e8f163fc3a. That PR touched only the read-scope compiler and one read-scope cell of the shared frozen matrix, not this card's caller-where door.

    • Re-derived on origin/main e8f163fc3a. strategies/filter-normalizer.ts:1078-1082 still reads 「Implicit equality / array → in」 and leaf('in', raw.map(comparand)), and the frozen matrix's whereEq cells still read accept. The premise holds.
    • Double check. The only merged PR on this card after its blocked comment is the blocker itself, which declared this door out of its scope.
    • Claimed in the same pass (see the claim), carrying triage's instruction: it is a narrowing, and the first step is to measure the stored object-form filters in the repo and the examples.

    Generated by Claude Code

  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 1
    Session: session_01Evb5jFDZGKQE9KG4jbMfMF
    Branch: claude/issue-19888-analytics-implicit-array
    Worktree: objectstack-issue-19888
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/services/service-analytics/src/strategies/filter-normalizer.ts, region only: the leaf arm reading an implicit-equality array as in, plus the object-form $eq-list path of the caller where door (lowerAnalyticsWhere); src/__tests__/comparand-door-single-source.test.ts (the where* cells only); new test file(s) under packages/services/service-analytics/src/; .changeset/19888-*.md. ⛔ Not read-scope-sql.ts (just landed through #19975). ⛔ No packages/spec: the shared face is ruling 乙's and is already landed. Measurement first: stored object-form filters in the repo and examples. (Stop on breach; explain in the report.)
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate ⇒ default tier)
    Clause-②: no (narrowing)
    Thread-read: 5818417193
    Serial constraints cleared:

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    os-dev-report
    {
    "issue": 19888,
    "status": "done",
    "branch": "claude/issue-19888-analytics-implicit-array",
    "pr": "#20008",
    "session": "session_01Evb5jFDZGKQE9KG4jbMfMF — mode:subagent, so this is the parent seat's harness-stamped id (the container's CLAUDE_CODE_REMOTE_SESSION_ID resolves to it; commits carry Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)",
    "premise_still_valid": true,
    "summary": "Premise confirmed at origin/main e8f163f: filter-normalizer.ts:1078-1082 read 「Implicit equality / array → in」 and the frozen matrix array-row whereEq cell read accept. Measured worse than the card said: { f: { $eq: [a, b] } } compiled to f = a with b dropped, { f: { $eq: [] } } compiled to NO predicate (every row), and the ObjectQL path handed the engine { f: { $in } } so the engine's shared-face seam never saw the list; the FilterArray spelling was already refused 400. Fix: a new gate assertNoListInEqualitySlot, called in lowerAnalyticsWhere's object branch, walks the condition like the shared face (same where path seed, plus nested-relation descent because this compiler flattens those to dotted members) and hands each equality-slot list to assertListComparandShapes as a one-entry node, so the refusal is the face's own INVALID_FILTER / 400, wording, path and $in remedy, byte-identical to the FilterArray spelling; the in arm and its two dependent bare-array arms are deleted. It covers the caller where, dataset scope filter and measure filters on native execute, the /analytics/sql echo and the ObjectQL engine path; the draft-data preview calls the same exported gate (bounded in-place fix, surface supplement owed). In-repo measurement (recorded as empty commit cca9717 before any fix): 0 stored artefacts carry the shape in examples/, packages/ or docs code blocks, so nothing was converted. Changeset is a minor narrowing with !, Clause-② no (narrowing), BREAKING FROM → TO table naming $in, and ADR-0087 disposition not-required (already-registered filter-equality-array-comparand-refused) instead of the dispatched no-migration-prescription (reason under deviations). Assignee untouched; claim 5818433693 names this branch.",
    "tests": "All at HEAD 896e1e7 (git rev-parse --short HEAD) unless stated. pnpm --filter @objectstack/service-analytics test → "Test Files 117 passed (117) / Tests 2541 passed (2541)" (base e8f163f: 116 files / 2484 tests); pnpm --filter @objectstack/service-analytics typecheck → tsc --noEmit exit 0, and tsc --noEmit --listFiles lists all four touched test files. New src/tests/where-equality-slot-list-refusal.test.ts, 59 tests, every refusal asserting code INVALID_FILTER + status 400: $eq list ×9 positions, implicit list ×7 (empty and nested relation included), byte identity ×4 (object = FilterArray = face message), list-before-member ×4, 12 neighbouring shapes unchanged, 4 faces (native execute, echo, ObjectQL engine path, draft preview) × 4 spellings over a real sql.js engine with 0 statements and 0 engine.aggregate calls + a control, stored dataset through the service doors ×6. Matrix cell array/whereEq re-judged accept → INVALID_FILTER/400 with a note; two existing pins re-judged (filter-normalizer-not-null-safe bare [] now refused; filter-normalizer-undefined-comparand rows { d: [1, undefined] } and { d: [1, null] } retired with notes). Ablation A (put the in reading back: delete the gate call in lowerAnalyticsWhere via scripts/ablation-replace.mjs --hold, anchor 1 → 0, blob 86a47d68b027 → e04c9b5afb15; restore the old bare-array arm in fieldLeaves via ablation-replace WRAP, anchor 1 → 0, blob → 338021f0dfa3; on-disk gate-call-count=0, in-arm-count=1). Predicted 41 red before running; measured "Tests 41 failed | 161 passed (202)" over the four touched test files: $eq 9, implicit 7, byte identity 4, list-first 4, faces 12 (preview cells stayed green: it runs the gate itself), stored 3, matrix 1, bare [] 1. Sample: "AssertionError: native execute: expected a refusal, got rows: expected [ 'd1', 'd2' ] to be undefined"; "AssertionError: expected 'accept' to be 'INVALID_FILTER/400'". Restore: "ok restored: blob == HEAD (86a47d68b027) and git diff HEAD is empty". Ablation B (delete the preview gate call, anchor 1 → 0, blob 711d7c256d6f → 378947ae8a7c, grep count 0). Predicted 6; measured "Tests 6 failed | 53 passed (59)": the 4 preview face cells + 2 stored preview-door cells. Sample: "AssertionError: draft preview: expected a refusal, got rows: expected [ 'd5' ] to be undefined". Restore proven blob == HEAD (711d7c256d6f), git diff HEAD empty. No build/dist is on the path: every test imports the subject by relative path to src. Lint narrowed: eslint --no-inline-config --format json over the 6 touched TS files → 6 files, 0 errors, 0 warnings (eslint exit 0); eslint --print-config resolves a config for each (exit 0 ×6); eslint.config.mjs never enables type-aware linting (its note near line 327), so no untouched file's verdict can move. Gates: 61 run (60 derived by dispatch-gates at 896e1e7 + the dispatch-time list's check:dispatcher-error-vocabulary), 59 exit 0, 2 NOT MEASURED (exit 3, PREREQUISITE NOT MET: whole-workspace build); dispatch-gates --ran → "60 derived, 58 run, 2 NOT-MEASURED, 0 UNRUN"; GITHUB_TOKEN=$GH_TOKEN node scripts/check-issue-citations.mjs (board-probing) exit 0 → "19 resolves". Cross-package consumers: 19 dependents via pnpm --filter '...@objectstack/service-analytics'; the AST scan found no shape-carrying filter in their sources or tests; their suites were not run locally (CI affected set).",
    "mcp_calls": "0 — no MCP tool was called (reads went through REST GETs with the session token: issue 19888 + comments, the PR list/readback for #20008).",
    "api_writes": "2 — two fleet-write relay strokes, each a POST /repos/objectstack-ai/objectstack/dispatches from this container (HTTP 204), executed by the fleet-write workflow as objectstack-fleet[bot]: (1) POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft forced) → PR #20008, run 36036960362 success; (2) POST /repos//issues/19888/comments (this os-dev-report). Labels: 0 writes (the dispatch named no label and skip-changeset does not apply: the diff publishes and carries a changeset). git push is not REST: 6 pushes to claude/issue-19888-analytics-implicit-array (empty branch probe, measurement commit, 4 WIP/fix commits). PR body written once at creation, read back byte-identical (15183 bytes).",
    "open_questions": [
    {
    "question": "The registered ADR-0087 entry filter-equality-array-comparand-refused (packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts and its copy in packages/spec/src/migrations/registry.ts) says the analytics normalizer's OBJECT form "still reads as membership". This PR makes that sentence false. The entry is unreleased (the 19757 changeset is still pending), but release notes and the upgrade guide are compiled from it. Who edits it?",
    "options": [
    "A — the seat authorizes a packages/spec touch on this PR: one-sentence edit of both copies plus the regenerated upgrade-guide artifacts (check:spec-changes / check:upgrade-guide).",
    "B — a separate spec-lane follow-up that lands before the next release.",
    "C — leave it, reading the sentence as a record of what was measured at #19757's release."
    ],
    "recommendation": "B, because the dispatch fenced packages/spec off this PR and a registry edit drags generated artifacts and a second gate family into a services PR; C is wrong because the release that ships both changes would publish a false sentence."
    },
    {
    "question": "The changeset carries not-required (already-registered filter-equality-array-comparand-refused) instead of the dispatched not-required (no-migration-prescription). Accept?",
    "options": [
    "A — accept: stored dataset / widget / measure filters CAN carry the shape (DatasetSchema admits it, measured), so the changeset ships a FROM → TO table; the transition is already on the ledger from 19757 and its surface and prescription cover this door verbatim; the same disposition PR 19374 used for the same situation; check-adr-0087-registration passes.",
    "B — switch to no-migration-prescription and remove the FROM → TO table so the prescription detector does not refuse it."
    ],
    "recommendation": "A, because B would claim no author has to rewrite anything while a stored filter with this shape now answers 400, and it would strip the migration an upgrading author greps for."
    }
    ],
    "out_of_scope_findings": [
    "class: b · The analytics object-form where door runs none of the shared comparand-shape face's OTHER arms, while the FilterArray spelling of the same condition on the same door runs all of them (via parseFilterAST). Measured at 896e1e7 through normalizeAnalyticsFilterTree: { d: { $in: [1, null] } } → leaf in [1, null] vs ['d','in',[1,null]] → INVALID_FILTER/400 null list member; { d: { $gt: null } } → gt [null] vs ['d','>',null] → 400 null comparand; { d: { $between: [null, 5] } } and ['', 5] → gte/lte leaves vs 400; { d: { $in: 'x' } } → in ['x'] vs 400 requires an ARRAY. Contract: the face header — "the one place that decides whether $in / $nin / $between received a list at all, for every driver" — plus the 2026-08-31, 2026-09-01 and 2026-09-20 rulings on that face. This package's own pins hold several object-form answers ({$in:[null]}, {$gt:null} in filter-normalizer-undefined-comparand and filter-value-type-fidelity), so each arm needs a per-ruling decision about this door. Seam: spec:assertListComparandShapes → runtime:service-analytics lowerAnalyticsWhere (object branch). Dedupe words: analytics where object form bypasses comparand-shape face · analytics $in null member accepted FilterArray refused · analytics $gt null object form · analytics $between blank bound object form",
    "class: a · $ne with a list on the analytics where door silently drops every member after the first. Measured at 896e1e7 over sql.js: { stage: { $ne: ['won', 'lost'] } } → native/echo SQL (stage IS NULL OR stage != ?) bound to 'won' → rows include d2 'lost'; the ObjectQL engine path receives { $or: [{ stage: null }, { stage: { $ne: 'won' } }] }; the draft preview string-compares against 'won,lost'. Not ruling 乙's; read-scope-sql.ts records $ne with a list as 19886's ruling A carried on that card, so the seat should confirm whether that card covers this door. Dedupe words: analytics $ne list drops members · filter-normalizer notEquals multiple values first only · analytics where $ne array",
    "class: c · The authoring door admits the equality-slot list that every runtime door now refuses: DatasetSchema.safeParse with filter: { stage: ['won', 'lost'] } → success true, and with a measure filter { stage: { $eq: ['won', 'lost'] } } → success true (measured at 896e1e7); FilterConditionSchema.safeParse admits both. An AI writing a dataset / measure / dashboard widget filter or report runtimeFilter (stored, re-authored metadata; named producers: the dataset filter, measure filter, widget filter, report runtimeFilter keys) with the natural "IN" intuition publishes cleanly and gets a 400 at chart time. The 19757 changeset declared this gap ("The schema doors are not touched"). Seam: spec:FilterConditionSchema (FieldOperatorsSchema.$eq z.any, checkFilterConditionComparands skips arrays) → runtime:service-analytics lowerAnalyticsWhere. Dedupe words: FilterConditionSchema admits equality array · dataset filter implicit array publishes then 400 · schema door equality slot list",
    "carrier: 承接者:无 · The draft-data preview does not lower a FilterArray where: ['stage', '=', 'won'] answered no row in the probe while publish lowers and serves it. Reachability through the dataset door (dataset filter and selection.runtimeFilter are object-typed) is not established, so it is noted in Acceptance notes only, not filed."
    ],
    "gates": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-ci-filter-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
    "node scripts/check-registry-log-declared.mjs :: exit 0",
    "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
    "node scripts/check-system-context-census.mjs :: exit 0",
    "node scripts/check-system-context-census.mjs --self-test :: exit 0",
    "node scripts/check-tenant-audit-census.mjs :: exit 0",
    "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
    "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
    "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:cross-package-test-inputs :: exit 0",
    "pnpm check:doc-authoring :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:dts-closure :: exit 0",
    "pnpm check:dual-build-cjs-loads :: exit 3",
    "pnpm check:engine-double-contract :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:issue-citations :: exit 0",
    "pnpm check:lean-entry-closure :: exit 3 then 0 after building the objectql closure",
    "pnpm check:logger-receiver-detach :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:objectql-double-limit :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:org-identifier :: exit 0",
    "pnpm check:page-declaration-shape :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:query-options-erasure :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:slot-lookup :: exit 0",
    "pnpm check:sourcemap-no-sources-content :: exit 0",
    "pnpm check:test-source-alias :: exit 0",
    "pnpm check:tier-file-adoption :: exit 0",
    "pnpm check:type-check-coverage :: exit 0",
    "pnpm check:type-check-debt :: exit 3",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0",
    "pnpm check:dispatcher-error-vocabulary :: exit 0"
    ],
    "files_changed": [
    ".changeset/19888-analytics-implicit-array.md (new)",
    "packages/services/service-analytics/src/strategies/filter-normalizer.ts",
    "packages/services/service-analytics/src/preview-evaluator.ts (bounded in-place fix; claim surface supplement owed)",
    "packages/services/service-analytics/src/tests/where-equality-slot-list-refusal.test.ts (new)",
    "packages/services/service-analytics/src/tests/comparand-door-single-source.test.ts (array row whereEq cell only)",
    "packages/services/service-analytics/src/tests/filter-normalizer-not-null-safe.test.ts (one pin re-judged; surface supplement owed)",
    "packages/services/service-analytics/src/tests/filter-normalizer-undefined-comparand.test.ts (two rows retired with notes; surface supplement owed)"
    ],
    "deviations": [
    "File surface: the claim declared filter-normalizer.ts, the matrix where* cells, new test files and the changeset. Also touched: preview-evaluator.ts (bounded in-place fix, all four conditions hold — same defect class; a mechanical call of the same gate, shape pinned by ruling 乙; no open PR touches the package per the claim's own reading; same gate family) and two existing test files whose three pins asserted the reading ruling 乙 removes. The claim's file surface needs these three paths added by the seat.",
    "ADR-0087 disposition: not-required (already-registered filter-equality-array-comparand-refused) instead of the dispatched not-required (no-migration-prescription); see open_questions[1].",
    "Commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By form; the pre-push hook enforces the model-free pair.",
    "check:lean-entry-closure needed objectql's dependency closure built; built it (pnpm --filter '@objectstack/objectql...' minus the already-built spec/core/types) and re-ran to exit 0.",
    "Dispatch hypothesis 2 falsified in part: beyond "accepts the object form", the $eq list silently drops members and $eq: [] widens to every row; the FilterArray form is confirmed already refused."
    ],
    "in_repo_measurement": "chore(service-analytics): record the stored equality-slot list measurement before the where-door fix\n\nMeasurement only; no source change. Taken on this branch at its base\n(origin/main e8f163f) before any fix.\n\nIn-repo stored filters carrying a list in the equality slot\n-----------------------------------------------------------\nAn AST scan (TypeScript compiler API over every git-tracked .ts/.js/.json\nfile; fenced ts/js/json blocks for md/mdx) with four detectors:\nD1 a $eq whose value is an array literal; D2 a field entry whose value is\nan array literal inside an object under a filter-bearing key (filter,\nfilters, where, runtimeFilter, relatedListFilter, having, baseFilter,\ndefaultFilter, readScope, rowFilter, scopeFilter), walked as a\nFilterCondition ($and/$or/$not and nested-relation objects descended);\nD3 a FilterArray triple [field, =|==|equals|eq, [...]]; D4 a filter rule\n{ field, operator: EQUALITY_SPELLING, value: [...] }.\nPositive controls on the same walkers: a synthetic fixture (TS and JSON)\ncarrying one of each hits 4/4 in both; C1 counts $in array literals;\nC2 counts filter-bearing objects walked.\n\n- examples/: 228 files (3 tsconfig JSONC unparsed). C1 = 1 (matches the\n text grep: two $in in shipping sources, one a literal), C2 = 91.\n Hits: 0. Every dataset, dashboard widget, report runtimeFilter and\n measure filter in app-crm, app-showcase and app-todo uses scalars,\n $ne, $nin, $gte/$lt or date tokens.\n- packages/: 6910 files, C1 = 522, C2 = 4636. Non-test hits: 5, all\n false positives (2 realtime-subscription eventTypes lists in\n packages/client/src/realtime-api.ts; 3 MongoDB aggregation-EXPRESSION\n $eq: [a, b] operands in driver-memory memory-analytics.ts and\n driver-mongodb mongodb-aggregation.ts). Test/fixture hits: 65, all\n deliberate refusal fixtures or pins.\n- md/mdx fenced blocks (content, skills, docs, examples, packages,\n .changeset): 1545 files, 3162 blocks, C1 = 12, C2 = 176. Hits: 3, all\n false positives (a { field, operator: 'nin', value } rule in a design\n note; a plugin-permission filter in packages/core PHASE2 notes).\n- Text grep for a $eq list outside packages/ and examples/: 7 hits, all\n in changesets describing the refusal itself (19757, 19886, 19975);\n control $in list: 140.\n- Deployed sys_metadata rows: NOT MEASURED (no deployment data here).\n\nConclusion: no in-repo artefact carries the shape; nothing to convert.\n\nThe analytics faces, three spellings, measured at base\n-------------------------------------------------------\nProbe over a real sql.js engine (SqliteWasmDriver), rows d1 won, d2 lost,\nd3 open, d4 NULL, d5 'won,lost', d6 '["won","lost"]':\n\n- { stage: ['won','lost'] }: native execute and the /analytics/sql echo\n compile stage IN (?, ?) -> d1,d2; the ObjectQL engine path hands the\n engine { stage: { $in: [...] } }, so the engine's shared-face seam never\n sees the array; the draft preview string-compares -> d5.\n- { stage: { $eq: ['won','lost'] } }: stage = ? bound to 'won' only ->\n d1; the second member is dropped in silence; engine path gets\n { stage: 'won' }; preview -> d5.\n- { stage: { $eq: [] } }: no WHERE at all -> EVERY row (native, echo,\n engine {}); preview -> none.\n- { stage: [] }: the FALSE constant -> no row.\n- FilterArray ['stage', '=' | 'equals' | '==' | 'eq', [...]]: refused\n INVALID_FILTER / 400 on the tree, native, echo and engine faces (it is\n lowered through parseFilterAST, which runs the shared face).\n- CONTROL { stage: { $in: ['won','lost'] } }: d1,d2 on every face.",
    "labels": "0 writes — the dispatch named no label; skip-changeset does not apply (publishing diff with a changeset).",
    "cleanup": "Done before this report: rm -rf ../objectstack-issue-19888/node_modules && git worktree remove ../objectstack-issue-19888 (exit 0, no --force); working tree was clean and origin/claude/issue-19888-analytics-implicit-array = 896e1e7 at removal. No dev server, background job or monitor was started.",
    "pr_body_new": "Fixes #19888\n\nClause-②: no (narrowing)\n\n## What this changes\n\nThe analytics where door (lowerAnalyticsWhere in packages/services/service-analytics/src/strategies/filter-normalizer.ts) now refuses a list in the equality slot of an object-form filter, { f: [...] } and { f: { $eq: [...] } }, with INVALID_FILTER / 400. This applies ruling 乙 of #19757 (record 5793368540: 「an array in the implicit-equality slot is refused at the shared face, for every driver at once」) to the one analytics spelling that never reached the shared face.\n\nThe new gate, assertNoListInEqualitySlot, walks the object-form condition the way the shared face does: $and / $or, $not and field entries, from the same where path seed. It hands each equality-slot list to assertListComparandShapes (@objectstack/spec/data) as a one-entry node. That node holds nothing but the list, so only the face's equality arm can fire, and the refusal is the face's own: its envelope, wording, path and $in prescription. The FilterArray spelling of the same condition was already refused inside parseFilterAST. Both spellings now produce the same bytes, and a test pins this.\n\nEvery filter source of this door passes through the gate: the caller where, a dataset's scope filter and a measure's filter. That holds on the NativeSQL execute path, the /analytics/sql echo and the ObjectQL engine path. The draft-data preview (preview-evaluator.ts) calls the same exported gate, so a drafted chart refuses what the published chart refuses. The in reading is deleted from fieldLeaves. So are the two arms that existed only because of it: the bare-array member sweep in assertDefinedComparands and the bare-array guard in nullGuardForFieldSpec.\n\nTwo details:\n\n- Nested relations. A nested-relation object ({ acct: { region: [...] } }) is descended too. The shared face leaves such an object alone, because a driver reads it as a deep-equality comparand or another object's condition. This compiler flattens it to the dotted member acct.region, and the list is in that member's equality slot.\n- Precedence. The list is diagnosed before any member gate. { d: [1, undefined] } gets the list refusal, not the undefined-comparand one. The shared face and read-scope-sql.ts use the same order.\n\n$ne with a list is not judged, because ruling 乙 names equality only. The list operators keep their lists, including $in: [] and $nin: []. Every scalar compiles as before, including null.\n\n## Measured first (recorded on this branch as cca9717240, before any source change)\n\nIn-repo stored filters. An AST scan (TypeScript compiler API) covered every git-tracked .ts / .js / .json file, plus the fenced ts/js/json blocks of md/mdx files. It used four detectors:\n\n- D1: $eq with an array literal.\n- D2: a field entry with an array literal under a filter-bearing key (filter, filters, where, runtimeFilter, relatedListFilter, having and five more). The object is walked as a FilterCondition, descending $and / $or / $not and nested relations.\n- D3: a FilterArray triple on = / == / equals / eq carrying an array.\n- D4: a filter rule { field, operator: EQUALITY_SPELLING, value: [...] }.\n\nThe positive controls ran on the same walkers:\n\n- a synthetic fixture with one case per detector (4/4 hits, in both TS and JSON);\n- a count of $in array literals (C1);\n- a count of the filter-bearing objects walked (C2).\n\n| scope | files | C1 | C2 | hits |\n|:--|--:|--:|--:|:--|\n| examples/** | 228 (3 tsconfig JSONC unparsed) | 1 (matches the text grep) | 91 | 0 |\n| packages/** | 6910 | 522 | 4636 | 5 in non-test files, all false positives: 2 realtime-subscription eventTypes lists, 3 MongoDB aggregation-expression $eq: [a, b] operands. 65 in tests, all deliberate refusal fixtures or pins. |\n| md/mdx fenced blocks (content, skills, docs, examples, packages, .changeset) | 1545 files, 3162 blocks | 12 | 176 | 3, all false positives (a nin rule in a design note; a plugin-permission filter) |\n\nA text grep for a $eq list outside packages/ and examples/ found 7 hits, all in changesets that describe the refusal itself. The control ($in list) found 140. No in-repo artefact carries the shape, so nothing needed converting. Deployed sys_metadata rows: NOT MEASURED (there is no deployment data here).\n\nThe analytics faces at base, over a real sql.js engine. The rows are d1 won, d2 lost, d3 open, d4 NULL, d5 the text 'won,lost':\n\n| where | native execute / echo | ObjectQL engine path | draft preview |\n|:--|:--|:--|:--|\n| { stage: ['won', 'lost'] } | stage IN (?, ?), rows d1, d2 | the engine received { stage: { $in: [...] } }, so the engine's own shared-face check never saw the list | string-compared the row against 'won,lost': d5 |\n| { stage: { $eq: ['won', 'lost'] } } | stage = ? bound to 'won': d1, and 'lost' was dropped without a word | { stage: 'won' } | d5 |\n| { stage: { $eq: [] } } | no WHERE at all: every row | {} | no row |\n| { stage: [] } | the FALSE constant | | |\n| ['stage', '=' / 'equals' / '==' / 'eq', [...]] | refused INVALID_FILTER / 400 | refused | no row (the preview does not lower an array) |\n| control { stage: { $in: ['won', 'lost'] } } | d1, d2 | d1, d2 | d1, d2 |\n\nAfter this change, every object-form cell above is refused with INVALID_FILTER / 400, carrying the face's message (re-measured at 896e1e70f3).\n\n## Compile surfaces (a list in the equality slot)\n\n| surface | verdict |\n|:--|:--|\n| lowerAnalyticsWhere / normalizeAnalyticsFilterTree (analytics caller where, dataset scope filter, measure filter; NativeSQL execute, /analytics/sql echo, ObjectQL engine path) | changed. Both spellings are refused with INVALID_FILTER / 400 at any depth, measured over sql.js before and after. |\n| evaluateAnalyticsQueryOverRows / matchesWhere (analytics draft-data preview) | changed, as a bounded in-place fix (see Deviations). It used to string-compare the row against the list and now runs the same gate. |\n| assertListComparandShapes (spec shared face) | already compliant. This is ruling 乙's own face (#19882). This PR calls it and does not change it. |\n| compileScopedFilterToSql (service-analytics read scope) | already compliant. Since #19975 (landed e8f163fc3a) it refuses both spellings with READ_SCOPE_COMPILE_FAILED / 500. Its matrix cells and read-scope-eq-array-refusal.test.ts are green in this PR's package run. Not touched. |\n| matchesFilterCondition (formula) | already compliant, per the table in PR #19994 (400 for the implicit list, $eq and $not). Not re-measured here. |\n| applyFilterCondition (driver-sql) | already compliant, per the table in the #19882 changeset. Not re-measured here. |\n| buildWhereSQL (driver-turso RemoteTransport) | already compliant, per the table in PR #19994. Not re-measured here. |\n| checkCondition (driver-memory) | already compliant: 400 at every depth, per the table in the #19882 changeset. Not re-measured here. |\n| translateFieldOperators (driver-mongodb) | out of scope. The driver answers with MongoDB array equality. A platform door reaches it only through the shared face, which refuses the list (the declared scope of #19882). |\n| applyHaving / matchesHaving / checkCondition (objectql HAVING) | out of scope. This is a caller-authored filter over aggregated rows, a different door from this card's. Its answers are recorded in the #19886 stage-2a report. |\n\n## Tests and evidence (head 896e1e70f3)\n\n- New src/__tests__/where-equality-slot-list-refusal.test.ts, 59 tests. Every refusal asserts code + status.\n - The $eq list at 9 positions: every depth, the empty list, and beside another operator in either key order.\n - The implicit list at 7 positions, including the empty list and a nested relation.\n - Byte identity (4): the object spelling equals the FilterArray spelling, which equals the face's own message.\n - List before member (4).\n - 12 neighbouring shapes that must compile as before: a scalar, a Date, the null predicate, the $in remedy, the $in: [] / $nin: [] constants, a nested scalar, a $field reference, $between, and $ne (not judged).\n - Four faces over a real sql.js engine (native execute, echo, ObjectQL engine path, draft preview) × 4 spellings, plus a control. Each is refused before any statement runs and before any engine.aggregate call.\n - A stored dataset through the service doors (6): the dashboard door and the draft preview, for a scope filter and a measure filter; the registered cube on the ObjectQL door; and a control.\n- comparand-door-single-source.test.ts: the array row's whereEq cell is re-judged from accept to INVALID_FILTER/400, with a note. It had pinned qty = 'al' with 'be' dropped.\n- Two existing pins re-judged, not rewritten by rote:\n - filter-normalizer-not-null-safe.test.ts: the bare [] is now refused, and $in: [] keeps its FALSE constant.\n - filter-normalizer-undefined-comparand.test.ts: the { d: [1, undefined] } row leaves the undefined table, and the { d: [1, null] } row leaves the null control group. Each carries a note: its enclosing shape is now refused whole.\n- Package run. pnpm --filter @objectstack/service-analytics test: 117 files and 2541 tests passed (base: 116 files, 2484 tests). typecheck exited 0, and tsc --listFiles includes all four touched test files.\n- Ablations. Each was run from the committed fix through scripts/ablation-replace.mjs, with the red/green count predicted before running. The tests import the source by relative path, so no build is on the path. Each restore was proven by the blob hash matching HEAD and by an empty git diff HEAD.\n - A: the in reading put back. The gate call in lowerAnalyticsWhere was deleted and the old bare-array arm restored in fieldLeaves. Predicted 41 red; measured 41 red / 161 green over the four touched test files:\n - 9 $eq, 7 implicit, 4 byte identity, 4 list-first;\n - 12 faces: native, echo and engine × 4 spellings. The preview cells stayed green, because the preview runs the gate itself;\n - 3 stored, 1 matrix array where row, 1 bare [].\n - Sample failures: native execute: expected a refusal, got rows: expected [ 'd1', 'd2' ] to be undefined and expected 'accept' to be 'INVALID_FILTER/400'.\n - B: the preview's gate call deleted. Predicted 6; measured 6 red / 53 green: the four preview face cells and the two stored-dataset preview-door cells. Sample: draft preview: expected a refusal, got rows: expected [ 'd5' ] to be undefined.\n- Gates. dispatch-gates derived 60 at 896e1e70f3. The run also covered the dispatch-time list's check:dispatcher-error-vocabulary, for 61 in total. 59 exited 0.\n - NOT MEASURED (2): check:dual-build-cjs-loads and check:type-check-debt exited 3 (PREREQUISITE NOT MET). They need the whole workspace built, which CI does.\n - check:lean-entry-closure exited 3 until objectql's closure was built, then 0.\n - --ran reconciliation: 60 derived, 58 run, 2 NOT-MEASURED (derived from the recorded exit 3), 0 unrun.\n - Among the passes: check-adr-0087-registration --base origin/main (1 declared-breaking changeset, not-required (already-registered)), check-changeset-no-major, check:changeset-gate-self-tests, check:nul-bytes, and check-issue-citations in its board-probing mode (19 citations, all of which resolve).\n- Lint, narrowed to the change. eslint --no-inline-config --format json over the six touched TypeScript files: 6 files, 0 errors, 0 warnings. eslint --print-config resolves a config for each of them. eslint.config.mjs never enables type-aware linting (its note near line 327), so this diff cannot change the verdict on any untouched file.\n- Dependents. pnpm --filter '...@objectstack/service-analytics' names 19 downstream packages. The AST scan above found no filter carrying the shape in their sources or tests. Their suites were not run here; CI's affected set runs them.\n\n## Deviations from the dispatch, stated\n\n1. File surface. The claim declared filter-normalizer.ts, the matrix's where* cells, new test files and the changeset. This PR also touches three more files:\n - src/preview-evaluator.ts: one import, one call and comments. This is a bounded in-place fix, and all four conditions hold: it is the same defect class; it is a mechanical call of the same gate, whose shape ruling 乙 pins; no open PR touches this package, per the claim's own reading; and it is the same gate family, with no new verification surface. The claim's file surface needs this path added.\n - filter-normalizer-not-null-safe.test.ts and filter-normalizer-undefined-comparand.test.ts: three pins asserted the reading this ruling removes. They are re-judged, with notes (above).\n2. The changeset's ADR-0087 disposition is not-required (already-registered filter-equality-array-comparand-refused), not the dispatched not-required (no-migration-prescription). A stored dataset, widget or measure filter can carry this shape, because the authoring schema admits it (measured). So the changeset carries a FROM → TO table. The gate refuses no-migration-prescription on a body that carries one, and that disposition would also claim that no author has to rewrite anything. The transition itself has been on the ledger since #19757, and that entry's surface and prescription cover this door verbatim. PR #19374 used the same disposition for the same situation. The gate accepts it.\n\n## Acceptance notes\n\n- A registry sentence this PR makes false. The registered entry filter-equality-array-comparand-refused (packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts, and its copy in registry.ts) says that the analytics normalizer's OBJECT form "still reads as membership". That is no longer true. Editing it is a packages/spec change, outside this dispatch, so the carrier is the seat's call.\n- $ne with a list is not judged (ruling A of #19886). Measured at 896e1e70f3: { stage: { $ne: ['won', 'lost'] } } compiles to stage IS NULL OR stage != 'won', so 'lost' rows are served. Reported to the seat.\n- The shared face's other arms. The object-form door still runs none of them: the null list member, the null ordering comparand, the null or blank $between bound, and the scalar $in. The FilterArray spelling of each is refused on the same door. This package's own pins hold several of the object-form answers. Reported, not addressed.\n- The authoring door. It still admits the list: DatasetSchema with filter: { stage: ['won', 'lost'] } parses. The #19757 changeset declared this. Reported.\n- The draft preview does not lower a FilterArray where. It answered no row for ['stage', '=', 'won'] in the probe. Reachability through the dataset door is not established, so this is an observation only."
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Review — ACCEPT (landing gated on CI) · PR #20008 · head 896e1e70f3 · domain:services seat · 2026-09-24T18:02Z

    Seat session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub and origin/main (61609edf81), ⛔ not against the report's own account.

    Checklist

    • PR shape. Draft, targeting main. The first line is Fixes #19888, and Clause-②: no (narrowing) starts its own line. A whole-body scan finds no other closing keyword.
    • Scope. 7 files, +607 / −45, all under packages/services/service-analytics/, plus .changeset/19888-analytics-implicit-array.md (@objectstack/service-analytics minor). No packages/spec, and read-scope-sql.ts is untouched.
    • Diff, read line by line.
      • assertNoListInEqualitySlot walks $and / $or / $not and field entries with the face's own path seed. It hands each equality-slot list to assertListComparandShapes as a one-entry node, so only the face's equality arm can fire. The envelope, wording and $in prescription are the face's, byte for byte.
      • A nested-relation object is descended, because fieldLeaves flattens it to a dotted member whose equality slot the list sits in.
      • lowerAnalyticsWhere runs the gate on the object spelling. The FilterArray spelling already met the face inside parseFilterAST.
      • The old in arm in fieldLeaves, its bare-array nullGuardForFieldSpec arm and the bare-array member sweep in assertDefinedComparands are all removed. Each can no longer be reached, because the gate runs first.
      • $ne and the list operators are untouched, as the ruling's letter requires.
    • Tests. 59 new cases on a real sql.js engine assert INVALID_FILTER / 400. They cover $eq ×9, implicit ×7, byte identity ×4, list-before-member ×4, 12 neighbouring shapes, 4 faces × 4 spellings with 0 statements and 0 engine.aggregate calls, and the stored-dataset doors ×6. Ablation A puts the in reading back: 41 red. Ablation B removes the preview gate: 6 red. Both restores are proven blob-identical. The package run is 2541 passed (base 2484).
    • Changeset prose. I read it against the diff, and every claim matches, including the four-way compile table and the 「Who is affected」 limits. The narrowing shape holds: minor, !, a BREAKING paragraph, and a FROM → TO table.

    Open questions, answered (both fall in the no-escalation class):

    1. Registry sentence: B. A domain:spec follow-up must land before the next release. It is recorded on [finding] FilterConditionSchema still PARSES { field: [...] } and { field: { $eq: [...] } }, so a stored dataset or widget filter publishes clean and is refused at query time on every backend #19889, the spec-lane card for the same door, with both copies' file:line on origin/main. This lane holds zero packages/spec, and C would publish a false sentence.
    2. ADR-0087 disposition: A (accepted). The marker is not-required (already-registered filter-equality-array-comparand-refused). Stored dataset, measure and widget filters can carry the shape (measured), so the FROM → TO table is owed. The transition is already on the ledger with a prescription that covers this door verbatim. The dispatch's no-migration-prescription was the seat's error.

    Accepted deviation: surface breach. Three paths were added:

    • packages/services/service-analytics/src/preview-evaluator.ts, a bounded in-place fix calling the same gate, so a drafted chart refuses what publish refuses;
    • src/__tests__/filter-normalizer-not-null-safe.test.ts;
    • src/__tests__/filter-normalizer-undefined-comparand.test.ts.

    The two test files' three pins asserted the reading ruling 乙 removes. The claim's file surface is revised to include all three (this line is the revision).

    Out-of-scope findings, one line each:

    Landing. No Clause-② queue-gate limb is hit: no packages/spec/src, and the declaration is a narrowing whose changeset shape I checked above. Per the maintainer's 2026-09-10 tier ruling, the seat's review covers the changeset prose. The PR waits on every check going green, then goes ready and auto-merge.


    Generated by Claude Code

  7. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Landed · domain:services seat · session_01Evb5jFDZGKQE9KG4jbMfMF · 2026-09-24T18:25Z


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions