Repository navigation
[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
Activity
objectstack-fleet commented
on Sep 23, 2026 ContributorMore actionsBlocked-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 乙 (5793368540on #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/maindabf8d795e)filter-normalizer.ts:1078注释「Implicit equality / array → in」,:1082leaf('in', raw.map(comparand)),与卡面一致。- 实现报告的前后测量:改动前 service-analytics 把两种写法都读成
IN;PR fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882 之后,它的FilterArray写法被拒,对象写法仍然答出结果。
判定
- 为什么不进决策箱:裁决 乙 的原则是「一个声明的契约,一个答案」,并否决了 甲(给数组定义一种相等语义)。analytics 把数组读成
IN,正是给这个位置私下定义了一种语义。⇒ 本卡是在又一个使用方上执行已有的裁决,⛔ 不是新的契约决定。 p2,与 [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757 同级。⚠️ 这是收窄:今天依赖 analytics 这种读法的已存看板或数据集,改后会拿到 400。接手人照 [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757 的做法处理:changeset 按收窄写,补 ADR-0087 条目,拒绝提示给出$in的写法。先量一下仓库与示例里有没有已存的这种写法。Blocked-by: #19757(本条第一行,供解锁扫描):应当让这条路径走 PR fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882 在共享层新加的相等位拒绝,而不是再写一套。fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882 落地、[finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — anddriver-mongodbalone answers it, as an exact-array match #19757 关闭后再做。
Generated by Claude Code
- addedbugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3
on Sep 23, 2026 objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsBlocked-by: #19975
Unlock scan: the old blocker closed, and a new one is derived.
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021) · 2026-09-24T15:58Z- The previous blocker, [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — and
driver-mongodbalone answers it, as an exact-array match #19757, closed 2026-09-24T14:44Z through PR fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882, which added ruling 乙's equality arm to the shared face. - Re-derived on
origin/mainccccdcc35e, ⛔ not released on reflex.strategies/filter-normalizer.ts:1078-1082still reads 「Implicit equality / array → in」 andleaf('in', raw.map(comparand)). The read-scope-sql compiles$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975 dev also measured the analytics caller-wheredoor still accepting the object form{ f: { $eq: [list] } }(frozen matrix cellwhereEq= accept, on PR fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it #19994's head). The defect stands. - New serial constraint (the line above). PR fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it #19994 (read-scope-sql compiles
$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975, in flight in this lane, same package) editssrc/__tests__/comparand-door-single-source.test.ts, the frozen comparand matrix whosewhereEq/ implicit-array cells this card's fix must re-judge. Two PRs re-judging one frozen matrix is a textual and semantic collision. ⇒ this card goes after read-scope-sql compiles$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975. It returns topm:queueon the unlock scan when read-scope-sql compiles$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975 closes. - Carried forward for whoever takes this card: triage's note that this is a narrowing (
Clause-②: no (narrowing), minor,!, an ADR-0087 marker, and the remedy naming$in), and that the first step is to measure stored dashboards and datasets in the repo and examples that use the object form.
Generated by Claude Code
- The previous blocker, [finding] the comparand-SHAPE face declares it closes the door "for every driver at once", but an array in the IMPLICIT-EQUALITY slot passes it — and
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsUnlocked 2026-09-24T16:52Z by the
domain:servicesseat (session_01Evb5jFDZGKQE9KG4jbMfMF, seat post #6021):Blocked-by: #19975resolved. PR #19994 merged 2026-09-24T16:50Z ase8f163fc3a. That PR touched only the read-scope compiler and one read-scope cell of the shared frozen matrix, not this card's caller-wheredoor.- Re-derived on
origin/maine8f163fc3a.strategies/filter-normalizer.ts:1078-1082still reads 「Implicit equality / array → in」 andleaf('in', raw.map(comparand)), and the frozen matrix'swhereEqcells 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
- Re-derived on
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsClaim: 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 asin, plus the object-form$eq-list path of the callerwheredoor (lowerAnalyticsWhere);src/__tests__/comparand-door-single-source.test.ts(thewhere*cells only); new test file(s) underpackages/services/service-analytics/src/;.changeset/19888-*.md. ⛔ Notread-scope-sql.ts(just landed through #19975). ⛔ Nopackages/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:- PR fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it #19994 (read-scope-sql compiles
$eq: [...]in a policy scope ascol = ?with the array bound (read-scope-sql.ts:1273) — outside ruling 乙's shared face; a bare array fails closed as 500, not 400 #19975, the same package and the same frozen matrix file) landede8f163fc3a. - No open PR in any lane touches
packages/services/service-analytics/: the open-PR file intersection taken earlier this round had no hit, and fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it #19994 was the only one since. - security: the analytics ObjectQL execute face answers a row-level read scope it cannot run with
INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 (the analytics ObjectQL execute face's 400 envelope, same package) is filed but not dispatched.
- PR fix(service-analytics): the read-scope compiler refuses a list under $eq instead of binding it #19994 (read-scope-sql compiles
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsos-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 (samewherepath 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; theinarm 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, andtsc --noEmit --listFileslists 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) andgit diff HEADis 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 jsonover the 6 touched TS files → 6 files, 0 errors, 0 warnings (eslint exit 0);eslint --print-configresolves 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 viapnpm --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-formwheredoor 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 ·$newith a list on the analyticswheredoor 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$newith 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 datasetfilter, measurefilter, widgetfilter, reportruntimeFilterkeys) 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 FilterArraywhere: ['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$eqwhose 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$inarray literals;\nC2 counts filter-bearing objects walked.\n\n- examples/: 228 files (3 tsconfig JSONC unparsed). C1 = 1 (matches the\n text grep: two$inin 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/$ltor date tokens.\n- packages/: 6910 files, C1 = 522, C2 = 4636. Non-test hits: 5, all\n false positives (2 realtime-subscriptioneventTypeslists 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-permissionfilterin packages/core PHASE2 notes).\n- Text grep for a$eqlist outside packages/ and examples/: 7 hits, all\n in changesets describing the refusal itself (19757, 19886, 19975);\n control$inlist: 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 compilestage 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 analyticswheredoor (lowerAnalyticsWhereinpackages/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: [...] } }, withINVALID_FILTER/ 400. This applies ruling 乙 of #19757 (record5793368540: 「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,$notand field entries, from the samewherepath seed. It hands each equality-slot list toassertListComparandShapes(@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$inprescription. TheFilterArrayspelling of the same condition was already refused insideparseFilterAST. Both spellings now produce the same bytes, and a test pins this.\n\nEvery filter source of this door passes through the gate: the callerwhere, a dataset's scopefilterand a measure'sfilter. That holds on the NativeSQL execute path, the/analytics/sqlecho 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. Theinreading is deleted fromfieldLeaves. So are the two arms that existed only because of it: the bare-array member sweep inassertDefinedComparandsand the bare-array guard innullGuardForFieldSpec.\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 memberacct.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 andread-scope-sql.tsuse the same order.\n\n$newith 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, includingnull.\n\n## Measured first (recorded on this branch ascca9717240, before any source change)\n\nIn-repo stored filters. An AST scan (TypeScript compiler API) covered every git-tracked.ts/.js/.jsonfile, plus the fenced ts/js/json blocks of md/mdx files. It used four detectors:\n\n- D1:$eqwith an array literal.\n- D2: a field entry with an array literal under a filter-bearing key (filter,filters,where,runtimeFilter,relatedListFilter,havingand five more). The object is walked as a FilterCondition, descending$and/$or/$notand nested relations.\n- D3: a FilterArray triple on=/==/equals/eqcarrying 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$inarray 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-subscriptioneventTypeslists, 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 (aninrule in a design note; a plugin-permissionfilter) |\n\nA text grep for a$eqlist outsidepackages/andexamples/found 7 hits, all in changesets that describe the refusal itself. The control ($inlist) found 140. No in-repo artefact carries the shape, so nothing needed converting. Deployedsys_metadatarows: NOT MEASURED (there is no deployment data here).\n\nThe analytics faces at base, over a real sql.js engine. The rows are d1won, d2lost, d3open, 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', [...]]| refusedINVALID_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 withINVALID_FILTER/ 400, carrying the face's message (re-measured at896e1e70f3).\n\n## Compile surfaces (a list in the equality slot)\n\n| surface | verdict |\n|:--|:--|\n|lowerAnalyticsWhere/normalizeAnalyticsFilterTree(analytics callerwhere, dataset scopefilter, measurefilter; NativeSQL execute,/analytics/sqlecho, ObjectQL engine path) | changed. Both spellings are refused withINVALID_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 (landede8f163fc3a) it refuses both spellings withREAD_SCOPE_COMPILE_FAILED/ 500. Its matrix cells andread-scope-eq-array-refusal.test.tsare 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,$eqand$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 (head896e1e70f3)\n\n- Newsrc/__tests__/where-equality-slot-list-refusal.test.ts, 59 tests. Every refusal assertscode+status.\n - The$eqlist 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, aDate, the null predicate, the$inremedy, the$in: []/$nin: []constants, a nested scalar, a$fieldreference,$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 anyengine.aggregatecall.\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: thearrayrow'swhereEqcell is re-judged fromaccepttoINVALID_FILTER/400, with a note. It had pinnedqty = '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).typecheckexited 0, andtsc --listFilesincludes all four touched test files.\n- Ablations. Each was run from the committed fix throughscripts/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 emptygit diff HEAD.\n - A: theinreading put back. The gate call inlowerAnalyticsWherewas deleted and the old bare-array arm restored infieldLeaves. 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 matrixarraywhere row, 1 bare[].\n - Sample failures:native execute: expected a refusal, got rows: expected [ 'd1', 'd2' ] to be undefinedandexpected '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-gatesderived 60 at896e1e70f3. The run also covered the dispatch-time list'scheck:dispatcher-error-vocabulary, for 61 in total. 59 exited 0.\n - NOT MEASURED (2):check:dual-build-cjs-loadsandcheck:type-check-debtexited 3 (PREREQUISITE NOT MET). They need the whole workspace built, which CI does.\n -check:lean-entry-closureexited 3 until objectql's closure was built, then 0.\n ---ranreconciliation: 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, andcheck-issue-citationsin its board-probing mode (19 citations, all of which resolve).\n- Lint, narrowed to the change.eslint --no-inline-config --format jsonover the six touched TypeScript files: 6 files, 0 errors, 0 warnings.eslint --print-configresolves a config for each of them.eslint.config.mjsnever 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 declaredfilter-normalizer.ts, the matrix'swhere*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.tsandfilter-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 isnot-required (already-registered filter-equality-array-comparand-refused), not the dispatchednot-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 refusesno-migration-prescriptionon 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 entryfilter-equality-array-comparand-refused(packages/spec/src/migrations/entries/semantic/18.filter-equality-array-comparand-refused.ts, and its copy inregistry.ts) says that the analytics normalizer's OBJECT form "still reads as membership". That is no longer true. Editing it is apackages/specchange, outside this dispatch, so the carrier is the seat's call.\n-$newith a list is not judged (ruling A of #19886). Measured at896e1e70f3:{ stage: { $ne: ['won', 'lost'] } }compiles tostage 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$betweenbound, 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:DatasetSchemawithfilter: { stage: ['won', 'lost'] }parses. The #19757 changeset declared this. Reported.\n- The draft preview does not lower a FilterArraywhere. 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
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsReview — ACCEPT (landing gated on CI) · PR #20008 · head
896e1e70f3·domain:servicesseat · 2026-09-24T18:02ZSeat
session_01Evb5jFDZGKQE9KG4jbMfMF. Checked against GitHub andorigin/main(61609edf81), ⛔ not against the report's own account.Checklist
- PR shape. Draft, targeting
main. The first line isFixes #19888, andClause-②: 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-analyticsminor). Nopackages/spec, andread-scope-sql.tsis untouched. - Diff, read line by line.
assertNoListInEqualitySlotwalks$and/$or/$notand field entries with the face's own path seed. It hands each equality-slot list toassertListComparandShapesas a one-entry node, so only the face's equality arm can fire. The envelope, wording and$inprescription are the face's, byte for byte.- A nested-relation object is descended, because
fieldLeavesflattens it to a dotted member whose equality slot the list sits in. lowerAnalyticsWhereruns the gate on the object spelling. TheFilterArrayspelling already met the face insideparseFilterAST.- The old
inarm infieldLeaves, its bare-arraynullGuardForFieldSpecarm and the bare-array member sweep inassertDefinedComparandsare all removed. Each can no longer be reached, because the gate runs first. $neand 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 0engine.aggregatecalls, and the stored-dataset doors ×6. Ablation A puts theinreading back: 41 red. Ablation B removes the preview gate: 6 red. Both restores are proven blob-identical. The package run is2541 passed(base2484). - 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):
- Registry sentence: B. A
domain:specfollow-up must land before the next release. It is recorded on [finding]FilterConditionSchemastill 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:lineonorigin/main. This lane holds zeropackages/spec, and C would publish a false sentence. - 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'sno-migration-prescriptionwas 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:
- The object-form door runs none of the face's other arms (class b):
filed #20010. $newith a list silently drops members on this door (class a): carried by service-analytics: object-form analyticswhereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010 (addendum comment), with a pointer on [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886, whose ruling A puts the$nerefusal on the shared face.- The authoring door admits the equality-slot list (class c): a duplicate of [finding]
FilterConditionSchemastill PARSES{ field: [...] }and{ field: { $eq: [...] } }, so a stored dataset or widget filter publishes clean and is refused at query time on every backend #19889, re-measurement recorded there. - The draft preview does not lower a
FilterArraywhere: Acceptance notes. Reachability through the object-typed dataset door is not established.
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
- PR shape. Draft, targeting
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsLanded ·
domain:servicesseat ·session_01Evb5jFDZGKQE9KG4jbMfMF· 2026-09-24T18:25Z- PR fix(service-analytics)!: the analytics
wheredoor refuses a list in the equality slot instead of reading it asIN(#19888) #20008 →14add487b4onorigin/main. Parent count 1 (a queue squash).assertNoListInEqualitySlotis present at that commit infilter-normalizer.tsand inpreview-evaluator.ts. The card closedcompletedthrough the PR's one closing keyword. - Removed in this stroke, with read-back:
pm:dispatchedand the assignee. The claim is discharged. - Carried off this card:
- service-analytics: object-form analytics
whereskips the shared comparand-shape face's other arms ($innull member,$gt: null, null/blank$betweenbound, scalar$in) that the FilterArray spelling refuses 400 #20010: the object-form door's other face arms, plus$newith a list. - [finding]
FilterConditionSchemastill 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 ADR-0087 entry sentence this landing made false. It must be fixed before the next release. - [finding]
$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886: a pointer to the analytics door.
- service-analytics: object-form analytics
- Unlocked by this landing: security: the analytics ObjectQL execute face answers a row-level read scope it cannot run with
INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995 (itsBlocked-by:names this card). The unlock is re-derived on that card.
Generated by Claude Code
- PR fix(service-analytics)!: the analytics
- added 5 commits that reference this issue
on Sep 28, 2026
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-devround on #19757 (PR #19882, ruling 乙5793368540: an array in the equality slot is refused at the shared comparand-shape face) and filed by thedomain:specexecution 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
{ 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」.origin/main1f89ba0d70. The normalizer's leaf arm carries the comment 「Implicit equality / array → in」 and doesif (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
FilterArrayform 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-normalizerleaf 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 shapereturned 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 faceGenerated by Claude Code