Skip to content

fix(service-analytics): a retired filter operator ($regex, $options) in an analytics filter is refused with the retirement's prescription naming $icontains - #29017

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-jebtpj
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-jebtpj

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread
Fixes #28945
Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:分析查询(/analytics/dataset/query 的 runtimeFilter、/analytics/query、/analytics/sql、草稿预览)遇到已退役的 $regex / $options 时,仍答 400 INVALID_FILTER,但文案改成和数据接口一样:说明该操作符已退役、写 Write "$icontains" instead.,再逐字附上退役表里的处方。
  • 为什么改:升级后的仪表盘若还存着 $regex 过滤,原来只看到一长串"支持的操作符",找不到一行就能改好的写法;数据接口早已给出 $icontains,分析接口应当说同一句话。
  • 风险与回滚:只改报错文字,拒收范围、状态码、错误码都不变,patch。新检查放在过滤器归一化的最后一步,已有的拒收都先于它答复,未知操作符仍是原来的通用列表。回滚即还原本 PR。
  • 先看:packages/services/service-analytics/src/strategies/filter-normalizer.ts(retiredFilterOperatorError、assertNoRetiredOperators、normalizeWhereComparands 末尾的调用);src/__tests__/retired-filter-operator-refusal.test.ts;packages/qa/dogfood/test/analytics-retired-filter-operator.dogfood.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/retired-filter-operator-refusal.test.ts,预期 18 个全绿,其中各个分析入口对 $regex 给出同一句 RETIRED 文案。
  • 需要你做的:无,席位落地。

What changes

fieldLeaves in filter-normalizer.ts refused $regex / $options with the right envelope (INVALID_FILTER / 400) but with the generic Unsupported filter operator "$regex" on "name". Supported: … list, because it never read RETIRED_FILTER_OPERATORS. The draft preview answered with its own … is not evaluated by the draft-data preview sentence instead. So the faces disagreed, and none of them named $icontains.

  • retiredFilterOperatorError(op, field, siblings) builds the refusal the way each data-door face builds it: [analytics] Filter operator "OP" on field "FIELD" is RETIRED and is no longer evaluated., then Write "TO" instead., then RETIRED_FILTER_OPERATORS[op].why word for word, then every other retired key of the same constraint ({ $regex, $options } is one mistake with one fix). The lead-in is driver-sql's (the data door on a SQL stack) without its "by this driver". It returns null for a non-retired operator.
  • assertNoRetiredOperators(node) walks the condition with the shared forEachWhereFieldEntry traversal, the same positions the shape, type and flag passes judge: $and / $or members, $not, field entries, and nested relation entries.
  • One call for every face. It is the last pass of normalizeWhereComparands, which lowerAnalyticsWhere runs for the native execute, the /analytics/sql echo and the ObjectQL engine path. The draft preview calls it itself, before its own operator vocabulary. Dataset scope filters and measure filters go through the same door.
  • Last, so no existing refusal moves. A shape, type or non-boolean flag defect anywhere in the same where is still answered first. The only answer that changes is the one that used to fall to the unsupported-operator list. An unknown operator such as $sounds_like keeps the generic list.
  • No path in the lead-in. A client reads at most 500 characters of a refusal (CLIENT_MESSAGE_MAX), and the table's why puts everything an author must write in its opening sentences for that budget (data: a 400 INVALID_FILTER prescription is cut at 500 characters, before the alternative it names, so a caller using a retired filter operator is not told what to write instead #27098). A test pins Write "$icontains" instead., `$contains` for a case-sensitive one and match in application code inside 500 characters.

On the order's "the same message builder". No shared builder exists on main. The data door's five faces (driver-sql, driver-memory, driver-mongodb, driver-turso's remote transport, objectql's having) each compose the lead-in themselves around the shared to / why, and none of those builders is exported from a package service-analytics depends on (a driver dependency is the wrong direction). So this follows the same form from the same table, and no word of the prescription is written in this package. Folding all six into one spec-side builder would be a separate change (spec export, five drivers). It is noted below, not done here.

Tests

