Skip to content

fix(objectql)!: a per-aggregation filter refuses a scalar comparison on a declared JSON-stored field, in where's words - #21097

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21007-aggregation-filter-json-equality
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/issue-21007-aggregation-filter-json-equality

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #21007
Clause-②: yes (widening)

A per-aggregation filter now refuses a scalar comparison on a declared JSON-stored field ($eq, $ne, $gt, $gte, $lt, $lte, $between, $in, $nin, implicit equality) with INVALID_FILTER / 400, in the words where refuses the same filter in. It no longer counts rows the stored arrays cannot support. The operator set and the refusal text move from driver-sql to @objectstack/core, byte for byte, so both faces read one set and one sentence.

Clause-② has two halves. It is yes (widening) because @objectstack/core's root gains three exports (JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText and its return type JsonColumnOperatorRefusalText), and applyInMemoryAggregation gains an optional trailing reportWithheld parameter. It also narrows: @objectstack/objectql refuses queries it used to answer 200, at engine.aggregate and at the published applyInMemoryAggregation given a field map. The changeset therefore carries minor for objectql with a BREAKING banner and one ADR-0087 marker, minor for core, and patch for driver-sql, whose output is unchanged. The seat answer on the card (## Seat answer — #21007, comment 5924546829) amended the claim to this surface and this Clause-②.

What was wrong (measured before this change, d1f8ce865)

POST /api/v1/data/:object/query on SQLite and a live PostgreSQL 16.14, over the card's six rows (owners is a multiple: true lookup, and d1 and d3 hold u1). Both dialects answered identically:

filter where twin per-aggregation m before now
owners $in ['u1','u9'] (the card) 400 INVALID_FILTER 0 400, same body
owners $nin ['u1','u9'] (the card) 400 6, with d1 and d3 counted 400, same body
owners $eq 'u1' / { owners: 'u1' } 400 0 400
owners $ne / $gt / $lte / $between 400 6 / 4 / 1 / 5 400
tags $eq 'red' 400 1 (['red'] loosely == 'red') 400
meta (json) $eq / $in 400 0 / 0 400
owners $contains 'u1' (the prescribed spelling) 2 2 2 (unchanged)
title $in / $nin / $eq (controls) 2 / 4 / 1 2 / 4 / 1 unchanged

What changed

Measured findings behind the shape (H1–H5)

  • H1, the premise, holds. SET_MEMBER_DESCRIPTION, the $in / $nin entries of FILTER_OPERATORS and the $contains docblock give no per-element reading. driver-sql's where refuses (it does not answer membership), so triage's "membership, as driver-sql does" misread it.
  • H2, the set. All ten operators, plus null and empty-list comparands, were answered with a wrong count; none was already refused. The bare infix spellings (in, =, nin) are refused earlier at both positions by the nested-relation door, so the evaluator only meets the $ forms.
  • H3, the home. None existed; per the seat answer, the home is @objectstack/core.
  • H4, where it fires. The engine's in-memory lowering is the only evaluator of aggregations[i].filter: every driver's aggregate face refuses a per-aggregation filter 501, and the analytics ObjectQL strategy hands measure filters to engine.aggregate.
  • H5, having. After fix(objectql)!: engine aggregate asks the field-type table for every row — min / max / avg over a refused type answer INVALID_FIELD / 400 on every driver #21037 (landed, merged here), min / max over a multi-valued field is refused INVALID_FIELD at the aggregate door. Measured on InMemoryDriver at the merged head: max(owners) with or without having gives 400 INVALID_FIELD. So having cannot meet a JSON-stored column, and it is left alone.

Tests

Round 1 numbers were read at 6e541b101; round 2 numbers are marked with the head f66bed950 (main merged).

  • @objectstack/core json-column-operator-refusal.test.ts: 6 passed. It pins the set member for member, and the message and three diagnostics by SHA-256 and length against what driver-sql printed at 8f784959c. Hashes avoid a third literal copy of the sentence.
  • driver-sql sql-driver-json-column-refusal-shared-text.test.ts, run beside the existing JSON-column, compile-refusal-seam and provenance suites: 248 passed. It checks every FILTER_OPERATORS member against the shared set: the driver's thrown message and withheld diagnostic equal the core builder's output.
  • Byte identity of the move. A scratch capture through the built driver-sql (SqlDriver over SQLite) covered all 22 spellings plus bare equality, unmarked and author-marked, message and diagnostic, 46 entries. Before (8f784959c) and after: cmp identical, sha256 dbcf32f5…534b2 on both. driver-sql's dist no longer contains the sentence.
  • objectql engine-aggregate-filter-json-column-refusal.test.ts (engine-level cell over the find() read shape): 74 passed. It covers 18 family cases on each of owners, tags and meta (code, status, the $contains / $or prescription, the field absent from the message, field and operator in the logged diagnostic, and the driver never asked for a row), an empty table (pure and grouped), the logged position, 11 answered cases (membership, null predicates, title controls) and the per-row floor.
  • rest aggregation-filter-json-column-refusal.test.ts: 52 per cell. SQLite passes and a live PostgreSQL 16.14 passes locally; MySQL is a named skip. Every family case asserts that the per-aggregation 400 body's error is the same string as its where twin's. fix(objectql): a per-aggregation filter counts $contains on a multi-valued field by membership, as its where twin does #21004's aggregation-filter-array-membership.test.ts still passes beside it.
  • Full suites. Read at merge 1a226419e: objectql local 6948 passed, rest local 5072 passed / 247 skipped, core 1809 passed. Read before the first merge: driver-sql 3285 passed / 188 skipped. typecheck passed for core, driver-sql, objectql and rest. At head 6e541b101: core, driver-sql (refusal suites), objectql engine-aggregate* (571 passed) and rest aggregation-filter* (150 passed with PostgreSQL) re-ran green.
  • Ablation A: the engine gate call deleted (ablation-replace, plus a rebuilt objectql dist, plus ablation-dist-preflight --absent):
    • objectql suite: 56 of 74 red. The 54 family cases, the empty table and the logged position failed; the 11 answered cases and the 7 floor cases stayed green.
    • rest suite: 92 of 104 red (46 per dialect). Populated owners / tags cases still got a 400 from the per-row floor, without the logged diagnostic. meta negations ($ne, $nin, $nin [], $not $in, where meta is null on every row) answered 200 { n: 6, m: 6 }; the mechanism was not traced. The empty table answered 200.
    • Restored: blob equals HEAD, git diff HEAD empty, objectql rebuilt, preflight shows the marker present in 4 dist files with a clean tree, and both suites green again (74 and 104).
  • Ablation B: the per-row operator floor replaced by a no-op (src, engine suite): 5 red, the 4 operator floor cases and the no-value row; restored blob equals HEAD.
  • Round 2: the direct-caller pins (engine-aggregate-filter-json-column-refusal.test.ts, 92 passed). For meta (json) $ne, $nin and $not $in, each on four cells: an empty row set, an empty grouped row set, meta null in every row with the filter as spec lowerFilterCondition lowers it (the shape that carries the $null arm), and the same rows with the filter as written. Each must refuse 400 INVALID_FILTER with exactly engine.aggregate's message, and hand the diagnostic (field, operator, At aggregations[1].filter.…) to reportWithheld. Also pinned: no reporter means the same refusal; no field map means nothing judged (m: 0, as before); and $contains still answers.
  • Ablation C: the new applyInMemoryAggregation call deleted (ablation-replace, anchor 1 to 0, blob c65412761a90 to f530761c0559): 13 of 92 red.
    • The 9 empty, empty-grouped and lowered-null cells, plus the no-reporter case, answered instead of refusing. That is the backstop's 200.
    • The 3 as-written null-row cells were refused by the backstop but with no diagnostic reported.
    • Restored: blob equals HEAD c65412761a90, git diff HEAD empty, 92 passed again.
  • Round 2 at f66bed950: objectql local full suite 7008 passed (356 files), rest aggregation-filter* 150 passed / 61 skipped with a live PostgreSQL 16.14, core refusal pin 6 passed, driver-sql refusal pins 141 passed.
  • Driver conformance ledger: 50 covered cells, 0 in the DEBT ledger, 0 exempt, both before and after, in both rounds.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 70 families at 6e541b101.
  • 68 ran, exit 0. Among them: check:adr-0087-registration, check:changeset-no-major, check:engine-double-contract, check:nul-bytes, check:doc-authoring, check:driver-conformance, check:driver-memory-census, check:query-options-erasure and check:test-source-alias.
  • 2 NOT MEASURED (exit 3, prerequisite not met): check:dual-build-cjs-loads and check:type-check-debt. Both need the whole workspace built; two attempts at that build timed out in the shared verify-lock queue. CI's lint job builds first.
  • --ran reconciliation: 70 derived, 68 run, 2 NOT MEASURED, 0 unrun.
  • Round 2 at f66bed950: re-derived with no paths, the same 70 families; 68 ran with exit 0 and the same 2 were NOT MEASURED (exit 3). --ran: 70 derived, 68 run, 2 NOT MEASURED, 0 unrun. typecheck passed for core and objectql. Narrowed lint: 10 changed .ts files, 0 errors, 0 warnings.
  • An earlier run caught one real finding, fixed in e7bd7f667 ("type the shared-text pin's find options"): check:query-options-erasure's test surface grew 236 to 237 because of an as any on a find options bag in the new driver-sql test.
  • Lint, narrowed and declared: eslint --no-inline-config --format json over the 9 changed .ts files reports 9 files, 0 errors, 0 warnings. eslint.config.mjs sets no parserOptions.project and registers no typed rule, so linting is not type-aware and this diff cannot move a verdict on an untouched file. The full pnpm lint is CI's.

Acceptance notes


Generated by Claude Code

claude added 9 commits October 1, 2026 04:21
… text move to @objectstack/core, byte for byte

driver-sql's module-private JSON_COLUMN_INCOMPATIBLE_OPERATORS and the two
texts of jsonColumnOperatorError now live in core's
json-column-operator-refusal.ts, exported from the root; the driver imports
both under the same names and keeps its own error constructor (the #8220
provenance seam). Pins: the set member for member and the texts by SHA-256
against what the driver printed at 8f78495, and the driver's thrown text
against the shared one.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…on a declared JSON-stored field, in where's words

$in / $nin / $eq / $ne / the orderings / $between and implicit equality on
a field the object declares JSON-stored (a structured-JSON type or a
multi-valued field) are refused INVALID_FILTER / 400 at
assertAggregationFilterIsEvaluable, before any driver is asked, with the
withheld text driver-sql's where refuses them in (now core's) and the
diagnostic handed to the host log. Before, the in-memory evaluator compared the
whole stored array against a scalar: $in counted 0 and $nin counted the rows
it was asked to exclude. checkCondition carries the same refusal as the floor
for a direct caller.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…re twin's body, on SQLite and live PostgreSQL

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…inor, core minor, driver-sql patch)

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl 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

This PR changes 3 package(s): @objectstack/core, @objectstack/driver-sql, @objectstack/objectql, touching 13 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query))
  • content/docs/api/data-api.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/api/wire-format.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/data-modeling/queries.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/kernel/runtime-services/data-service.mdx (via data.query (sdk, the route ledger binds it to POST /api/v1/data/:object/query, selected by route anchor /data/:object/query; the route ledger binds it to POST /data/:object/query), /data/:object/query (route, a path literal in a comment on a changed line))
  • content/docs/protocol/objectql/query-syntax.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))

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

  • content/docs/releases/v17/17-5.mdx (via /data/:object/query (route, a path literal in a comment on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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

Coarse fallback — 38 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 8368f1c00551b289b536291e31de297823f64e0b → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8368f1c00551b289b536291e31de297823f64e0b

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8368f1c00551b289b536291e31de297823f64e0b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6e541b101c87368a66ee93b6c1f9f01be7cc6af5
Local-runs: none

Inputs, and nothing else: card #21007 (body and comments 5923286198, 5924145107, 5924194074, 5924484320, 5924546829, 5925997383); PR #21097 (body, the 10-file list, the net diff against main at merge-base 63d1a7c37, +987 / -93 over 10 files, 9 commits); the 31 check-runs and 1 commit status on the head, read ONCE. Nothing was built, run or re-run. The byte comparisons below are text diffs of the net diff's own removed and added lines; every source reading is git show of the head.

① Derived judgments

  1. @objectstack/core publishes a widening — right; the count is three, not two. packages/core/src/index.ts adds export * from './utils/json-column-operator-refusal.js', which puts three names on the root: JSON_COLUMN_INCOMPATIBLE_OPERATORS (a ReadonlySet of 22 spellings, the bare infix forms included), jsonColumnOperatorRefusalText(field, op, bare) returning { message, diagnostic }, and the type JsonColumnOperatorRefusalText. The PR body and the changeset count two; the type is the third. Additive, no existing core name moves, and core carries no api-surface baseline to regenerate (only packages/spec does).

  2. The driver-sql move is byte-identical — right. From the net diff's own lines: the removed message template (9 lines) and core's added one are identical; the removed diagnostic (8 lines) and core's are identical; the spelling / on prefix helpers are identical; the removed module-private set equals the exported set member for member, 22 of 22. jsonColumnOperatorError(field, op, bare, subtree) keeps its name and signature and still goes through withheldFilterError(message, diagnostic, subtree), so the [A of #7929] a spec-declared provenance mark set at both read-scope merge boundaries, so the driver can restore the author-facing cross-field diagnostic without re-disclosing policy #8220 provenance seam is untouched. assertOperatorAppliesToColumn (sql-driver.ts :15921–:15923 at the head) reads the imported set under the same name; the only other change in the file is the import at :153–:156. driver-sql's refused set, its refusal text and its public surface are unchanged.

  3. @objectstack/objectql narrows its accept set at exactly one position — right. assertAggregationFilterIsEvaluable now calls assertAggregationFilterSparesJsonStoredFields once per aggregations[i].filter, after the reference rule, against declaredJsonStoredFields(declared.fields) (every STRUCTURED_JSON_TYPES member — json, composite, repeater, record, location, address, vector — plus every isMultiValueField), before driver.find is asked for a row. The walk covers $and / $or (array or single) and $not; it refuses implicit equality (reported as =, bare; null, a Date or an array comparand included) and every member of the shared set, whatever the comparand. It throws the shared withheld sentence through invalidFilterError (ADR-0112, INVALID_FILTER / 400) and hands the diagnostic with the position (At aggregations[2].filter.owners.$nin:) to reportWithheld. No field map judges nothing. The gate runs on the pre-lowering filter (typed, engine.ts :17144), ahead of the spec lowering at :17300–:17315, and walks every branch, so an empty table and a short-circuited $or refuse too. The bare infix spellings are in the set; the dev measured them refused earlier by the nested-relation door at both positions, and if that door ever stops they are refused here in the same words.

  4. The population is one definition on both faces — right. Engine: STRUCTURED_JSON_TYPES.has(type) || isMultiValueField(...). driver-sql: JSON_COLUMN_TYPES (STRUCTURED_JSON_TYPES plus MULTI_OPTION_TYPES, sql-driver.ts :314) || isMultiValueField(field) at :15627, keyed on the table's registered columns. Symmetric on the unknown object: driver-sql does not refuse a table it was never told about, and the engine judges nothing without a field map.

  5. The per-row floor is NOT complete, and is not meant to be — right, with one docblock sentence to correct. checkCondition refuses implicit equality and every set member above its no-value exit, so the floor fires on every row THAT REACHES THE ARM. It does not fire on rows the walker never brings there, and the report's untraced 200s are exactly that: packages/spec/src/data/filter-lowering.ts rule 3 (:37–:44), applied to every per-aggregation filter by resolveThenLowerWhere (engine.ts :17310, after the gate), rewrites a negative-polarity leaf ($ne a non-null value, $nin) into $or: [ f $null true, f spec ] and gives a $not operand an f $null false conjunct; matchesHaving short-circuits $or with some and $and with every. On a row with no value the $null arm answers first and the $ne / $nin / $not $in arm is never walked. The REST fixture's meta is null in all six rows, so under ablation A every meta negation answered 200 m: 6, while owners / tags rows holding a value reached the arm and got the floor's 400; an empty table has no row and no arm. So the floor's docblock sentence "it is the filter's verdict, not the row's" is true of the arm's position and false of its reach. The gate's own docblock already says the right thing ("the per-row walk never meets an empty table or a short-circuited $or branch"), and the gate is the complete door. A docblock correction for the seat, not a contract defect at the card's doors. The engine suite's floor cases call matchesAggregationFilter on rows that reach the arm, so they pin what the floor does, not completeness; right as pins.

  6. One public door reaches the floor without the gate — right to name; no caller takes it. applyInMemoryAggregation(rows, ast, timezone, fields) is exported from objectql's root (index.ts:366) and from ./core (core.ts:78); given fields it calls the walker with declaredJsonStoredFields(fields) and never calls the gate. Through it a direct caller now meets a row-dependent refusal on a JSON-stored scalar comparison, which the changeset's "Who is affected" does not name (the LEVEL covers it: objectql's BREAKING minor). Callers at the head: engine.aggregate (gated upstream) and packages/verify/src/date-bucket-parity.ts:239, which passes no fields, so nothing is judged there; the objectui sibling has no caller. matchesAggregationFilter and checkCondition are on neither export map. Escalated in ③.

  7. $contains / $notContains / $exists / $null / $empty keep answering; having, where and every scalar column untouched — right. having cannot meet a JSON-stored value after fix(objectql)!: engine aggregate asks the field-type table for every row — min / max / avg over a refused type answer INVALID_FIELD / 400 on every driver #21037 (INVALID_FIELD at the aggregate door, re-measured at the merged head), so leaving it alone is right.

  8. engine.ts log line — host-log wording, now true of both refusals it carries; not a contract. Right.

  9. The comparand edge the dev named: owners $eq { $field: 'title' } is refused by this gate in the JSON-column sentence, and by driver-sql's where through its cross-field class rule in that rule's sentence; both INVALID_FILTER / 400 with the field withheld. A difference in which sentence prints, not in the verdict. ③.

  10. Tests — right as pins of the contract above. Core pins the set member for member and the message plus three diagnostics by SHA-256 and length against the pre-move driver, so no third literal copy exists. driver-sql's shared-text pin runs every FILTER_OPERATORS member on a real SqlDriver over SQLite and asserts the thrown message and the withheld diagnostic equal the core builder's. objectql's engine cell: 18 family cases on each of owners, tags, meta, driver.find never called, the empty table pure and grouped, the logged position, 11 answered cases, the floor. REST: the per-aggregation 400 body's error is the same string as its where twin's, with the field absent and the diagnostic in the log; SQLite always, PG and MySQL only with OS_TEST_*_URL.

② Semver level

  • The changeset: objectql minor, core minor, driver-sql patch; first line fix(objectql)!: …; Clause-②: yes (widening); one adr-0087: not-required (no-migration-prescription) marker; a **BREAKING** banner naming the position, every driver and every caller that reaches the engine, and the spelling to write instead. All three packages are in the fixed group.
  • core minor — right: three new root names; yes takes at least minor.
  • objectql minor with the banner — right: an accept-set narrowing is BREAKING; under the launch-window convention (check-changeset-no-major.mjs) a break ships as minor, carried by the banner, the ! summary and the marker. check-adr-0087-registration.mjs's breakingDeclaration reads two signals here (the banner and the bang; widening adds none by design) and so demands the marker; the marker is present, its category is in CATEGORIES, and its why-text closes the other categories on facts (no authorable key or stored row moves, nothing unpublished, no ADR-0087 id covers a filter operator on a JSON column, one ADDED export and no narrowed interface). The migration prescription is in the banner ($contains, an $or of $contains, $not around either).
  • driver-sql patch — right: no published change; the dependency on @objectstack/core already exists, and the fixed group moves the floor together.
  • The Clause-②: line — right. The pair is legal, the PR body and the changeset carry the same line, and it names what the diff publishes as a public-surface change: new core names, additive. One line carries one arm by rule; the narrowing half is declared where the ADR-0087 gate reads it (signals 2 and 3), so nothing is under-declared at any gate. The amendment from the claim's no (narrowing) (5924194074) to yes (widening) is the seat's (5924546829) and is what the PR carries.
  • Gate verdicts on the head: Check Changeset success. check:adr-0087-registration and check:changeset-no-major run inside Lint & Repo Gates, still in progress: not a verdict, and the dev's local exit 0 is not one either.

③ Boundary flags

Dev flags (report 5925997383 and the PR body):

open_questions:

  • Round 0 (5924484320) Q1, the home — answered by the seat (5924546829: A, @objectstack/core) and implemented as ruled: the new core module and root export, driver-sql re-pointed byte-identically with its refused set unchanged, objectql importing at the one-time seam, the floor kept.
  • Round 0 Q2, disclosure — answered (A, where's posture) and implemented: the message names neither the field nor the operator (core pin not.toContain('secret_col'); engine and REST pins not.toContain the field), and the diagnostic with field, operator and position goes to reportWithheld.
  • Round 1 (5925997383): open_questions: [].

Check-runs on the head, read once:

  • completed, success (12): Auto Label; No other open PR may claim the same single-writer path; Type Check · source gates; Check Documentation Links; filter; Part-of PR must not also close its card; Governed Surface Queue Guard; Check PR Size; The card this PR closes must claim this branch; Check Changeset; Flag docs affected by code changes; No other open PR may claim the same issue. Commit status Vercel: success.
  • completed, skipped (3): Build Docs; Console Pin Gate; Packed-tarball smoke (opt-in).
  • in progress (16), each one NOT a verdict: Build Core; Test Core 1/6, 2/6, 3/6, 4/6, 5/6, 6/6; Dogfood Regression Gate 1/3, 2/3, 3/3; Dogfood Verify CLI; Temporal Conformance (live PG + MySQL); Type Check · workspace; Type Check · consumer gates; Type Check · debt ledger; Lint & Repo Gates. The landing waits on every check green; that read is the owning seat's, not this record's.

No governed-surface path is in the file list. The PR is a draft. This at-tier record is the one the BREAKING changeset owes (claim 5924194074).

Implemented-by: claude/issue-21007-aggregation-filter-json-equality
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS

claude added 3 commits October 1, 2026 07:02
…n each filter before any row, as engine.aggregate does

Given a field map, the published applyInMemoryAggregation reached the per-row
backstop without the one-time gate, so an empty row set and a row the lowered
filter's $null arm decided first answered 200 where engine.aggregate refuses
400. It now calls assertAggregationFilterSparesJsonStoredFields (exported from
having-filter.ts, the same function) once per aggregations[i].filter, and takes
an optional reportWithheld for the withheld diagnostic. The floor's docblock
now says what it is: a backstop for a row that reaches the arm.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…ggregation's direct callers

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

Round-2 delta review on the at-tier PASS 5926186339 at 6e541b101. Inputs, and nothing else: card #21007 (body and comments 5923286198, 5924145107, 5924194074, 5924484320, 5924546829, 5925997383, 5927016404); PR #21097 (body, the 11-file list, the net diff against main at merge-base 8368f1c00, +1124 / -94, 13 commits); the 41 check-runs and 1 commit status on the head, read once. Nothing was built, run or re-run. The whole net diff was judged; the commits after 6e541b101 are fd30845fc (the fix), 13e0104ca (the changeset) and two merges of main. The merge f66bed950 brings main's own +10 to core/src/index.ts and +29 to sql-driver.ts, which are not this PR's hunks; the net diff against the merge-base carries only the PR's (core index +7, sql-driver +24 / -85), and the file list grew from 10 to 11 by in-memory-aggregation.ts alone. Every source reading below is git show of the head.

① Derived judgments

  1. The published applyInMemoryAggregation, given fields, now refuses the JSON-column family before any row is judged, independent of the rows — right. in-memory-aggregation.ts :144–:150: when fields is present and some aggregation carries a non-empty filter, it calls assertAggregationFilterSparesJsonStoredFields(agg.filter, aggregationFilterClause(index).root, { fields, reportWithheld }) once per aggregations[i].filter, before filterClasses and filterJsonStored are computed, before bucketing, and without reading rows, so an empty row set, an empty grouped row set and a null-valued row set are refused alike. It is the same exported function engine.aggregate reaches through assertAggregationFilterIsEvaluable (:1013), with the same population (declaredJsonStoredFields(fields)), the same set and the same two texts from @objectstack/core, the same invalidFilterError envelope (INVALID_FILTER / 400) and the same diagnostic prefix At aggregations[i].filter…. Lowered or as-written: the walk descends $and / $or (array or single) and $not exactly as assertNodeIsEvaluable, the reference walker and matchesHaving do, every branch, no short-circuit, so a rule-3 $or with a $null arm is walked past the arm to the refused leaf. One gate call per filter, no second walk inside the entry point (the per-row arm in checkCondition is an evaluator arm kept as the backstop, not a walk of the filter), and no second copy: both positions read JSON_COLUMN_INCOMPATIBLE_OPERATORS and jsonColumnOperatorRefusalText from core. Precision, for the record: engine.aggregate's OTHER per-aggregation doors (unknown operator, the no-operator object, the nested-relation door, comparand type and number narrowing, the cross-field reference rule) are not carried by applyInMemoryAggregation, exactly as on main; they are not this card's and the changeset does not claim them.

  2. On the engine's own path the gate now runs twice — right, a redundant judgment, not a contract change. engine.aggregate still passes declaredFields to applyInMemoryAggregation (engine.ts :17451), so after the judgment at :17160 on typed (pre-resolution, pre-lowering, with the logger) the same function runs again inside the entry point on the resolved and lowered filter with a no-op reporter. It cannot refuse what the first passed: a filter token is a fully-wrapped string resolved value-for-value (core filter-tokens.ts walks values, never keys or operators), and the spec lowering's closed output vocabulary ($and, $or, $lt, $gte, $lte, $null) emits a comparison only from $between and $lte, both in the set, plus $null guards outside it; the field map is the same registry object. So the second judgment is idempotent and never logs. The PR body acknowledges it ("engine.aggregate passes none, because it has already judged and logged the same filter"). ③.

  3. A caller that passes no fields behaves exactly as on main — right. The gate sits under if (fields && anyFilter); without fields, filterClasses and filterJsonStored are undefined as before, matchesHaving passes jsonStored?.has(key) === true as false, both backstop arms in checkCondition are guarded by jsonStored, and reportWithheld is never read. The function body outside the new block is byte-identical to the merge-base (the diff touches the import list, the docblock, the parameter and the gate block only). A field map with no JSON-stored member returns at jsonStored.size === 0, also as before. Pinned: "no field map: nothing is judged" (m: 0).

  4. reportWithheld is public surface — right, and counted. applyInMemoryAggregation is on objectql's . entry (index.ts:366) and ./core entry (core.ts:78), the only two in package.json exports. An optional trailing parameter is a widening of a published signature and its .d.ts. The changeset's "Who is affected" names it as the fifth argument and says the diagnostic is dropped without it; the ADR-0087 why-text names it; the PR body lists it under yes (widening). objectql carries no api-surface baseline (only packages/spec does), so nothing to regenerate.

  5. assertAggregationFilterSparesJsonStoredFields is internal — right, and rightly not counted. It is exported from the module having-filter.ts, but having-filter.js is not re-exported from index.ts or core.ts (the only mentions are imports in engine.ts, in-memory-aggregation.ts and two door files), index.ts has no export * line, and package.json exposes no deep path. Its parameter narrowing to the fields and reportWithheld members of AggregationFilterDeclaration is internal too. The PR body says "not from the package root"; the changeset is silent on it. Both right.

  6. The backstop docblocks (round 1's F10 (a)) — corrected, right. The gate's docblock now names itself the complete door and names the spec lowering's rule 3 and the walker's short-circuit as the reasons a row never reaches the arm; checkCondition's arm says BACKSTOP and that its reach is the row's. That is ①.5 of the previous record, written into the source.

  7. Unchanged since 6e541b101, re-read at the head and still right: core's export * publishes three names (set, text builder, return type); driver-sql's move is byte-identical in the net diff's own lines, jsonColumnOperatorError (:3335) keeps its name, signature and withheldFilterError seam, the reader at :15950–:15952 is untouched, and the import at :153–:156 is the file's only reader of the core set after main's merge; the engine gate at assertAggregationFilterIsEvaluable runs after the reference rule and before getDriver; the population is one definition on both faces; $contains / $notContains / $exists / $null / $empty, having, where and every scalar column are untouched; the engine.ts log line is host-log wording.

  8. Round-2 tests — right as pins. The direct-caller block runs three negations ($ne, $nin, $not $in on meta, a json field) on four cells each: an empty row set, an empty grouped row set, null-valued rows with the filter lowered by spec lowerFilterCondition (its $null arm asserted present first), and the same rows as written. Each asserts INVALID_FILTER, 400, message strictly equal to engine.aggregate's for the same filter, the $contains prescription, the field withheld, and the diagnostic to reportWithheld with operator, field and At aggregations[1].filter.. Also pinned: no reporter gives the same refusal; no field map judges nothing; $contains still answers m: 2. The dev's ablation C (13 of 92 red with the call deleted) is the dev's measurement; the check-runs are the verdict.

② Semver level

  • The changeset is unchanged in level: objectql minor with the **BREAKING** banner and the fix(objectql)!: summary, core minor, driver-sql patch; Clause-②: yes (widening) in the changeset and on line 2 of the PR body; one adr-0087: not-required (no-migration-prescription) marker. All three packages are in the fixed group.
  • Round 2's edits match the diff: the why-text now reads "ADDITIONS only (three new core exports and one new optional trailing parameter on applyInMemoryAggregation)"; the banner names the published applyInMemoryAggregation given a field map as narrowing the same way; "Who is affected" names direct callers, the fifth argument and the no-fields case; the core paragraph counts three root exports with the type.
  • core minor — right: three new root names. objectql minor with the banner — right: an accept-set narrowing at engine.aggregate and at the published entry point given fields, declared where check-adr-0087-registration.mjs reads it (the banner and the bang), plus one additive optional parameter, which minor already covers. driver-sql patch — right: no published change.
  • The Clause-②: line — right. yes (widening) names the public-surface additions the diff makes: three core names and one optional parameter. The narrowing arm is carried by the banner and the bang, so nothing is under-declared at any gate.
  • Gate verdicts on the head: Check Changeset (pr-automation.yml, which runs check-adr-0087-registration.mjs and check-changeset-no-major.mjs) is success on the first run and on the re-run; Lint & Repo Gates success; Type Check · workspace, · consumer gates, · debt ledger, · source gates success; Build Core success. The two gate families the dev could not run locally need the built workspace; the CI jobs that build it are green.

③ Boundary flags

Round-2 report 5927016404: open_questions: []. Its one out-of-scope finding, the optional reportWithheld dropping the diagnostic when absent — answered: by design, declared in the changeset and the docblock; the entry point has no logger, a host that wants the diagnostic passes its log, and engine.aggregate logs at its own gate (①.4).

Round-1 flags, carried forward:

New this round:

  • N1 — the engine path's second, idempotent judgment inside applyInMemoryAggregation (①.2). Answered: harmless by construction. The seat may trim it later by having engine.aggregate skip the gate it has already run, or leave it; not a defect.
  • N2 — the engine's other per-aggregation doors are not carried by the published entry point (①.1). Pre-existing, not this card's; named for precision only.
  • N3 — check-runs on the head, read once: 41, all completed; 36 success; 5 skipped (Build Docs; Console Pin Gate; Packed-tarball smoke, opt-in; and the re-run's Check PR Size and Auto Label, whose first runs are success). Commit status Vercel: success. None in_progress: nothing is awaited.
  • The PR is still a draft; readiness and the landing are the owning seat's, not this record's.

No governed-surface path is in the file list (packages/spec is untouched). This at-tier record is the one the BREAKING changeset owes (claim 5924194074), re-rendered on the round-2 head.

Implemented-by: claude/issue-21007-aggregation-filter-json-equality
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 07:55
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 07:55
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit a11faee Oct 1, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21007-aggregation-filter-json-equality branch October 1, 2026 08:18
This was referenced Oct 1, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…rness text state each case in words instead of a tracker number (stage 5b) (objectstack-ai#21139)

Fixes objectstack-ai#20513
Clause-②: no

**Stage 5b, the last stage of this lane under the maintainer's A / A
ruling on the card (5902360492): the `src/`-shipped test strings.**
These are the 28 literals (32 occurrences in 6 files) that PR objectstack-ai#20924's
body inventoried as stage 5b and release 5922539397 restates: shared
case labels, a suite title, two contract `why` texts and the mongod
test-harness text. After this PR the `domain:engine` packages hold zero
rows in `scripts/doc-authoring-prose-id.baseline.json`. Text only: no
error `code`, export, HTTP status, case value or control flow moves.

## What this does

Each label or harness line sent the reader to a tracker number for the
reason behind it. In form D, as stages 1 to 5a applied it, the number
goes. Where the sentence already stated the case, only the citation goes
(17 literals). Where the sentence leaned on the number, it now says the
case in words (11 literals):

| Where | Cited | The text now says |
|---|---|---|
| `driver-mongodb` `test-mongod.ts` skip reason | 5517 | "retired from
default test runs because concurrent downloads made green runs exit 1" |
| `driver-mongodb` `test-mongod.ts` abandoned-download warning | 5517 |
"Ignoring the MongoDB binary download abandoned by a concurrent-download
race" |
| `ENGINE_DELETE_DISPATCH_CASES`: where.id plus a CAS operator, `multi:
false` | 11009 | "refused, the by-id path would drop the CAS guard" |
| `ENGINE_FINDONE_PREDICATE_CASES`: an empty `where` object | 3896 |
"read as match-every-row, so NOT a predicate" |
| `ENGINE_UPDATE_DISPATCH_CASES`: payload id beside a DIFFERENT scalar
where.id | 11142, 5748 | "refused; the payload id no longer silently
wins over a where.id naming another row" |
| same table: payload id beside an `$in` where.id, no multi | 11230 |
"refused; the by-id write would silently drop the declared row SET" |
| same table: payload id beside a NULL where.id | 11230 | "refused; a
declared where.id that is not one primary key is never silently dropped"
|
| same table: payload id beside a FALSY scalar where.id | 11142, 11230 |
"(it is a scalar, so neither the different-row nor the non-scalar
refusal applies)" |
| same table: where.id plus a CAS operator, `multi: false` | 11009 |
"the redeliver shape, refused rather than dropping its CAS guard" |
| same table: payload id plus an extra where predicate, no multi | 11009
| "refused; the by-id write would silently drop the predicate, through
the payload door" |
| `OBJECT_SCHEMA_MASK_CASES` `write-capable-caller/exempt` `why` | 7020,
6603 | "D4 is DERIVED from the schema write gate (`manage_metadata`)" |

Citation only: the contract suite's `serialized-form identity` group
title; 3 delete, 2 findOne and 9 update labels; the empty-readable-set
`why`; the mongod acquisition-timeout message. Each new clause was read
against today's code: `resolveEngineUpdateDispatch` (the different-row
refusal fires only on a truthy scalar where.id, the non-scalar refusal
only on a declared non-scalar), `ObjectQL.requireFindOnePredicate`
(absent, null and an empty object are read as match-every-row), and
`OBJECT_SCHEMA_WRITE_CAPABILITIES` (`manage_metadata`, the key the D4
exemption is derived from).

The `test-mongod.ts` docblock "Names the issue and the switch" now says
"Names the reason and the switch", so it stays true. No other comment or
docblock moves; code comments still cite ids, as in every earlier stage.

**Re-pins (H4).** The two `objectstack-ai#5517` number pins in
`mongodb-memory-server-gate.test.ts` now assert the text that replaced
the number: the skip line contains "retired from default test runs
because concurrent downloads made green runs exit 1", and the guard's
warning contains "download abandoned by a concurrent-download race".
Neither pin was deleted. Both were proven able to fail (below).

## Ledger (`scripts/doc-authoring-prose-id.baseline.json`)

Recomputed with `node scripts/check-doc-authoring.mjs --census-ledger`
(exit 0: no growth refusal) and copied into place. The diff is 25
deleted lines, nothing added.

| File | Before | After |
|---|---|---|
| `drivers/driver-mongodb/src/test-mongod.ts` | 3 | 0 |
| `metadata-core/src/contract-suite.ts` | 1 | 0 |
| `metadata-core/src/engine-delete-dispatch.ts` | 4 | 0 |
| `metadata-core/src/engine-findone-predicate.ts` | 3 | 0 |
| `metadata-core/src/engine-update-dispatch.ts` | 18 | 0 |
| `metadata-core/src/object-schema-fls-contract.ts` | 3 | 0 |
| **This stage** | **32 in 6 files** | **0** |

Whole ledger: files 175 → 169, pairs 441 → 428, occurrences 653 → 621.
Gate pinned sites 549 → 521. No other file's row moves. After each of
the four merges of `origin/main` (below) the recomputed ledger was
byte-identical to the committed one.

## Hypotheses, measured

- **H1, held.** At `origin/main` `9c8b65aa2` the six files held exactly
the inventory: `objectstack-ai#5517` ×3; `objectstack-ai#7856` ×1; `objectstack-ai#11009` ×4 (delete); `objectstack-ai#11767`,
`objectstack-ai#3896`, `objectstack-ai#4346` (findOne); `objectstack-ai#11009` ×6, `objectstack-ai#11142` ×4, `objectstack-ai#11230` ×4,
`objectstack-ai#5748` ×4 (update); `objectstack-ai#3545`, `objectstack-ai#6603`, `objectstack-ai#7020` (FLS). The gate's census
listed 28 literals carrying 32 occurrences.
- **H2, held.** The recomputed ledger has 0 rows under
`packages/objectql`, `core`, `formula`, `metadata*`, `platform-objects`,
`drivers/driver-*` and `plugins/plugin-pinyin-search` (the
`domain:engine` path set). The lane's ledger is empty. The whole ledger
is not, so the gate's stale-baseline arm keeps its blindness floor.
- **H3, nothing keys on the old labels.** Each of the 28 old literals
was searched byte for byte across the repo (`dist` excluded). Each was
found only in this stage's own source files (two delete labels have
update twins), with two exceptions. The tail `download — objectstack-ai#5517)` also
sits in a comment in `mongodb-pipeline-evaluator.testkit.ts`.
`serialized-form identity (objectstack-ai#7856)` also titles an independent `describe`
in `metadata-core/test/canonicalize.test.ts`, a test file outside the
ledger. Both are left alone. No CI workflow, `package.json` script,
vitest config, snapshot or skip list carries the labels or a `-t` filter
on them. The cited numbers appear elsewhere only in workflow comments,
an ADR anchor's invariant prose and a merge-parent sha. The labels reach
other code only as test names: objectql's `engine-*-dispatch` /
`engine-findone-predicate` parity loops, `service-queue`'s `it.each`,
and `assertObjectSchemaMaskCase`'s failure text. Each label stays unique
within its table (35 / 19 / 18).
- **H4, held.** See Re-pins.

## Changeset

`.changeset/20513-metadata-core-case-labels-state-the-case.md`: `patch`
for `@objectstack/metadata-core`. Its case tables ship in
`dist/index.*`, and the contract suite and FLS table ship in
`dist/testing.js` (measured after build: the new labels found in those
files).

There is **no `@objectstack/driver-mongodb` entry**, because
`test-mongod.ts` is not published. The package builds `src/index.ts`
only, and only test files import `test-mongod.ts`. Measured after build:
`mongodSkipReason`, "retired from default test runs",
"concurrent-download race" and "waiting for the MongoDB binary" are each
in 0 files under `packages/drivers/driver-mongodb/dist`. The positive
control `MongoDBDriver` is in 4.

## Text-only proof

A TypeScript-AST skeleton of each changed `.ts` file compares merge base
`670680e93` with head `24b72b809`. In the skeleton, every string literal
and template text is one placeholder, pure-literal `+` operands
collapse, and comments are never read. Result: 7 of 7 SAME, with token
and literal-slot counts identical per file. Control: the same tool
reports DIFF on `rest` `import-template.ts` across `6f1f1c103`, a real
code change.

## Tests (head `24b72b809` unless noted)

- Build: `turbo run build` over `./packages/*` and `./packages/*/*`
under the verify lock, 71/71, after the last merge.
- `@objectstack/metadata-core`: 16 files / 298 tests passed; `typecheck`
exit 0.
- `@objectstack/driver-mongodb`: 30 files passed and 5 skipped, 675
tests passed and 172 skipped (the skips need a MongoDB server);
`typecheck` exit 0, including `check:test-typecheck`.
- Consumers of the case tables, against the rebuilt `metadata-core`
`dist`:
- `objectql`, 7 files / 181 tests: `engine-delete-dispatch`,
`engine-update-dispatch`, `engine-findone-predicate`,
`engine-update-by-id-payload-id`, `engine-update-multi-payload-id`,
`engine-write-not-found-gate` and
`validation/operator-object-write-value`;
  - `service-queue` `db-queue-adapter`, 38;
  - `rest` `meta-object-fls`, 95;
  - `runtime` `domains/meta-object-fls`, 85;
  - `metadata-fs` `contract`, 31;
- `metadata-protocol` `sys-metadata-repository.contract` and
`protocol.update-path-id-wins`, 64.
- A verbose run before the merges showed the parity loops naming the new
labels. objectql's tests resolve `metadata-core` through `dist`, so this
shows they read the rebuilt build.
- Re-pin mutations, one-off, on the committed head (`2729901db`),
through `scripts/ablation-replace.mjs` in WRAP mode (restore armed on
EXIT/INT/TERM):
- Skip-reason anchor ×1 → ×0: `mongodb-memory-server-gate.test.ts` 1
failed / 7 passed.
  - Warning anchor ×1 → ×0: 1 failed / 7 passed.
- Each restore: blob equals HEAD's (`22a95643c8`), and `git diff HEAD`
is empty. After both, `git diff HEAD` was 0 bytes and `git status
--porcelain` 0 lines.

## Gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths) at `24b72b809` derived 69
commands. All 69 ran one at a time from the worktree, each exit 0.
`--ran` reports "69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN". Among
them:

- `check:doc-authoring`: 521 pinned sites across 169 files, no growth,
no burn-down unrecorded;
- `check:driver-conformance`: 50 covered cells, 0 DEBT, 0 exempt, the
same reading as before the change;
- `check:engine-double-contract`, `check:dual-build-cjs-loads` (105
require entry points across 66 packages load, read from the post-merge
build), `check:nul-bytes`, `check:type-check-coverage` and
`check:type-check-debt`.

After the first merge (`packages/spec` moved on main): `pnpm --filter
@objectstack/spec check:generated` reported all 15 artifacts up to date.

Narrowed lint: `eslint --no-inline-config --format json` over the 7
changed `.ts` files reported 7 files, 0 errors and 0 warnings, none
ignored (counts from eslint's JSON). The resolved `parserOptions` are
`ecmaVersion: latest, sourceType: module`, with no `project` or
`projectService`. So no type-aware rule runs, and this diff cannot move
an untouched file's verdict. Repo-wide `pnpm lint` is CI's.

NOT MEASURED locally (CI's): the live MongoDB cells of `driver-mongodb`,
the Test Core shards, Dogfood, and the workspace type-check lanes.

## Acceptance notes

- `origin/main` was merged four times while this was open (`1d55ee66e`,
`4f1c99822`, `4516a0643`, `24b72b809`; the third brings in objectstack-ai#21097). None
of the incoming commits touches a file this PR changes. They touch
`objectql`, `rest`, `runtime` and `metadata-protocol`, so those consumer
suites were re-run at the final head. `main` moved again after the gate
union; the queue rebuilds on it.
- PR objectstack-ai#21105, in `driver-mongodb`'s filter and temporal suites, landed
before the first merge and shares no file with this PR.
- `metadata-core/test/canonicalize.test.ts` titles its own `describe`
`serialized-form identity (objectstack-ai#7856)`, and
`mongodb-memory-server-gate.test.ts` titles two `describe` blocks
`[objectstack-ai#5517] …`. Both are `*.test.ts` files, which the ruling's third
category (`src/`-shipped test strings) and the ledger do not cover. They
are left as they are: noted, not filed.
- The lane children objectstack-ai#20749, objectstack-ai#20751 and objectstack-ai#20753 carry `Blocked-by:` lines
naming this card. They unlock when it leaves the open state on merge.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ntains and $like / $ilike as it refuses the equality family (objectstack-ai#21165)

Fixes objectstack-ai#21009
Clause-②: no (narrowing)

Patch round 1 executes the seat answer on objectstack-ai#21009 (5930243637):

- **Q1 → A.** The `$search` expander matches a multi-valued field by
membership, in this PR.
- **Q2 → A.** The shared sentence stays byte-identical; its rewrite
belongs to objectstack-ai#21067.
- **Q3 → A.** The ADR-0087 disposition is `not-required
(no-migration-prescription)`.

Head `143f4ccd8f` merges `main` at `d34aa58a2`.

## What changes

**`@objectstack/core`.** `JSON_COLUMN_INCOMPATIBLE_OPERATORS` is the one
set `driver-sql`'s `where` and objectql's per-aggregation `filter` both
read since objectstack-ai#21097. It gains the text operators other than the membership
pair:

- `$startsWith`, `$endsWith`, `$icontains`;
- the staged pattern pair `$like` / `$ilike`, which `driver-sql` answers
ahead of `FILTER_OPERATORS`.

On a JSON-stored column each now gets the `400` the equality family
already gets there:

- `INVALID_FILTER`, with the same withheld message, byte for byte;
- the operator and the field named in the server-log diagnostic, and in
the message for a filter marked as the caller's own.

Nothing else on that gate moves:

- `$contains` / `$notContains` (membership), `$exists`, `$null` and
`$empty` answer as before.
- No membership reading is invented for a prefix, suffix or case-folded
test.
- It is one edit to the shared set, with no second copy.
- `sql-driver.ts`, `having-filter.ts`, `remote-transport.ts` and
`driver-memory` are untouched.

**`@objectstack/objectql`.** The search expander (`search-filter.ts`,
`fieldClausesForTerm`) matches a field the object declares multi-valued
(`isMultiValueField`) by membership:

- a term matching option labels becomes one `$contains` per matched
option value, replacing the `$in` that is refused there;
- any other term, or any term on a field with no options (`tags`, a
multi-valued lookup), becomes `$contains` of the term.

The declaration is read from the field map the engine already passes in:
each entry is the object's whole field definition, `multiple` included.
No spec type moves. Scalar fields keep their clauses.

The visible cost: to hit a multi-valued field, a term must now equal one
of its members or match one of its option labels. SQLite used to match
substrings of the serialized array as well, so `wood` found a row tagged
`redwood`; it no longer does.

## Measured, before (`origin/main` `7a606a9a3`) and after

### The `where` / per-aggregation filter

Measured through `POST /api/v1/data/:object/query` on SQLite and a
private PostgreSQL 16.14. The fixture has six rows: `owners` is a
multi-value lookup (`d1` holds `u1, u2`; `d3` holds `u3, u1`; `d5` holds
only `u10`).

| filter on `owners` | SQLite `where`, before | PostgreSQL `where`,
before | per-aggregation `filter`, before | after |
|:--|:--|:--|:--|:--|
| `$startsWith: '['` | 5 rows, every row with a value | 500
`DATABASE_ERROR` | m = 0 | 400 on both faces and both dialects |
| `$startsWith: 'u1'` | 0 rows, though two rows hold `u1` | 500 | m = 0
| 400 |
| `$endsWith: ']'` | 5 rows | 500 | m = 0 | 400 |
| `$icontains: 'U1'` | `d1`, `d3`, `d5` (`d5` holds only `u10`) | 500 |
m = 0 | 400 |
| `$like` / `$ilike` | `d1`, `d3`, `d5` | 500 | 400, as an operator that
face does not evaluate | `where` 400 (this refusal); per-aggregation
unchanged |

- A `tags` field gave the same results.
- A `json` field was already refused all seven text operators at the
engine's declared-type door, which still answers first.
- The scalar `title` control answered the same rows before and after.

### `$search`

The search was measured through the same route, with `search`, on two
objects:

- the shape of `examples/app-todo`'s `todo_task.tags` (`select`,
`multiple: true`, in the auto-default set);
- a declared searchable `tags` field.

| search | `main`, SQLite / PostgreSQL (measured) | this branch before
the expander fix | now, SQLite and PostgreSQL (pinned) |
|:--|:--|:--|:--|
| task, a label term (`Important`, `quick`) | 400 (the `$in`) / 400 |
400 | 200, the rows holding the matched value |
| task, a term only the scalar subject holds (`meeting`) | 200 / 500 |
400 | 200, by the subject |
| note, a member (`red`) | 200, `n1` and `n2` / 500 | 400 | 200, `n1`
(member) and `n2` (scalar title) |
| task, a raw member (`quick_win`); note, a member (`redwood`) | not
measured on `main` | — | 200, the rows holding it |
| task, a non-member (`zebra`); note, a substring of a member (`wood`) |
not measured on `main` | — | 200, no rows |

No term answers 400 or 500 any more. The scalar controls (a `select`
label, a text fold) are unchanged.

### H2, H3, H4

**What a caller reads (H2).** For `$startsWith` on `owners`, the REST
body is cut at the envelope's 500 characters:

```text
A constraint in this filter WAS NOT APPLIED: it aims a scalar comparison operator at a field this driver stores as a JSON TEXT column (e.g. ["a","b"]), and such an operator compares that whole serialized text against a single value — it can never equal one member. Use "$contains" for membership ({ "FIELD": { "$contains": "a" } }), or an $or of "$contains" for any-of ({ "$or": [{ "FIELD": { "$contains": "a" } }, { "FIELD": { "$contains": "b" } }] }). Refused rather than compiled because the answ…
```

Per the seat answer, it stays byte-identical here, and objectstack-ai#21067 owns the
rewrite.

**Turso remote (H3).** `RemoteTransport.buildWhereSQL` compiles its own
filters and has no JSON-column gate at all, for the equality family
included. This PR leaves it alone, and it is reported for filing.

**driver-memory (H4).** It answers each text operator per element. It is
unchanged here; once objectstack-ai#21066's shape gate reads this set, it refuses them
too. The seat answer orders this PR ahead of PR objectstack-ai#21159.

## Pins

- `core` `json-column-operator-refusal.test.ts`:
  - the set, member for member;
  - every text operator except the membership pair is in it;
- each of the five reads the equality family's message (its SHA-256 and
length).
- `driver-sql`
`sql-driver-21009-json-column-text-operator-refusal.test.ts` (new) is a
dialect-cell suite: SQLite always, PostgreSQL and MySQL where
provisioned, and the Temporal Conformance job provisions both. On a
multi-value lookup and a `tags` column, each of the five gets:
  - `INVALID_FILTER` / `400` through `find` and `count`;
  - the operator and field named to an author;
  - for anyone else, the equality family's message, byte for byte.

The same file pins the scalar control (exact rows) and membership still
answering.
- `driver-sql` `sql-driver-json-column-operator-refusal.test.ts`: the
text family moves from the keep-working list to the refused list, on
every face.
- `driver-sql` `sql-driver-17590-…` and `sql-driver-17343-…` held the
text family "unmoved" or "compiling" on a JSON column. They now pin the
refusal on all three compilers, with the scalar column unmoved.
- `objectql` `engine-aggregate-filter-json-column-refusal.test.ts`: the
text family on the per-aggregation filter and its per-row floor; a
structured-JSON field still meets the declared-type door first.
- `objectql` `search-filter.test.ts`: membership clauses for a
multi-valued `select` (label, partial label, no label), for `tags` and
for a multi-valued lookup, with the scalar `select` and text controls.
- `rest` `data-search-multi-valued-membership.test.ts` (new) runs the
table above through `POST /api/v1/data/:object/query` with `search`, on
SQLite and PostgreSQL, with MySQL where provisioned.
- `rest` `aggregation-filter-json-column-refusal.test.ts`: the text
family on both faces, with the per-aggregation body equal to the `where`
twin's.
- The dogfood `search-conformance.ledger.ts` summary now names
membership for a multi-valued field. That half's HTTP proof is the REST
file above, because no showcase object carries one in its search set.

## Reverse verification

Both fixes were committed before each ablation. Each restore leg proved
the file's blob equal to HEAD and an empty `git diff HEAD`.

**The core set.** The ablation deleted the five new members (blob
`8799778c` to `cbf406f9`), rebuilt, and the preflight found the members
`--absent`.

| suite | with the members deleted | after the restore |
|:--|:--|:--|
| core | 2 failed of 8 | 8 passed |
| driver-sql | 82 failed of 220 | 219 passed, 1 skipped |
| objectql | 13 failed of 106 | 106 passed |
| REST | 20 failed of 195 | 130 passed, 65 skipped (MySQL) |

**The expander.** The membership branch was disabled (`&& term ===
'ablated-21009'`, blob `44a09d96` to `61401d11`), objectql rebuilt, and
the preflight found the marker present.

| suite | with the branch disabled | after the restore (rebuilt, marker
`--absent`) |
|:--|:--|:--|
| objectql `search-filter.test.ts` | 3 failed of 20 | 20 passed |
| REST search file | 20 failed of 22: every search case on SQLite and
PostgreSQL answered 400 `INVALID_FILTER` | 22 passed, 11 skipped (MySQL)
|

Both moved in the expected direction: the pins turned red.

## Tests and gates (head `143f4ccd8f`)

| suite | result |
|:--|:--|
| driver-sql, full | 4244 passed, 96 skipped (SQLite and PostgreSQL;
server at Asia/Shanghai, process at America/New_York) |
| driver-turso, full | 2218 passed, 33 skipped |
| core pins | 8 passed |
| objectql pins | 126 passed |
| REST pins | 152 passed, 76 skipped (SQLite and PostgreSQL) |
| ADR-0061 dogfood proof (`showcase-search.dogfood.test.ts`) and the
search-conformance ledger | 7 passed, exit 0 |

The full suites at `76d2fd5e8` (`main` `bafb8c949` merged; the last
merge brought only `sql-driver.ts`'s sequence region and `driver-turso`
into these packages) were:

| suite | result |
|:--|:--|
| objectql `local` | 7070 passed |
| REST (SQLite) | 4970 passed, 302 skipped |

- Typecheck passed for core, driver-sql, objectql, REST and dogfood.
**MySQL: NOT MEASURED locally** (no server); CI's temporal job runs it.
- Gates: `dispatch-gates --commands` was derived at `143f4ccd8f` with no
paths. It named 70 commands, and 69 exited 0.
`check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, a
whole-workspace build): NOT MEASURED. `--ran` reconciled 70 derived, 69
run, 1 NOT MEASURED, 0 UNRUN. The derivation was stale by one `main`
commit, a production-dependency bump (`f3b16fc2f`) that changes
`package.json` only.
- Driver conformance: 50 / 0 / 0 before (`7a606a9a3`) and after
(`143f4ccd8f`).
- Lint was narrowed to the 12 changed `.ts` files. The proof has three
parts:
  1. each file resolves a config under `eslint --print-config`;
  2. `--format json` reports 12 files, 0 errors and 0 warnings;
3. `eslint.config.mjs` never enables type-aware linting, so no untouched
file's verdict can move.
- The changeset is `@objectstack/core` and `@objectstack/objectql`, both
`minor`, BREAKING. It states the search cost. Its ADR-0087 disposition
is `not-required (no-migration-prescription)`.

## Acceptance notes

- `SqlDriver.isNonTextColumn`'s docblock says "a text operator is legal
against a JSON column". That now holds for the membership pair only.
Carrier: none; it is outside this claim's surface.
- The registered migration entry
`filter-text-operator-declared-type-refused` names `multiselect` /
`checkboxes` / `tags` and lookup ids as fields that must keep answering
exactly as before. That over-claims once this lands. The seat records it
as a spec-lane wording finding, filed at landing.
- A view-filter builder offering "starts with" or "ends with" on a
multi-valued field now gets a loud 400. Carrier: the objectui filter
builder.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…lared JSON-stored field, in the SQL family's words (objectstack-ai#21066) (objectstack-ai#21159)

Fixes objectstack-ai#21066
Clause-②: yes (narrowing)

On a field the object declares JSON-stored (a `multiple: true` field,
`tags` / `multiselect` / `checkboxes`, or a structured-JSON type such as
`json`), `driver-memory` now refuses the scalar-comparison family that
`driver-sql`'s `where` refuses: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`,
`$lte`, `$between`, `$in`, `$nin` and implicit equality, whatever the
comparand, at any depth. The answer is `INVALID_FILTER` / 400 with the
same message. The operator set and the sentence are read from
`@objectstack/core` (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`,
`jsonColumnOperatorRefusalText`, homed by PR objectstack-ai#21097). There is no third
copy. `$contains` / `$notContains` (membership), `$null`, `$exists` and
`$empty` keep answering.

## What was wrong (H1, measured at `origin/main` `670680e93` through
`engine.find`)

A real `ObjectQL` over `InMemoryDriver`, objectstack-ai#21004's six rows (`owners` is
a `multiple: true` lookup, `tags` is a `tags` field). Every row
reproduces the card:

| `where` | before | now |
|:--|:--|:--|
| `owners` `$eq 'u1'` | `d1`, `d3` (per element) | 400 `INVALID_FILTER`
|
| `owners` `$in ['u1','u9']` | `d1`, `d3` | 400 |
| `owners` `$nin ['u1','u9']` | `d2`, `d4`, `d5`, `d6` | 400 |
| `owners` `$gt 'u1'` | `d1`, `d2`, `d3`, `d5` | 400 |
| `tags` `$gt 'red'` | `d3` | 400 |
| also: bare `{ owners: 'u1' }`, `$ne`, `$gte`, `$lt`, `$lte`,
`$between`, `tags $eq`, `{ owners: null }`, `$eq null`, `$ne null`, `$in
[]`, `$nin []` | rows, per element | 400 |
| controls: `owners $contains 'u1'` / `$notContains` / `$null` /
`$exists` / `$empty`, `title $in` | rows | unchanged rows |

The engine hands the driver the operators as written, except `$ne` /
`$nin`. Those arrive inside the spec's null-safe lowering (`$and` of
`$or` of `$null: true` and the operator). The gate walks `$and` / `$or`
/ `$not`, so that shape is refused too.

The analytics face (`MemoryAnalyticsService`) answered the same
per-element rows. Its SQL echo rendered `owners = 'u1'`, which matches
no row over the JSON text the SQL family stores. It now refuses in
`query()` and `generateSql()` alike.

## What changed

- `filter-refusal.ts`: the shape gate (`assertFilterConditionShape`)
takes an optional `FilterFieldDeclarations` (`isJsonStoredField`,
`reportWithheld`). It has two arms. Implicit equality on a declared
JSON-stored field is refused as `=`, bare. Any operator in the shared
set is refused AFTER the existing comparand-shape rules, which is
`driver-sql`'s order (comparand gate, then column-type gate). So an
array under `$eq` or a one-element `$between` still gets its own refusal
first. `jsonStoredFieldOperatorError` builds the error from the shared
text: this package's `unsupportedFilterError` envelope, with the
withheld diagnostic handed to `reportWithheld` (prefixed `At PATH:`)
before the throw.
- `memory-driver.ts`: `convertToMongoQuery` passes
`this.filterFieldDeclarations(object)`. The population is
`isJsonStoredField`, the predicate `$contains` already forks on
(`STRUCTURED_JSON_TYPES` or `isMultiValueField`). So the fields where
`$contains` asks membership are exactly the fields where the family is
refused. The diagnostic goes to the driver's logger at `warn`, the level
`driver-sql` uses for its withheld filter diagnostics. That keeps the
message's "the full diagnostic is in the server log" true here.
- `memory-analytics.ts`: `normalizeFilters` takes the cube. It judges a
`where` key (a cube member) by the field it maps to on the cube's table,
the same (table, field path) pair `filterContainsTest` reads. Its
diagnostic goes to the analytics service's own logger.
- `.changeset/21066-memory-json-column-family-refusal.md`:
`@objectstack/driver-memory` `minor`, BREAKING banner, `Clause-②: yes
(narrowing)`, one ADR-0087 marker `not-required
(no-migration-prescription)`. No registered id covers a filter operator
on a JSON-stored column. The one migration-registry entry that mentions
json columns (`cel-predicate-one-value-comparand-refused`) is the CEL
list-comparand surface, not this one.

## Hypotheses, measured

- **H1** holds: the table above.
- **H2.** The shared home is `@objectstack/core`'s
`json-column-operator-refusal.ts`, and both names are read. Before this
change `driver-memory` had NO withheld-diagnostic seam: every refusal it
raises (the `$null` / `$exists` non-boolean refusals included) names the
field in the message, and nothing in the package logged a diagnostic.
This change keeps the shared posture: the message names neither field
nor operator, and the diagnostic goes to the server log.
- **H3.** `driver-sql` decides a JSON column from `jsonFields`, filled
from `JSON_COLUMN_TYPES.has(type) or isMultiValueField(field)`.
`JSON_COLUMN_TYPES` is `STRUCTURED_JSON_TYPES` plus `MULTI_OPTION_TYPES`
plus the driver-internal `object` / `array` aliases. Memory's population
is the same predicate less those aliases and less a single-value media
field on an unmoved deployment (both recorded on `isJsonStoredField`).
On a schemaless direct call (an object never passed through
`syncSchema`), nothing is judged. Every operator answers per element as
before, as `SqlDriver.isJsonColumn` answers `false` for a table it was
never told about. Pinned. A field declared SCALAR (`text`) that holds an
array is not judged either.
- **H4.** `@objectstack/formula`'s `ORDERING_OPERATORS` docblock does
NOT declare a per-element reading for the query plane. It records a
non-alignment ("driver-memory's read, a frozen test driver, compares a
stored list element by element and keeps returning those rows ...
declared on objectstack-ai#15104"). objectstack-ai#15104 is the `$field` cross-field reference card,
shut as `not_planned` under the driver-memory investment freeze. It
rules nothing about the equality or ordering family on a stored list. So
this is a formula-plane record of observed behaviour, not a query-plane
contract, and no contract conflict stops the card. That docblock
sentence goes stale on declared fields once this lands (see Acceptance
notes).
- **H5.** objectstack-ai#21009 widens the same shared set to the text operators. Both
gates here read the set live, and the new suite iterates
`JSON_COLUMN_INCOMPATIBLE_OPERATORS` intersected with this driver's
vocabulary, with a floor of the nine `$`-spellings. So once both land,
memory refuses `$startsWith` / `$endsWith` / `$icontains` on these
fields with no edit here, and the suite pins them. Whichever of the two
lands second merges `main` and checks the other's members on its face.
The suite's `$contains` control is outside objectstack-ai#21009's scope.

## Pin sweep

- The ONE per-element pin the package carried on a declared field
flipped: `memory-20444-empty-operator.test.ts` had `{ tags: { $empty:
true, $ne: null } }` giving `r2`. It is now a refusal pin (`code` +
`status` + the shared message). The composition (`$empty` beside a
has-a-value sibling on one multi-value field) is kept through `$null:
false`, which answers `r2`. `driver-sql`/SQLite answers that row too,
and refuses the `$ne: null` spelling with the same body (measured on the
built driver).
- `memory-matcher-scalar-comparand-array-value.test.ts` pins per-element
answers on a column declared `text`. Those cells still hold, and a
header note now says the population there is a scalar-declared column.
- Repo-wide: only two tests outside this package bind the real driver
(`packages/runtime`'s two ruled consumers). Neither filters a
JSON-stored field. No other `INVALID_FILTER` pin moves.

## Tests (final head `13407b76f`)

- `pnpm --filter @objectstack/driver-memory exec vitest run
--maxWorkers=2`: **70 files, 1703 passed**. The first run after the
implementation, before any test edit: 69 files, 1 red of 1613, the
per-element pin flipped above.
- `pnpm --filter @objectstack/driver-memory run typecheck`: exit 0. `tsc
--listFiles` includes both edited test files.
- New `memory-21066-json-column-family-refusal.test.ts` (89 tests):
  - the card's five rows;
  - every family member on `owners` / `tags` / `meta` (`json`);
- ten shapes per field (bare, bare null, `$eq null`, `$ne null`, the
engine's `$ne` lowering, `$in []`, `$nin []`, under `$not`, an `$or`
branch after a holding one, `$eq` beside `$contains`);
- `count` / `findOne` / `updateMany` / `deleteMany` refusing with the
table untouched;
- the withheld message plus the logged `At filter.$or[1].owners.$gte:`
diagnostic;
  - comparand-first ordering;
  - eleven answered controls;
- the declaration boundary (undeclared object, scalar-declared column,
the gate with and without declarations);
- the analytics face, `query()` and `generateSql()`, including the
`cube.member` spelling, plus its log line and a `$contains` control.
- Ablations (`node scripts/ablation-replace.mjs`, wrap mode, run at
`de1fef341`; the two later merges touched no file in this package; each
restore proven blob == HEAD with `git diff HEAD` empty). The subjects
are this package's `src`, imported relatively, so no `dist` is involved:
- **A** `filterFieldDeclarations`' predicate forced to `() => false`:
**73 red / 37 green** of 110 across the new file and 20444. The 17 green
in the new file are exactly the answered controls, the
declaration-boundary trio, the premise, the comparand-first case and the
analytics `$contains` control.
- **B** the implicit-equality arm disabled: **7 red**, exactly the six
bare cases and the analytics bare case.
- **C** the operator arm disabled: **67 red**, every operator-based
refusal pin, the direct-gate test and the 20444 flip.
- Gate union, derived with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no paths; 7 changed
paths, working tree clean) at `13407b76f`: **60 derived, 60 run, every
one exit 0**. Reconciled with `--ran`: "60 derived famil(ies) accounted
for — 60 run, 0 NOT-MEASURED (a DERIVED zero — all 60 recorded an exit
code and none of them is 3)". The same 60 also ran all-zero at the
previous merge head `5b75fe461`. At `de1fef341`,
`check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, no dist
yet); it measured on both later heads.
- Driver conformance ledger (`node
scripts/check-driver-conformance.mjs`), before and after:
byte-identical. 50 covered cells, 0 DEBT, 0 exempt. The shared matrix
has no JSON-column or multi-value case-set, so this invariant is held by
the per-package pins, not the matrix.

## Acceptance notes

- **The class gains one method.**
`InMemoryDriver.filterFieldDeclarations` is tagged `@internal`. It is
not private only because the analytics face is another class.
`FilterFieldDeclarations` is not exported from the package root, but the
method does appear in the published `.d.ts`. objectstack-ai#20984 graded the analogous
public `filterContainsTest` as a surface widening (`Clause-②: yes
(widening)`). The seat graded it so (5929927010): the line is `yes
(narrowing)`, with the semver (`minor`) and the ADR-0087 marker
unchanged.
- **Surface beyond the claim's list.** `memory-driver.ts` and
`memory-analytics.ts` are edited. The gate cannot see a declaration on
its own, so the plumbing is the minimum the direction needs, and the
analytics face calls the same gate. No open PR touched either file when
read before the first edit.
- **The AST comparison-node door** (`{ type: 'comparison', field:
'owners', operator: '=', value: 'u1' }`) still answers per element on a
declared field: `d1`, `d3`, measured on the built driver. No seam emits
that form (the engine and the protocol hand a driver a FilterCondition),
so it is reachable only by a direct driver call. Left alone.
- **The shared sentence's mechanism clause** ("a field this driver
stores as a JSON TEXT column", "$in/$eq matched nothing") is
`driver-sql`'s, and is literally untrue of this driver and of the
engine's per-aggregation face. The prescription (`$contains`, an `$or`
of `$contains`) is right on all three. Inherited as objectstack-ai#21007 shipped it.
objectstack-ai#21009 is the PR that next edits the shared home.
- `@objectstack/formula`'s `ORDERING_OPERATORS` docblock
("driver-memory's read ... keeps returning those rows") is now true only
of undeclared objects. It is a comment, and no claim holds that file.
- **Tooling.** With the `turbo` 2.10.10 to 2.11.5 bump now on `main`,
every repo-scoped turbo run in an agent session appends a managed
"turborepo-agent-rules" block (an HTML-comment-delimited section) to
`AGENTS.md`. These include `pnpm exec turbo run build`, `pnpm
check:type-check-debt`, `check:query-options-erasure` and
`check:slot-lookup`. It happened repeatedly in this worktree and was
restored each time, and every gate derivation above was taken on a clean
tree; this PR does not touch `AGENTS.md`. Tracked as objectstack-ai#21146 (PR objectstack-ai#21151).

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants