Skip to content

fix(driver-memory): $contains on a multi-valued or JSON-stored field is membership, on every face - #20984

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20874-memory-contains-membership
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20874-memory-contains-membership

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #20874
Clause-②: yes (widening)

What changes

driver-memory now answers $contains and $notContains on a declared JSON-stored field by whole-element membership, the reading driver-sql compiles on all three dialects. A field counts as JSON-stored when it is multiple: true, a multiselect / checkboxes / tags field, or a STRUCTURED_JSON_TYPES member such as json. A scalar text column keeps the case-exact substring test. The change covers every face of the package:

  • the query path's $-spelling (normalizeFieldOperators) and its AST spelling (convertConditionToMongo): find, count, and every verb that goes through convertToMongoQuery;
  • the analytics (cube) face's mingo $match;
  • the analytics face's SQL echo (generateSql), which now renders SQLite's json_each membership construct for such a column, so the echoed statement still returns the rows the chart was drawn from.

One rule backs all of them: InMemoryDriver.filterContainsTest(object, field, value). On a JSON-stored field it returns { $elemMatch: { $in: members, $not: { $type: 'array' } } }, and on any other field { $regex: filterSubstringPattern(value) }. $notContains is $not over that test, on both faces. The members come from the comparand's text, read the way driver-sql's jsonMembershipCandidates reads it: '1' names the string or the number 1, '1.50' names 1.5, and 'true' / 'false' / 'null' name the string or the JSON literal.

The $contains docblock in packages/spec/src/data/filter.zod.ts lost its stale "driver-memory DOES NOT ANSWER IT YET" bullet. Its dead tracker link went with it (that card answers 404). The replacement text states the measured status, and states that the other text operators over a stored array are not ruled by that section.

Measured on origin/main f6ccca4a before the change, and after (HEAD 10656601)

Fixture: driver-sql's #17590 fixture plus a multi-valued lookup owners (['u1','u2'], ['u10'], ['u3','u1'], []).

where memory before SQLite (driver-sql) memory after
{ owners: { $contains: 'u1' } } 1, 2, 3 1, 3 1, 3
{ owners: { $notContains: 'u1' } } 4 (2 dropped) 2, 4 2, 4
{ tags_: { $contains: 'red' } } 1, 2 1 1
{ nums: { $contains: '1' } } (members are numbers) none 1 1
{ label: { $contains: 'red' } } (scalar control) 1, 2 1, 2 1, 2
the cube face, the same filters the query path's rows n/a the query path's rows

engine.find on a real InMemoryDriver with the nested-relation filter { owners: { region: 'NA' } } (owner u1 NA, u10 EU; this is PR #20872's lowering): d1, d3, d5 before and d1, d3 after. Its $not gave d2, d4 before and d2, d4, d5 after, which matches the rest suite's SQLite rows. That was a one-off run (a throwaway probe file, not committed): this package may not import the engine and the engine's packages may not import this driver (check:driver-memory-census). What is pinned instead is the driver input the engine sends, an $or of one $contains per related id, which objectql's engine-nested-relation-lowering.test.ts asserts.

Mechanism hypotheses (order Zone 2)

  • H1 confirmed. The $contains arm lowered to an escaped $regex, and mingo applies a $regex to each element of an array value. Reproduced on the fixture above before any edit.

  • H2: the fork is by the DECLARED field, not the row's runtime shape. Memory has the declaration (valueShapes, recorded by syncSchema since the $empty work), and SQL forks on its JSON-column registry, which is also filled from the declaration. The population is the spec's JSON-stored classes: STRUCTURED_JSON_TYPES plus isMultiValueField, the two halves driver-sql's registry is built from. The two readings DO give different rows on one fixture, so the choice goes to the PM in the report's open_questions: a declared json field holding the scalar string 'u1'. SQLite answers no member, because its constructs are array-only. A per-row-shape fork would answer it by substring. The declared fork matches SQLite, and the contract text says the question is "selected by the COLUMN", declared metadata. Undeclared fields (an object never passed through syncSchema) keep the substring reading, as SqlDriver.isJsonColumn answers false for a table it was never told about.

  • H3 confirmed and pinned. Number members answered nothing; '1', '2', '10', '0' and '1.50' now give SQLite's rows.

  • H4 confirmed: $notContains diverged. It is the mirror arm of the same defect, changed under os-dev rule 3's bounded in-place exemption. All four conditions hold:

    1. Same defect class.
    2. Mechanical, with its shape pinned by SQL's col IS NULL OR NOT (…): $not over the test admits null and missing rows, pinned on both fixtures.
    3. memory-driver.ts is held by no other claim; the sibling $exists card is kept off it by its own claim.
    4. Same tests, no new gate.

    The claim's surface should be amended to include it.

  • H5 confirmed. The cube face borrowed filterSubstringPattern and wrapped it in its own $regex, so it had the same defect. It now takes the driver's whole test. The echo reads its member set off that same test, so the chart and its echo cannot name different sets.

  • H6 confirmed. See the engine.find run above.

Compile-surface conclusions

# face conclusion
1 driver-sql applyFilterCondition (driver-sqlite-wasm, driver-turso local inherit it) already compliant: applyJsonMembership emits membership on JSON columns. Evidence: the #17590 suite now carries a multi-valued lookup column and the u1/u10 case, green on SQLite here. Its live PostgreSQL/MySQL cells are unprovisioned locally; the required live job runs the whole driver-sql suite. The two inheriting drivers were not separately run.
2 turso RemoteTransport.buildWhereSQL out of scope (another package). By reading, its $contains arm emits pushLike (a GLOB substring) on every column, JSON columns included, so remotely u1 would match ["u10"] while the local transport answers membership. Not measured. Reported as a finding.
3 service-analytics read-scope-sql compileScopedFilterToSql out of scope (another package). Measured: { owners: { $contains: 'u1' } } on a declared lookup + multiple: true field compiles to instr("t"."owners", ?) > 0 on SQLite and LIKE '%u1%' on PostgreSQL/MySQL, a substring over the stored JSON text. This is an RLS read-scope face, so it is reported as a security-relevant finding.
4 service-analytics filter-normalizer lowerAnalyticsWhere out of scope. By reading, it lowers $contains to the cube contains operator, which the native SQL strategy renders as LIKE, a substring. Reported.
5 formula matchesFilterCondition out of scope. By reading, the arm is typeof actual === 'string' && actual.includes(v), so a stored array never matches (fail-closed). Reported.
half objectql having-filter out of scope by the claim (serial behind another card). Untouched.
unfrozen driver-memory query path and cube face (memory-analytics.ts), behind filter-refusal.ts changed (this PR). filter-refusal.ts and the $exists arm are untouched (sibling card).
unfrozen driver-mongodb translateFieldOperators out of scope (another package). By reading, it lowers $contains to a native $regex, which MongoDB applies per array element: the same defect. Not measured. Reported.

Why the shared pin is a mirrored literal table, not FILTER_TEXT_CASES (order Zone 3, not taken)

FILTER_TEXT_CASES has no array column, so adding one changes the fixture of all five enrolled drivers. driver-mongodb imports every row of it and still carries the per-element defect, so its suite would go red. The table's own rule 2 says rows join a driver's suite in the PR that ends that driver's gap. Doing it here would also widen the spec touch beyond the one declared docblock. A new sibling case-set would add DEBT rows to a ledger that only goes down. So the new memory file mirrors driver-sql's #17590 fixture row for row and asserts the same literal row sets. That is how the #17590 file already holds its three dialect cells to one answer.

Files

  • packages/drivers/driver-memory/src/memory-driver.ts: the population (isJsonStoredField), the members, the one test (filterContainsTest), both query-path spellings.
  • packages/drivers/driver-memory/src/memory-analytics.ts: the $match rows take the driver's test; the echo renders membership (sqliteMembershipPredicate).
  • packages/drivers/driver-memory/src/memory-20874-contains-membership.test.ts (new): two fixtures. The first is the driver-sql: the $contains MEMBERSHIP spelling on any multi-valued / JSON column is a DATABASE_ERROR 500 on live PostgreSQL (SQLSTATE 42883, operator does not exist: json ~~ text) — it has only ever been executed on SQLite #17590 fixture plus owners. The second holds the stored shapes SQLite decides: a scalar, an object, a nested array, [null], [[null]], [true, 1.5], [''] and a NULL row, each answer measured on driver-sql/SQLite first. Every case runs on find in both spellings, on count, and on the cube's $match. The cube's echo is EXECUTED on sql.js over the same rows. The file also covers the nested-relation driver input and the fork.
  • packages/drivers/driver-sql/src/sql-driver-17590-json-column-membership.test.ts: an owners multi-valued lookup column and the u1/u10 case (the claim's SQLite pin).
  • packages/spec/src/data/filter.zod.ts: the one declared docblock.
  • .changeset/20874-memory-contains-membership.md: minor, not the patch the order suggested. filterContainsTest is a new public method on the exported InMemoryDriver. It ships in dist/index.d.ts (the existing filterSubstringPattern appears there as the positive control), and the level ruling quoted in pr-automation.yml (WHICH LEVEL) grades an additive widening of a published package's public surface at least minor. Clause-②: yes (widening): the new public method widens the published surface; no filter key or operator is added.
  • Sweep, beyond the claim's listed surface. The order's pin sweep asks for every same-semantics statement to be flipped in one round. Each item below is an edit to a statement this change makes false:
    • packages/rest/src/data-nested-object-door.test.ts (a header comment that cited the gap and the removed docblock clause);
    • .changeset/20802-nested-relation-filter-served.md, one sentence of a pending release note that said the in-memory driver still matches per element.

Confirmation requested: a pending release note is corrected

This PR changes .changeset/20802-nested-relation-filter-served.md, a pending release note this PR did not add. This is the DELIBERATE CORRECTION class check-empty-changeset.mjs names, so Check Changeset stays red on purpose. It is not a required context. skip-changeset must not be applied. The note's last sentence said:

On the in-memory driver, a multi-valued relation's $contains still matches a stored id by substring per element, so there an id that is a substring of another stored id (u1 inside u10) also matches; SQLite and PostgreSQL match the element.

This PR makes that false, so it now reads:

SQLite, PostgreSQL and the in-memory driver match the element of a multi-valued relation, so an id that is a substring of another stored id (u1 inside u10) does not match it.

Please confirm the correction on this PR. If the release that consumes that note ships before this PR lands, the edit should be dropped on rebase.

Tests (HEAD 10656601)

  • pnpm --filter @objectstack/driver-memory typecheck passes, and vitest run gives 68 files / 1553 tests passed. Both typecheck programs include the new test file (--listFiles).
  • The new file alone gives 113 passed.
  • pnpm --filter @objectstack/driver-sql typecheck passes (its program includes the edited test). sql-driver-17590-json-column-membership.test.ts gives 22 passed, 2 skipped; the skips are the live PostgreSQL/MySQL cells, unprovisioned here, NOT MEASURED locally.
  • pnpm --filter @objectstack/spec typecheck passes. spec build and then check:generated report "All 15 generated artifacts are up to date".

Reverse verification. The code change was committed first. Then memory-driver.ts and memory-analytics.ts were checked out at BASE under an EXIT/INT/TERM restore trap. The landed mutation was verified on disk before the run: filterContainsTest count 0, two escapeRegex(val) arms back, and both files byte-equal to BASE.

The predicted direction was that the cases where per-element substring and membership disagree go red and the agreeing ones stay green. Measured: 69 failed / 44 passed. The failures were every disagreeing case, on all four faces, plus the executed echo, the relation-lowering input and the fork pin. The label controls and the redwood, ab, u10 and '0' cases stayed green. The restore was proven by blob hashes equal to HEAD (d376dadb… / 9de9198b…), an empty git diff HEAD and clean porcelain.

Guard ablation (scripts/ablation-replace.mjs, anchor 1 to 0). Dropping $not: { $type: 'array' } turned exactly the nested-array cases red: 12, the json 'u1', its complement and 'null', on all four faces. The executed echo stayed green, since SQLite is the oracle there. The file was restored to the HEAD blob with an empty git diff HEAD.

Lint (narrowed, a measurement).

  1. Population: eslint.config.mjs lints **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} minus NEVER_LINTED. The six changed TS files are all in it; the two changesets are not.
  2. eslint --no-inline-config --format json over those six files reports 6 files, 0 errors, 0 warnings at 10656601.
  3. The config never enables type-aware linting (no parserOptions.project, no typed rules; it says so itself), so this diff cannot move any untouched file's verdict.

Driver conformance ledger: node scripts/check-driver-conformance.mjs reads "50 covered cell(s), 0 in the DEBT ledger, 0 exempt" both at f6ccca4a (before) and at 10656601 (after).

Gates: dispatch-gates --commands was re-derived at 10656601. That gives 84 commands, six more than the dispatch list: engine-double-contract, objectql-double-limit, query-options-erasure, type-check-coverage, type-check-debt and where-matcher. The run also covered the eight roster gates flagged as sharing a directory with these paths. All exit 0 except:

  • check-empty-changeset: exit 1, the deliberate correction above.
  • check:dual-build-cjs-loads and check:type-check-debt: exit 3, PREREQUISITE NOT MET (they need the whole workspace built). NOT MEASURED; CI's Build/Lint jobs own those.
  • check:lean-entry-closure: exit 3 at first. It passed after building objectql.

Acceptance notes (observed, not filed by this PR)


Generated by Claude Code

…ership, on every face

A multi-valued or JSON-stored field now answers `$contains` / `$notContains`
by whole-element membership (the SQL family's reading), on the query path's
two spellings and on the analytics face's `$match` and its SQLite echo. A
scalar column keeps the case-exact substring test. The fork is the field's
declared storage shape, read from the declaration `syncSchema` recorded.

Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG
Co-authored-by: Claude <noreply@anthropic.com>
…ory face; one seam; docblock and sweep

- memory-20874-contains-membership.test.ts: driver-sql's #17590 fixture row
  for row, plus the stored shapes SQLite decides (scalar, object, nested
  array, null/boolean/fractional members), through find() in both spellings,
  count(), the analytics query and its SQL echo executed on sql.js, and the
  nested-relation lowering's driver input.
- driver-sql #17590 suite: a multi-valued lookup column and the u1/u10 case.
- driver-memory: one public seam, filterContainsTest; the analytics echo
  reads its member set off the test the $match exit runs.
- spec: the $contains docblock's stale driver-memory status and dead tracker
  link are replaced with the measured status.
- the rest suite header and the pending #20802 changeset no longer state the
  in-memory gap.

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

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-memory, @objectstack/spec, touching 18 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/filter.zod.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/data-modeling/drivers.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/permissions/authentication.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via InMemoryDriver (symbol, a top-level class))

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

  • content/docs/releases/implementation-status.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via InMemoryDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via InMemoryDriver (symbol, a top-level class))

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/spec/src/data/filter.zod.ts) — pages documenting those are invisible to this run
  • 4 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 — 138 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 05be3525961a4977ea49eda507e44a4482f87681 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 05be3525961a4977ea49eda507e44a4482f87681

⚠️ 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 05be3525961a4977ea49eda507e44a4482f87681 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ing)

filterContainsTest is a new public method on the exported InMemoryDriver
class, so the published surface widens; the entry stays minor.

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: 1bf8dbb609ca103f2e7b7cd062dfad72a0e2ba17
Local-runs: none

Inputs: card #20874 (body and all five comments: triage 5914978120, claim 5921357350, report 5922095993, claim amendment 5922144972, patch-round report 5922235042), PR #20984 (body, its 8-file list, the net diff of refs/review/pr20984 against merge-base f6ccca4a), and the 46 check-runs on this head. Nothing was built, run or re-run; every claim below is judged by reading the diff against the base tree.

① Derived judgments

  1. Population (which fields ask membership) — right. isJsonStoredField = STRUCTURED_JSON_TYPES.has(type) || isMultiValueField(shape) over the valueShapes map syncSchema records (indexValueShapes: type plus multiple === true). driver-sql's registry is JSON_COLUMN_TYPES.has(type) || isMultiValueField(field) with JSON_COLUMN_TYPES = STRUCTURED_JSON_TYPES plus MULTI_OPTION_TYPES plus the driver-internal object/array aliases; isMultiValueField already covers MULTI_OPTION_TYPES, so the two populations agree on every authorable declaration and differ only on the two introspection aliases the memory driver never produces and on single-value media (per-deployment on SQL, bare id on memory). The changeset's type list (multiple: true on select/radio/lookup/user/file/image; multiselect/checkboxes/tags; structured types such as json) is exactly MULTI_CAPABLE_TYPES, MULTI_OPTION_TYPES, STRUCTURED_JSON_TYPES.
  2. The fork is the DECLARED field, not the row (H2 = A) — right. The spec's $contains docblock selects the question by the column (declared metadata), and driver-sql forks on isJsonColumn, a declaration registry. A per-row fork would answer a declared json field holding the scalar 'u1' by substring where every SQL dialect answers no member, and would read the answer off data. Triage's wording "by the stored value's shape" is subordinate to its own clause "matching the SQL drivers' existing rule", and that rule is declared. An undeclared object keeps substring, as isJsonColumn answers false for an unknown table; the test pins both halves.
  3. Member reading — right, and in parity. containsMemberCandidates (text; true/false/null literal; a finite number under the spelled-out JSON number grammar) is the same reading as driver-sql's jsonMembershipCandidates (same regex, same String(value) rendering, '1.50' names 1.5). Each candidate's JSON.stringify is the text the SQL construct binds.
  4. The test shape — right by reading; the CI verdict is pending (③). { $elemMatch: { $in: members, $not: { $type: 'array' } } } is array-only (a scalar, object or null stored value has no member) and excludes a nested array, the twin of SQLite's typeof(os_member.key) = 'integer' plus JSON-text equality. $notContains is $not over that test, which admits null and missing rows, the twin of col IS NULL OR NOT (...). The dev's reverse verification (69 red / 44 green at base) and guard ablation (exactly the 12 nested-array cases red) are the dev's own evidence, not a gate verdict.
  5. Every verb, both spellings — right. convertToMongoQuery(query.where, object) is the one entry for find/findOne/count/aggregate/update/delete (six call sites), it threads object into convertConditionToMongo (already took it; recursion passes it) and now into the single normalizeFieldOperators call. $elemMatch is a new lowered key that assembleLoweredWrites merges generically; no other operator writes it; the substring arm still joins regexConditions so composition with $startsWith/$endsWith is unchanged.
  6. No accept-set change — right. filter-refusal.ts and SUPPORTED_FIELD_OPERATORS are untouched, so an author-written $elemMatch is still refused; $contains/$notContains keep their declared string comparand. No key, operator, error code or refusal is added or removed on any face.
  7. Analytics $match — right. The builder input's substring became containment, resolved with extractTableName(cube.sql) and resolveFieldPath, the same resolution storageFormFor already uses for filterComparandStorageForm; a cube whose sql is not a bare object name falls to substring, which is this face's pre-existing resolution rule, not a new defect.
  8. Analytics SQL echo — right. sqliteMembershipPredicate is driver-sql's SQLite construct with literals where it binds (json_valid guard, integer key, CASE for the three literals else json_quote); notContains stays (col IS NULL OR NOT EXISTS (...)). The member set is read off the same filterContainsTest, so the chart and its echo cannot name two sets. The test executes the echo on sql.js, already a devDependency; package.json is unchanged.
  9. Public surface — right, declared. filterContainsTest is a new public method on InMemoryDriver, which index.ts exports and exports["."] maps to dist/index.d.ts: reachable from the entry type graph, so a widening. MemoryContainsTest is export type in memory-driver.ts and not re-exported from index.ts, as the changeset states. PR body line 2 and the changeset both read Clause-②: yes (widening), matching the amended claim 5922144972.
  10. Spec docblock (packages/spec/src/data/filter.zod.ts) — right, docblock only. The stale "DOES NOT ANSWER IT YET" bullet and the dead driver-memory: the stored-ARRAY value axis is still unrepaired outside the equality arm — $in/$nin, the text family and the ordering family answer one filter two ways, and the two exclusion arms answer it in the WIDENING direction #17286 link are gone; the replacement states the declared-field fork and the measured rows. The added sentence that $startsWith still differs (memory per element, SQLite over the serialized text) is consistent with driver-sql: $startsWith is not in JSON_COLUMN_INCOMPATIBLE_OPERATORS, so it reaches applyLike over the JSON text. No schema, key or generated artifact moves; Spec property liveness and Governed Surface Queue Guard are green on this head.
  11. The two test-side touches — right. driver-sql's driver-sql: the $contains MEMBERSHIP spelling on any multi-valued / JSON column is a DATABASE_ERROR 500 on live PostgreSQL (SQLSTATE 42883, operator does not exist: json ~~ text) — it has only ever been executed on SQLite #17590 fixture gains an owners multi-valued lookup on every row and the u1/u10 case (['1','3'], ['2'], $notContains ['2','4']); no existing assertion changes. packages/rest/src/data-nested-object-door.test.ts is a header comment flipped to the new status, and its pointer to the memory pin is accurate (the memory file pins { $or: [{ owners: { $contains: 'u1' } }] } and its $not, the driver input objectql's engine-nested-relation-lowering.test.ts asserts the engine sends).
  12. Sweep residue — one stale statement remains, outside the claim and outside the review faces (③ item 7). On this head, git grep finds no other "DOES NOT ANSWER IT YET" or driver-memory: the stored-ARRAY value axis is still unrepaired outside the equality arm — $in/$nin, the text family and the ordering family answer one filter two ways, and the two exclusion arms answer it in the WIDENING direction #17286 reference, and no content/docs or apps/docs page states the per-element behaviour (query-syntax.mdx line 347 is about drivers(memory, mongodb): the $contains family still folds case — the last two backends left on the wrong side of #4706 Q2 = A #6682 case folding). packages/objectql/src/engine.ts line 15427 still says driver-memory matches per element and every backend answers a substring superset.

② Semver level

  • @objectstack/driver-memory 17.5.0, changeset minor, Clause-②: yes (widening) — right. The act is a new public method on an exported class (① item 9); WHICH LEVEL grades an additive widening of a published surface at least minor and the commit type fix( cannot lower it; AGENTS.md: yes takes at least minor. The row-set change itself is a fix toward the contract the spec docblock already declared, refuses no input, and the changeset carries the migration paragraph ("If your tests relied on the old answer"). (narrowing) does not apply.
  • No other package publishes: driver-sql and rest move test files only; spec moves one docblock (no key, no runtime, no generated artifact). No second changeset is owed and skip-changeset must not be applied.
  • Check Changeset on this head is red at step 12 ("Reject an empty-frontmatter changeset added by this PR") because .changeset/20802-nested-relation-filter-served.md exists at the merge base and is edited here. This is a DELIBERATE CORRECTION, not a collision: this PR's own note is .changeset/20874-memory-contains-membership.md, a different filename; the 20802 file is byte-identical at the merge base and on current origin/main (still pending, consumed by no release); exactly one sentence of it changes and the rest of the file is untouched. The gate's own annotation names this class and its remedy ("say so on the PR and get it confirmed"); this record is that confirmation. The one rewritten sentence, judged against this head: "SQLite, PostgreSQL and the in-memory driver match the element of a multi-valued relation, so an id that is a substring of another stored id (u1 inside u10) does not match it." — right. The engine lowers a multi-valued relation condition to an $or of one $contains per related id (objectql's lowering test); on this head a declared lookup with multiple: true is in the membership population (① items 1, 2), so ['u10'] does not answer 'u1', pinned on the mirrored fixture; the SQLite and PostgreSQL halves are the original sentence's own clause, unchanged. The removed clause ("still matches a stored id by substring per element") is exactly the statement this diff makes false, so restoring it from base would put a false sentence into objectql's CHANGELOG. If the release that consumes the 20802 note ships before this PR lands, the edit drops on rebase and nothing else changes.
  • Because step 12 is red, steps 13 to 15 of Check Changeset were skipped, so check-changeset-no-major's LEVEL AXIS and the ADR-0087 step have no CI verdict on this head. By the rule text ("declared yes must grade at least one package whose published source it moves minor or above"), driver-memory at minor satisfies it; no breaking changeset exists, so ADR-0087 is not applicable. The dev's offline --event run is the dev's evidence, not a gate verdict.

Clause-②: yes (widening) — one new public method InMemoryDriver.filterContainsTest on the exported class; no accepted filter key, operator, comparand shape or error code is added or removed.

③ Boundary flags

  1. open_questions[0] (H2, declared field vs row shape) — answered: A, the declared field. Reasoning in ① item 2. The in-seat answer in 5922235042 is not adopted; it is re-derived here and agrees.
  2. open_questions[1] (correct the pending 20802 note in this PR) — answered: A, keep the correction. The note is named and its one rewritten sentence judged in ②. This record is the confirmation the gate asks for.
  3. H4, $notContains under the bounded in-place exemption — accepted. Same operator family, same seam (filterContainsTest), a mechanical $not mirror with its null rule pinned on both fixtures, no new gate, and "No other open PR may claim the same single-writer path" is green on this head; the claim was amended (5922144972) to carry it.
  4. Deviation, minor not the order's patch — right (②).
  5. Deviation, FILTER_TEXT_CASES not used as the carrier — right. That table has no array column; driver-mongodb imports every row and still lowers $contains to a per-element $regex, so a shared row would turn a suite outside this claim red; the literal mirrored fixture holds the two packages to one answer the way the driver-sql: the $contains MEMBERSHIP spelling on any multi-valued / JSON column is a DATABASE_ERROR 500 on live PostgreSQL (SQLSTATE 42883, operator does not exist: json ~~ text) — it has only ever been executed on SQLite #17590 file already holds its three dialect cells.
  6. Deviation, no permanent engine.find pin on memory — accepted. check:driver-memory-census forbids a new consumer; the driver-input pin (memory) plus the lowering pin (objectql) cover the seam from both sides.
  7. Stale docblock left in packages/objectql/src/engine.ts (delete-probe, line 15427) — escalated to the seat, not a FAIL. It says driver-memory matches per element and every backend answers a substring superset; false on this head for memory (and already for driver-sql since driver-sql: the $contains MEMBERSHIP spelling on any multi-valued / JSON column is a DATABASE_ERROR 500 on live PostgreSQL (SQLSTATE 42883, operator does not exist: json ~~ text) — it has only ever been executed on SQLite #17590). The code stays correct because membership is a subset of substring and the exact narrowing through storedReferenceIncludes is unchanged. It is a code comment, outside the five review faces and outside the claim; it needs a carrier (a comment-only rider on the next objectql touch, or a card).
  8. out_of_scope_findings (five class-b, two carrier-none) — escalated to the seat for carriers; none blocks this PR. The service-analytics read-scope-sql finding (an RLS read-scope compiler answering $contains on a multi-valued field by substring, measured) is security-relevant and should not wait for the family card; turso remote transport, mongodb, formula and the analytics filter-normalizer are the same family by reading.
  9. Docs drift (PR comment 5922055209) — no docs edit owed. The four hand-written pages name InMemoryDriver only; none states the per-element behaviour (① item 12). Release-owned pages are untouched.
  10. Check-runs on this head. Red by design: Check Changeset (step 12, the ruled correction class, ②). Green: Build Core, Type Check · source gates, Type Check · debt ledger, filter, Governed Surface Queue Guard, Spec property liveness, Flag docs affected by code changes, Check Documentation Links, the four card/claim guards, Auto Label, Check PR Size. Skipped: Build Docs, Console Pin Gate, Packed-tarball smoke. Not verdicts, still in_progress at review time (not awaited): Test Core 1/6 to 6/6, Dogfood Regression Gate 1/3 to 3/3, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Type Check · workspace, Type Check · consumer gates, Lint & Repo Gates. The new memory test file (113 cases), the edited driver-sql suite, and the live PostgreSQL/MySQL cells of the driver-sql: the $contains MEMBERSHIP spelling on any multi-valued / JSON column is a DATABASE_ERROR 500 on live PostgreSQL (SQLSTATE 42883, operator does not exist: json ~~ text) — it has only ever been executed on SQLite #17590 file (the only measurement of the new owners rows on those two dialects; the changeset's "SQLite, PostgreSQL and MySQL" sentence rests on it for those rows) are answered only when those jobs conclude; the dev's local counts are not adopted as gate verdicts. Landing waits on them; this record's verdict is on the contract.
  11. PR is a draft (draft: true); the seat marks it ready when the checks above conclude.

Implemented-by: claude/issue-20874-memory-contains-membership
Reviewed-by: session_01Ujdtvqs7ree7WyQmEDwEnG

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Check Changeset stays red on this PR by design, and it is carried into the queue

domain:engine#2 (seat post #20966) · session_01Ujdtvqs7ree7WyQmEDwEnG · 2026-10-01T00:57Z.

  • The gate and step: Check Changeset (pr-automation.yml), step 12, "Reject an empty-frontmatter changeset added by this PR". It is red on head 1bf8dbb6 because this PR edits .changeset/20802-nested-relation-filter-served.md, a pending note it did not add.
  • The reason: a DELIBERATE CORRECTION. The note's last sentence said the in-memory driver matches u1 inside u10; this PR makes that false. The gate's own text says it "stays red either way" and asks for confirmation on the PR. The at-tier contract review 5922367217 is that confirmation: it names the note and judges the one rewritten sentence right.
  • Why it may be carried: the gate is red by design on a pushed branch. pr-automation.yml has no merge_group trigger, so the queue never runs it. And Check Changeset is not one of the queue's seven required contexts. Every other check on this head is green or a rostered skip (check-expected-skips --pr 20984: OK).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 00:58
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 00:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit f8178ff Oct 1, 2026
48 of 51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20874-memory-contains-membership branch October 1, 2026 01:23
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 protocol:data size/l tests tooling

Projects

None yet

2 participants