Measured at HEAD 49e31fc2 (worktree on claude/project-thread-jebtpj, base 135054eb).

  • src/__tests__/retired-filter-operator-refusal.test.ts (new, 18 tests):
    • The where door: $regex and a dangling $options each give code + status + the lead-in + Write "$icontains" instead. + the table's why verbatim. { $regex, $options } names the other key. The refusal also fires under $not, in an $or beside a TRUE arm, in an $and and on a dotted member, and the client-cap budget holds. Precedence: a type-face defect in the same where still answers first.
    • Every face over a real sql.js engine (native execute, /analytics/sql echo, ObjectQL engine path, AnalyticsService.query native and ObjectQL, AnalyticsService.generateSql, draft preview): each gives the message the door gives, byte for byte, with zero statements and zero engine calls.
    • Data-door parity: engine.find with the same where (marked 'author', as the SecurityPlugin marks a caller's where) refuses with the same tail from Write "$icontains" instead. on.
    • queryDataset: runtimeFilter, runtimeFilter with previewDrafts, the dataset scope filter and a measure filter all give the prescription, and no statement runs.
    • Controls: $icontains is served (['a1','a2']) on every live face. $sounds_like keeps the generic list, and the draft preview keeps its own sentence for it. The FilterArray spelling ['name','regex','acme'] is still refused at isFilterAST.
  • src/__tests__/filter-normalizer-not-null-safe.test.ts: the service-analytics 的第二个 SQL 编译器 filter-normalizer.buildNode 仍带着 #5297 的三条分叉:$not 非 NULL-safe、{$not:{}} 不加 WHERE、$or 的 {} 析取项被丢 #5325 pin "an unknown operator inside a $not still THROWS" used $regex as its example of an unknown operator. It now uses $sounds_like for the generic list and pins $regex beside it as the RETIRED refusal. The pin's intent (fail-closed under $not) is unchanged.
  • packages/qa/dogfood/test/analytics-retired-filter-operator.dogfood.test.ts (new, booted stock showcase): POST /api/v1/analytics/dataset/query { datasetName: "showcase_project_metrics", selection: { measures: ["project_count"], runtimeFilter: { name: { $regex: "acme" } } } }, and the same with $options, answer 400 INVALID_FILTER with the RETIRED lead-in, Write "$icontains" instead. and the table's opening sentence. The data door POST /api/v1/data/showcase_project/query with the same where shows the same tail. Controls: $icontains → 200, $sounds_like → the generic list.
Red first on main: unit suite and the live showcase door

The base blob of filter-normalizer.ts (135054eb) was written over the file, under a trap restore.

  • Unit: Tests 11 failed | 7 passed (18). The 11 failures are every RETIRED case. The live faces answered [analytics] Unsupported filter operator "$regex" on "name". Supported: $eq, $ne, …, and the draft preview answered [analytics] Filter operator "$regex" on field "name" is not evaluated by the draft-data preview. …. The 7 that passed are the controls plus the 500-character case, which passed vacuously on a message that held none of the texts. It was then tightened to require each text to be present (so the ablation below reds it).
  • Live showcase (dogfood file, service-analytics rebuilt from the base blob, ablation-dist-preflight … --absent confirmed the marker gone from all 6 built files): Tests 3 failed | 2 passed (5). $regex and $options got 400 INVALID_FILTER [analytics] Unsupported filter operator …, which is the card's reported answer. The parity case failed, and the two controls passed.

Restore: blob hash equals HEAD's and git diff HEAD is empty. Then a rebuild, and the preflight showed the marker present in 2 built files.

Ablation: dropping the retired-table pass reds only the new cases

node scripts/ablation-replace.mjs --file …/filter-normalizer.ts --anchor ' assertNoRetiredOperators(node, path);\n' --delete -- …: anchor 1 → 0, blob 34fb0eb6 → e59a5023, calls-left: 0. The unit file imports the module from src/ (no dist involved).

  • Tests 12 failed | 6 passed (18). The 12 failures are every RETIRED case. The 6 that passed are the controls ($sounds_like generic list, the FilterArray refusal, the $icontains leaf and rows, the preview's own sentence for an unknown operator, and type-face precedence).
  • Restore: blob after restore 34fb0eb6 == HEAD, git diff HEAD empty.
Package suite, typecheck, dogfood file

All at HEAD 49e31fc2, through scripts/pm/os-verify-lock.sh (VERDICT command-exit 0):

  • pnpm --filter @objectstack/service-analytics build, then ablation-dist-preflight.mjs @objectstack/service-analytics 'is RETIRED and is no longer evaluated.': marker present in 2 built files
  • pnpm --filter @objectstack/service-analytics test: Test Files 205 passed (205), Tests 4907 passed | 282 skipped (5189)
  • pnpm --filter @objectstack/service-analytics typecheck: exit 0 (--listFilesOnly includes both test files)
  • pnpm --filter @objectstack/dogfood exec vitest run test/analytics-retired-filter-operator.dogfood.test.ts: Tests 5 passed (5)
  • pnpm --filter @objectstack/dogfood typecheck: exit 0 (--listFilesOnly includes the new dogfood file)
  • Full dogfood battery: not run (no ! in the title and no (narrowing) arm). The dogfood job does not run on PRs; the new file was run locally above.

Acceptance notes

  • Premise held as stated (triage 6569498837). It was measured on main through the unit faces and the live showcase door.
  • Declared conflict with the order. "The same message builder" assumes a shared builder. There is none (see What changes). The sentence follows the data door's form from the same table, and no prescription text lives here.
  • Observation, not filed: the analytics door serves a refusal message whole, while the data door cuts it at 500 characters with …. Measured on the dogfood showcase: the $regex refusal is 1,068 characters on /analytics/dataset/query and 500 on /data/showcase_project/query. Nothing is wrong in what either door says. Whether analytics should apply CLIENT_MESSAGE_MAX is not this card's question.
  • Not touched: the read scope (read-scope-sql.ts). It is policy-authored, so its refusal stays READ_SCOPE_COMPILE_FAILED / 500 with the message withheld (analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367), and a retired operator there keeps its fail-closed sentence. A top-level { $regex: … } (not a field constraint) keeps "Unsupported top-level filter operator", as the data door's faces have no retired arm there either. NOT MEASURED: what the data door says for a FilterArray ['name', 'regex', 'acme']. This door refuses it at isFilterAST, unchanged.
  • Possible follow-up (carrier: none): one spec-side builder for the retired-operator lead-in, so the six faces stop composing it separately. That is a spec export plus five drivers, and none of them is wrong today.
  • Overlap. Open PR fix(service-analytics)!: the draft preview refuses a date bucket over a column that is not a date or datetime, as the published dataset does #28976 edits service-analytics/src/strategies/objectql-strategy.ts (the draft preview's date bucket). No hunk meets this PR's files. No other open PR touches filter-normalizer.ts or the two service-analytics test files. All 27 open PRs were checked by file list.
  • Precedence change, declared: none for any existing refusal. A where whose only defect is a retired key now gets the RETIRED sentence instead of the generic list. The draft preview now gives the published faces' sentence instead of its own for those two keys.

Verification

Derived gate families: node scripts/pm/dispatch-gates.mjs --commands at 49e31fc2 (5 paths vs merge base 135054eb) gives 64 commands. All 64 ran locally and exited 0. dispatch-gates --ran: 64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED. The first run of check-plugin-teardown-shape.mjs --self-test exited 3 on the shallow clone. After git fetch --depth=1 origin 621a4876… it exited 0 (48 cases). CI owns the 50 artifact-roster families, the wide-population families, the type-check lanes and pnpm lint; their verdicts are on this PR's check-runs.

Gate record (COMMAND :: exit N), HEAD 49e31fc
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-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-dts-emitted.mjs --self-test :: 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-issue-citations.mjs :: 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-tenant-audit-census.mjs :: exit 0
node scripts/check-tenant-audit-census.mjs --self-test :: exit 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.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
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm --filter @objectstack/spec run check:empty-state :: exit 0
pnpm --filter @objectstack/spec run check:liveness :: exit 0
pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0
pnpm --filter @objectstack/spec run check:variant-docs :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: 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 0
pnpm check:engine-double-contract :: exit 0
pnpm check:error-status-conformance :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:platform-object-tenancy-census :: 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:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:system-context-census :: 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 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0

Generated by Claude Code

…irement prescription

A retired operator ($regex, $options) in an analytics where or dataset
runtimeFilter was refused INVALID_FILTER / 400 with the generic
unsupported-operator list. It now answers with the data door's sentence:
RETIRED, Write "$icontains" instead., then RETIRED_FILTER_OPERATORS[op].why
verbatim, naming every retired sibling of the same constraint. One pass at
the end of normalizeWhereComparands, so the native execute, the
/analytics/sql echo, the ObjectQL engine path and the draft preview all give
the same answer. Unknown operators keep the generic list.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oAC7wmJvoM1VnnCjg
… the showcase dataset door

The dogfood file asks the stock showcase's /analytics/dataset/query with
$regex and $options in runtimeFilter and checks the RETIRED prescription,
the same tail the data door prints, and two controls. The NULL-safe $not
pin that used $regex as its example of an unknown operator now uses a
truly unknown name, and pins the retired one beside it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oAC7wmJvoM1VnnCjg
…or does

Plain table lookup over the constraint's $-keys (the package's lib has no
Object.hasOwn), and drop an unused traversal parameter.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oAC7wmJvoM1VnnCjg
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ab085a048241b0bf1fde06696fc6cbf659a6707f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 727bb7ee82737a61618ae5227b085863fb096b2b — the merge of head 49e31fc2d7bef8db79a449fa3e29cb0b3bf2f688 into base ab085a048241b0bf1fde06696fc6cbf659a6707f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 727bb7ee82737a61618ae5227b085863fb096b2b && git checkout 727bb7ee82737a61618ae5227b085863fb096b2b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab085a048241b0bf1fde06696fc6cbf659a6707f 49e31fc2d7bef8db79a449fa3e29cb0b3bf2f688 && git checkout -B drift-repro ab085a048241b0bf1fde06696fc6cbf659a6707f && git merge --no-ff 49e31fc2d7bef8db79a449fa3e29cb0b3bf2f688

node scripts/docs-audit/affected-docs.mjs --json ab085a048241b0bf1fde06696fc6cbf659a6707f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-11-02T06:13Z · head 49e31fc2 · claimed by seat 2 on #28945 (comment 6569745193)

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
  2. Contract review: none is owed. The declaration is Clause-②: no, and no spec or governed path is touched. The seat read the hand-back (os-dev-report 6570427277) and the diff:
    • One refusal's words replace another's. The status, the code (INVALID_FILTER / 400) and the refused set are unchanged. An unknown operator keeps the generic list.
    • The prescription is RETIRED_FILTER_OPERATORS[op].why verbatim, so no word of it is written in this package.
    • It is the last pass of normalizeWhereComparands, so every analytics face answers it from one call and no existing refusal moves.
    • Order deviation, accepted: the order said "the same message builder", but none is shared on main, because each data-door face composes its own lead-in around the table. The PR follows driver-sql's lead-in word for word, and that is the right reading.
  3. Evidence: reported by the dev, and not re-run by the seat.
    • Red on main: 11 unit cases and 3 dogfood cases.
    • Ablation: removing the fix reds only the 12 new cases.
    • Package suite: 205/205 files.
    • Typecheck: clean.
    • Derived gates: 64 of 64 exit 0.
  4. Gates: CI on this head showed 13 success, 13 skipped, 0 red and 20 still running at the time of this ACCEPT. Auto-merge waits for them, and the merge queue re-runs CI against current main.
  5. Serial: none. Open PR fix(service-analytics)!: the draft preview refuses a date bucket over a column that is not a date or datetime, as the published dataset does #28976 touches another file in the same directory, and no hunk meets.
  6. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 06:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 06:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit c470581 Nov 2, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-jebtpj branch November 2, 2026 07:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant