Skip to content

fix(objectql): a per-aggregation filter counts $contains on a multi-valued field by membership, as its where twin does - #21004

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20873-aggregation-filter-array-membership
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20873-aggregation-filter-array-membership

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #20873
Clause-②: no

What changes

The per-aggregation filter (engine.aggregate({ aggregations: [{ …, filter }] }), and so POST /api/v1/data/:object/query) is evaluated by the engine's own walker, matchesAggregationFilter in packages/objectql/src/having-filter.ts. Its $contains arm failed every value that was not a string, so a stored array never matched, and its $notContains arm passed every such value, members included.

On a DECLARED JSON-stored field both arms now ask MEMBERSHIP, the reading FILTER_OPERATORS' $contains docblock (@objectstack/spec) declares and where already gives on every SQL dialect (SqlDriver.applyJsonMembership):

Files: having-filter.ts (the two arms, storedArrayHasMember, declaredJsonStoredFields, and an optional jsonStored set threaded through matchesHaving / matchesAggregationFilter), plus in-memory-aggregation.ts. That file is the one place the engine hands the object's declared field map to the per-aggregation filter, so it reads the declared set once per call beside declaredFieldClasses. having-filter.ts is not on objectql's published entry points. in-memory-aggregation.ts is (applyInMemoryAggregation and bucketDateValue, from index.ts and core.ts), and neither exported signature changes; the declared set is threaded through the internal aggregateBucket only.

The card's table, through the REST door, before and after

Measured with a real SqlDriver on SQLite and on a live PostgreSQL 16.14. Rows: d1 ['u1','u2'], d2 ['u2'], d3 ['u3','u1'], d4 [], d5 ['u10'], d6 null. Base 212d613c, head 90ba78d9.

query where twin per-aggregation m, base m, head
owners $contains 'u1' (the card) 2 0 2
owners $contains 'u10' 1 0 1
owners $notContains 'u1' 4 6 4
tags $contains 'red' (d3 holds ['redwood']) 2 0 2
tags $notContains 'red' 4 6 4
$or of $contains u1 / u3 (the any-of spelling #7398's refusal prescribes) 2 0 2
$not over owners $contains 'u1' 4 6 4
title $contains 'u1' (text, the control) 3 3 3

On the in-memory driver the per-aggregation m is the same evaluator's answer, also 2 now. Memory's own where answers 3 for the card at this base (d5 too, by a per-element substring). That face belongs to #20874 (in flight), whose branch (10656601) moves it to membership and pins d1, d3.

The fork: by the DECLARED column (Zone 2 H2)

The fork reads the declaration (STRUCTURED_JSON_TYPES or isMultiValueField), never the row. That is the contract's sentence: "One operator, two questions, selected by the COLUMN rather than by the caller". It is also SqlDriver.isJsonColumn's population (built from the same two spec sets) and #20874's isJsonStoredField, character for character.

Measured: on every fixture reachable through the public doors, the declared reading and a value-shape reading select the same rows.

  • $contains / $notContains on a declared structured-JSON field is refused before any row is read: the engine's text-operator declared-type door, INVALID_FILTER 400, in where and in the per-aggregation filter alike, on all three backends.
  • A multi-valued field's find() value is an array or null on memory, SQLite and PostgreSQL alike. The write door wraps a scalar: 'u1' is stored as ['u1'] on memory and SQLite.

The two readings differ only on rows a direct caller hands the walker: a declared multi-valued column holding a scalar string, or an undeclared column holding an array. There the declared reading gives what SQL where gives (no member; the substring reading), and a value-shape reading would not. Both cases are pinned. No open_questions fork results.

Zone 2 hypotheses, measured

Compile-surface conclusions

# face conclusion
1 driver-sql applyFilterCondition already compliant (evidence) — $contains / $notContains on a JSON column go through applyJsonMembership; the where twin numbers in the table above are this face, measured on SQLite and PostgreSQL 16.14. driver-sqlite-wasm and driver-turso local inherit it (not measured separately).
2 turso RemoteTransport.buildWhereSQL out of scope (reason) — an independent compiler this card does not touch. Read at 212d613c: its $contains / $notContains arms go pushLike (substring over the stored text) with no JSON-column fork. Not measured (no remote libsql here). In the out-of-scope finding below.
3 service-analytics compileScopedFilterToSql out of scope (reason) — a different compiler. Measured function-level at 212d613c: { owners: { $contains: 'u1' } } compiles to instr("t"."owners", ?) > 0 on SQLite, which admits a row holding ["u10"]. On PostgreSQL it compiles to "t"."owners" LIKE ? ESCAPE ? over a json column. In the out-of-scope finding below (an RLS read scope).
4 service-analytics lowerAnalyticsWhere out of scope (reason) — it lowers the analytics where to a FilterCondition and adds no $contains reading of its own. The ObjectQL strategy hands that to the driver (face 1). The native SQL strategy maps contains to the substring LIKE shape (native-sql-strategy.ts, read, not measured), in the same finding as face 3.
5 formula matchesFilterCondition out of scope (reason: fenced; PR #20972 landed on this file during this run) — its arm is typeof actual === 'string' && typeof v === 'string' && actual.includes(v) (unchanged by #20972). Measured: ['u1','u2'] → false, ['u10'] → false, 'u1 memo' → true. In the out-of-scope finding below.
half objectql having-filter changed — the per-aggregation filter, as above; applyHaving / matchesHaving without a declared set unchanged (H4).
unfrozen driver-memory / driver-mongodb out of scope (reason: fenced, #20874 / #20897 in flight). Memory measured above, and #20874's fork matches this one. Mongo's translateFieldOperators compiles $contains to a bare $regex, which MongoDB applies per array element (per-element substring). Read, not measured.

Tests

  • pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 src/engine-aggregate-filter-array-membership.test.ts — 30 passed. The card's rows with the rows themselves, empty table, per group, having control, the member-text reading (number / exponent / boolean / null / non-JSON-number spellings / nested / object / scalar), the declared fork, the declared population.
  • OS_TEST_POSTGRES_URL=… pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/aggregation-filter-array-membership.test.ts — 18 passed (9 SQLite, 9 live PostgreSQL 16.14), 9 named skips (MySQL). Each row runs beside its live where twin, populated and empty.
  • objectql whole local project on the merged head 90ba78d9: 349 files, 6851 tests passed; repo project 1 file / 5 passed.
  • REST aggregation-adjacent files on 90ba78d9 with the PostgreSQL cell live: 9 files, 106 passed, 34 skipped.
  • pnpm --filter @objectstack/objectql typecheck and pnpm --filter @objectstack/rest typecheck: green. Both new test files are in their package's test program (tsc -p tsconfig.test.json --listFiles).

Reverse verification

Each leg ran through scripts/ablation-replace.mjs (anchor hits proven on disk; restore proven blob == HEAD and git diff HEAD empty), from the committed change.

  • A1 — the $contains arm put back to the substring test: 15 of 30 red (every membership $contains row, the member-text rows, the declared-fork row); the $notContains rows and controls green, as predicted.
  • A2 — the $notContains arm put back: 3 red (its two rows and the complement row).
  • B — the REST suite reads objectql through dist/. declaredJsonStoredFields was emptied, objectql rebuilt, and ablation-dist-preflight.mjs found the marker in 4 built files. 12 red: the 6 membership rows on each of SQLite and PostgreSQL. Text control, having and empty-table rows stayed green. Restore leg: rebuilt, marker absent from all 14 built files, tree clean, 18 passed.

Driver conformance ledger

node scripts/check-driver-conformance.mjs: before (212d613c) "50 covered cell(s), 0 in the DEBT ledger, 0 exempt"; after (90ba78d9) the same.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands re-derived with no paths at 90ba78d9 gives 63 commands, all run, exit codes recorded to disk. --ran reconciliation: 63 derived, 61 run (all exit 0), 2 NOT MEASURED, 0 unrun.
    • NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt. Both are PREREQUISITE NOT MET (exit 3): they need the whole-workspace build lint.yml performs first, and 42 / 5 packages have no dist/ in this worktree.
    • check-engine-split-ratio first refused on the shallow checkout. It was green after a deepen to its window (git fetch --shallow-since=2026-06-26 origin main).
  • Lint, a declared narrowing over the four touched TS files at 90ba78d9: eslint --no-inline-config --format json reports 4 files, 0 errors, 0 warnings.
    • Each file maps to a config (--print-config).
    • eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules; its own lines 327-328 say so), so this diff cannot move an untouched file's verdict.

Acceptance notes


Generated by Claude Code

claude added 4 commits October 1, 2026 00:24
…ed multi-valued field by membership

The engine's aggregation evaluator failed `$contains` on every value that
was not a string, so a stored array never matched and `$notContains`
matched every row. On a declared JSON-stored field (STRUCTURED_JSON_TYPES
or isMultiValueField) both now ask membership, the reading `where` gives
on every SQL dialect; a scalar string column keeps the substring test.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…lti-valued field beside its where twin

Engine-level over the read shape find() presents on memory, SQLite and
PostgreSQL; REST door on a real SqlDriver, SQLite always and the live
PostgreSQL / MySQL cells where their URL is set, each row beside its
where twin. having keeps the substring reading on a text projection.

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

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

Coarse fallback — 17 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 a5bce408883b81a6e2382ebe709a03cc3a5b40b4 → packageMentionDocs.

Which tree this was computed on

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 90ba78d9b150faf40e31f68841e9ac94aa1e13a1
Local-runs: none

Inputs: card #20873 (body and all five comments: triage 5914962540, the blocked transition 5921472356, claim 5921990075, report 5922812043, claim amendment 5922857157); PR #21004 body, its five-file list, its four commits and the net diff against the merge base with origin/main (7fa67dada, the head's second parent: 5 files, 596 insertions, 14 deletions); the 34 check-runs on this head, read once at 2026-10-01T01:51Z. The head is a merge commit (e9d4728c9 plus origin/main at 7fa67dada); origin/main has since moved to e161ad358 (PR #20984 f8178ffec included) without touching either changed source file. driver-sql, driver-memory, spec and objectql sources were read with git show at the merge base and at current origin/main to judge the claims against them. Nothing built, run or re-run.

① Derived judgments

  1. Accept set unchanged in both directions — right. At the merge base the evaluator's $contains arm was typeof value !== 'string' ⇒ false (a stored array never matched) and its $notContains arm was typeof value === 'string' && includes ⇒ false (a stored array always passed). The diff changes only the boolean each arm returns when jsonStored is true; it adds no throw and removes none. $in / $nin (listHolds), $startsWith, $icontains, the flag arms and the F8 whole-day arms are untouched. The changeset's sentence "No query that was refused now answers, and none that answered is refused" holds against the diff.
  2. Published surface unmoved — right in substance; one sentence of the PR body is wrong. having-filter.ts gains one module export, declaredJsonStoredFields, and matchesHaving / matchesAggregationFilter gain a trailing optional jsonStored; checkCondition and storedArrayHasMember are private. None reaches the package entry: index.ts and core.ts have no export *, name none of these symbols, and engine.ts only imports from having-filter.js. But the body's "Neither module is on objectql's published entry points" is wrong for in-memory-aggregation.ts: index.ts line 366 and core.ts line 78 both export applyInMemoryAggregation and bucketDateValue from it. Consequence nil: applyInMemoryAggregation(rows, ast, timezone?, fields?) keeps its signature at the head, the new set is read from the fields it already receives, and aggregateBucket is module-private. The amendment's narrower statement (having-filter named nowhere in index.ts) is right, and the changeset's "no export" claim holds for the published surface.
  3. The member rule is driver-sql's, read as a predicate — right. storedArrayHasMember and jsonMembershipCandidates (sql-driver.ts 3821 at the base) carry the same JSON-number regex literal; a string element by text equality; a number element by Number(text) only when the text is a JSON number and finite (so '1.50' names 1.5, '1e2' names 100, '01' / ' 1' / '0x10' name nothing); true / false / null by literal text; array-only, with an object or nested-array element no member. The driver compares JSON texts and the evaluator JS values, and the same rows result on every case the new suite pins.
  4. The fork population — right for the authorable population; the body's "SqlDriver.isJsonColumn ... character for character" is over-stated. declaredJsonStoredFields is STRUCTURED_JSON_TYPES.has(type) || isMultiValueField({ type, multiple }), the identical expression to isJsonStoredField that fix(driver-memory): $contains on a multi-valued or JSON-stored field is membership, on every face #20984 landed on main. driver-sql's isJsonField is JSON_COLUMN_TYPES (the same two spec sets plus the driver-internal object / array aliases) || isMultiValueField, with a per-instance mediaColumnIsJson for a single-value media column on a deployment that has not moved those columns. The aliases are not authorable types, so the only member where the driver's registry is deployment-dependent and the evaluator's is not is single-value media; fix(driver-memory): $contains on a multi-valued or JSON-stored field is membership, on every face #20984's docblock excludes it for the same reason, it is unmeasured here, and it rides the consolidation flag in ③.
  5. Threading — right. in-memory-aggregation.ts reads the set once per call and only when some aggregation carries a filter, beside declaredFieldClasses; engine.ts 17297 is the one call that passes declaredFields. applyHaving passes none, so having keeps the substring reading (H4), which the two suites pin on the groupBy text projection.
  6. $in / $nin left as they are — right under the direction's own conditional. Triage asked for $in per element "where the operator's declaration says so". SET_MEMBER_DESCRIPTION says nothing about a stored array, and the spec's $contains docblock records membership as "the one operator driver-sql: a declared operator on a multiple: true (JSON array) column silently answers wrong — $in/$eq always zero rows, $nin returns the rows it was asked to EXCLUDE #7398 left working on a JSON column after refusing the equality family there". There is no declared per-element reading to implement; the per-aggregation 200 where where is a 400 is a residual the report files (③).
  7. Pins against triage's set — met at the evaluator and on SQLite; PostgreSQL and memory are qualified (③). The card's table (m: 2), the u10 prefix row, the $or any-of spelling, the $not complement and the text control are pinned in both suites; the REST suite runs each row beside its live where twin and the SQLite cell runs in CI (Test Core success). The PostgreSQL cell is a named skip in CI; the memory cell is engine-level over a rows-mock driver.
  8. The merge and its trailers — right. A true merge (parent 2 7fa67dada), no rebase; all four commits carry Claude-Session plus Co-authored-by: Claude.

② Semver level

Clause-②: no

  • The arm is right. No input is refused or newly accepted (① 1), no published export, type or signature moves (① 2), packages/spec/src is untouched. What moves is the count a per-aggregation filter returns on a declared multi-valued field, to the count the spec's $contains docblock declares and where already gives on every SQL dialect: a fix toward the declared contract, not a narrowing or widening of it. The claim, the amendment, the PR body line 2 and the changeset carry the same bare no.
  • patch for @objectstack/objectql is right. No BREAKING banner, no ADR-0087 marker and no migration are owed; the changeset states the FROM/TO (0 and every-row, to the where count), the member-text reading, and that a scalar text field and having are unchanged. Check Changeset is success on this head. The changeset's "where gives on every SQL dialect" is scoped to the SQL family and does not claim driver-memory (which at this merge base still answered per-element substring; fix(driver-memory): $contains on a multi-valued or JSON-stored field is membership, on every face #20984 moved it after).

③ Boundary flags

  • Triage's clause, "If a shared membership predicate exists (the reference semantics the drivers pin), call it. ⛔ No third copy." — antecedent false on both bases; the prohibition cannot be honoured inside the claimed surface; escalated. On the merge base 7fa67dada and on current origin/main e161ad358 alike, @objectstack/spec/data exports the population halves (STRUCTURED_JSON_TYPES, isMultiValueField) but no member-candidate rule; driver-sql's jsonMembershipCandidates is module-private; driver-memory's containsMemberCandidates (memory-driver.ts 369, landed as f8178ffec after this merge base) is module-private; objectql depends on neither driver (core, formula, metadata, metadata-core, metadata-protocol, spec, types). So there was nothing to call, and once this lands storedArrayHasMember is the third module-private copy on main, which is what the clause forbids. The diff is not silent about it: the function's docblock says "A second copy of one rule, not the shared one" and names the home; the PR's Acceptance notes and the report's finding [4] (which counts three readers) say the same; and claim 5921990075, the contract the dev worked under, required exactly that ("a second JS copy is named in the report, ⛔ never silent"). The remedy is a packages/spec/src/** addition beside asciiCaseInsensitiveContains plus re-pointing driver-sql (fenced for #5930 step 4 (domain:engine): the engine-fed faces delete their hand-copied filter meaning (driver-sql, turso remote, memory query, mongodb, formula, having); the memory reference matcher retires (D6) #20822 group 2) and driver-memory (just landed): outside this claim's surface on every leg. ESCALATED to the seat as a consolidation card, carrier none today, with the single-value-media population edge (① 4) listed on it. The verdict is not withheld on it: the copy is verbatim in rule (① 3), disclosed in three places, and the clause's call-it branch had no callee.
  • open_questions: none in either report. The four additions the dev flagged beyond the original surface (in-memory-aggregation.ts, the $notContains arm, the two test files) were taken into the amended claim 5922857157 before this review; answered.
  • H5, the $notContains arm under the bounded exemption — accepted. It is the exact complement of the $contains arm in the same case pair, the null and absent rows satisfy it as SQL's col IS NULL OR NOT (...) does, the base counted 6 where where counted 4, and the amended claim names it.
  • Deviation: the memory pin is engine-level over a rows-mock driver, not InMemoryDriver — accepted, gap named. check:driver-memory-census bars a new test consumer of that driver, so triage's "on memory" pin is met at the evaluator over the find() read shape; the real-driver m: 2 in the body is the dev's measurement, a claim. A real-driver pin is a census-ledger entry, the seat's own act if wanted.
  • Deviation: PostgreSQL / MySQL REST cells are named skips in CI — accepted. The Test Core verdict covers the SQLite cell; the live PostgreSQL 16.14 run (18 passed) is local evidence only, the same note data-group-by-json-door.test.ts carries. The private PG instance outside the scratchpad, the shallow-since deepen that advanced the shared origin/main ref, and the --maxWorkers spelling: mechanics, no bearing on the diff.
  • out_of_scope_findings [0]–[3], for the seat to file and route; no action on this PR. [0] per-aggregation $in / $nin on a multi-valued field answer 200 ($nin fail-open, counting the rows it was asked to exclude) where where is a 400 on the SQL family: the same evaluator, the same file, the natural next card there. [1] the $contains membership contract unanswered on the turso remote transport, the service-analytics SQL compilers (an RLS read scope over-reaches), driver-mongodb and formula. [2] min / max over a multi-valued field answered three ways. [3] $startsWith / $icontains on a multi-valued field, PostgreSQL 500. [4] is escalated above; [5] (no shared conformance kit for stored-array membership) is a routing note.
  • PR body: one wrong sentence (① 2) and one stale count. The body says "a second JS copy"; the report's "three readers" is the right count on current main. Named for the seat; prose on the PR, no bearing on the verdict.
  • Check-runs on this head, read once at 2026-10-01T01:51Z: 34 — 31 success, 3 skipped by design (Build Docs, Console Pin Gate, Packed-tarball smoke), 0 failure, 0 in progress. Check Changeset, Lint & Repo Gates, Test Core 1–6 and the roll-up, all four Type Check jobs, Build Core, Dogfood Regression Gate 1–3 and Dogfood Verify CLI, Temporal Conformance, the four claim and single-writer guards: success. Vercel commit status: success. Every gate verdict is in.

Implemented-by: claude/issue-20873-aggregation-filter-array-membership
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 02:07
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 02:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit d67b942 Oct 1, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20873-aggregation-filter-array-membership branch October 1, 2026 03:27
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

2 participants