fix(driver-memory): read a stored ARRAY as its elements in the equality arm, so both filter faces answer one filter one way - #17287
Conversation
…ty arm, so both filter faces answer one filter one way
`memory-matcher.ts`'s equality arm ended in `value == condition`. Loose `==`
converts a stored ARRAY to a primitive — `['a','b']` becomes the string
`"a,b"` — so the reference matcher and the live query path (`InMemoryDriver
.find`, through mingo) disagreed about the same filter in BOTH directions:
| filter | stored | matcher, before | live path |
|-------------------|-------------|-----------------|-----------|
| `{ tags: 'a' }` | `['a','b']` | no row | the row |
| `{ tags: 'a,b' }` | `['a','b']` | the row | no row |
| `{ tags: 'a' }` | `['a']` | the row | the row |
The second row is the sharper one: a false positive, a filter written to
narrow returning a row it should not, which on a read scope is a permission
concern rather than a degraded filter. The first is fail-open the other way
and just as silent.
A stored array is now read as its ELEMENTS, and each is asked the question
the arm asks of a scalar — so an array answers the OR of the answers its
elements would give. That is mingo's composition, which is this file's
standing tie-break: the reference face converges on the path users actually
run instead of inventing a third reading. One level only, measured: mingo
does not descend into a nested array, so neither does this face. Refusing the
shape was not available — a refusal is raised from the FILTER before any row
is seen, and this cell is a property of the stored ROW.
`comparandEquals` becomes the entry every arm calls; the previous body is
`singleValueEquals`, unchanged, deciding one value against one comparand.
The live query path is untouched.
Tests: `memory-matcher-scalar-comparand-array-value.test.ts` drives BOTH
faces in one process over one fixture — the card's three rows, its firing
control and its negative twin, `$eq`/`$ne`, a null comparand against a null
member, and the OR-over-elements property over the whole matrix.
#16810's pin block, which recorded this behaviour as unchanged so its own
refusal could not move it by accident, is rewritten rather than deleted: the
three answers move with the value side's ruling, and the invariant the block
exists for — the comparand refusal must not reach the value side — is now
asserted directly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fd4fde062a3e31a2e90da4f07ba48ff51a682b69 && git checkout fd4fde062a3e31a2e90da4f07ba48ff51a682b69
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ebf9a4891156a20d09889f6984b7d74d3b0301ce 17058b6dc7a4c1f34427eec00a31ad7b48656f3d && git checkout -B drift-repro ebf9a4891156a20d09889f6984b7d74d3b0301ce && git merge --no-ff 17058b6dc7a4c1f34427eec00a31ad7b48656f3d
node scripts/docs-audit/affected-docs.mjs --json ebf9a4891156a20d09889f6984b7d74d3b0301ce |
Contract review — seat self-review under the 2026-09-10 maintainer ruling · VERDICT: PASSAuthority: 「你的车道所有任务不需要 fable,任务结束你自己就可以审核」 — this lane is released from 1. Does it widen the accept set? YES — and that confirms the seat's declaration rather than assuming it.
function comparandEquals(value: any, condition: any): boolean {
if (Array.isArray(value) && !Array.isArray(condition)) {
return value.some((element) => !Array.isArray(element) && singleValueEquals(element, condition));
}
return singleValueEquals(value, condition);
}
2. Does it enlarge the published surface? NO.
3. Grade
|
Fixes #16838
Clause-②: yes — a published read verb changes which rows it returns (the accept-set direction), so this waits outside the queue under
needs:contract-review. That wait is the safe state; the label is not cleared here.The defect, in one line
memory-matcher.ts's equality arm ended invalue == condition. Loose==converts a stored ARRAY to a primitive —['a','b']becomes the string"a,b"— so this package's reference matcher and its live query path (InMemoryDriver.find, through mingo) answered the same filter two different ways, in both directions at once:{ tags: 'a' }['a','b']false— no rowtrue{ tags: 'a,b' }['a','b']true— the rowfalse{ tags: 'a' }['a']truetrue— the firing control, unmovedThe second row is the sharper one: a false positive, a filter written to narrow returning a row it should not, which on an RLS read scope is a permission concern rather than a degraded filter. The first is fail-open the other way and just as silent —
if (!rows.length)cannot tell "genuinely none" from "the predicate asked the wrong question". Both come from that one line, which is why both move together here.What changed
comparandEqualsbecomes the entry every arm calls (implicit equality,$eq,$ne); its previous body issingleValueEquals, byte-unchanged, deciding one value against one comparand. The new entry adds one composition:That is mingo's own composition, which is this file's standing tie-break (#5240, #5324, #5328, #5374): the reference face converges on the path this package's users actually run rather than inventing a third reading. The string-join reading was never a reading — no author writes
"a,b"meaning['a','b'].One level only, measured rather than reasoned: mingo does not descend into a nested array, so neither does this face.
[['a']]against'a'is no row on both faces now; it used to be a match on this one, by the same join.Refusing the shape was not available, and the difference from #16810 is structural rather than a preference: a refusal is raised from the FILTER by
assertFilterConditionShape, once, before any row is seen. This cell is a property of the stored ROW, so a refusal would fire or not fire depending on the data — the exact record-dependence #5240 moved the shape walk out of the field loop to avoid.The live query path is untouched. It already answered membership. An array in the COMPARAND position is still refused (
INVALID_FILTER/ 400) by the shape gate — that is #16810's cell, on the other side of the same operator, and this change does not widen it.#16810's pins moved deliberately, and were not deleted
memory-matcher-array-and-date-comparand.test.tspinned these three answers as UNCHANGED so that PR's refusal could not move them by accident. The pin did its job: the change that moved them had to come here and say so. The block is rewritten — the three answers now track the value side's own ruling — and it gains the invariant it existed for, stated directly instead of left to be inferred: the comparand refusal must not reach the value side, so a scalar comparand against any stored array must ANSWER rather than throw, while the comparand position still refuses on the same row.Evidence
Red before, green after —
memory-matcher-scalar-comparand-array-value.test.tsdrives BOTH faces in one process over one fixture: the card's three rows, its firing control and the control's negative twin,$eq/$ne, a null comparand against a null member, and the OR-over-elements property over the whole matrix.Ablation, from the committed state — the array arm removed on disk (marker grep 1 → 0, mutated blob
3b02e17vs HEAD bloba09ace0), then the new suite and #16810's pins re-run:Restored with
git checkout HEAD -- <path>; restored bloba09ace0equals the HEAD blob andgit diff HEADis empty.Package suite, at the merge commit, after rebuilding the dependency closure:
The new test file is in the
typecheckprogram (confirmed withtsc --noEmit --listFiles), so its assertions are type-checked rather than merely advertised.Gates. All 58 commands derived by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackwere run locally at17058b6d; 55 exited 0, includingcheck:where-matcher(395 matchers discovered and judged),check:driver-conformance,check:driver-memory-census,check:engine-double-contract,check:cross-package-test-inputs,check:test-source-alias,check:changeset-no-major,check:adr-0087-registrationandcheck:nul-bytes.Three exited 3 = PREREQUISITE NOT MET = NOT MEASURED, never a pass — each needs a whole-repo
pnpm buildor a re-measure this seat may not perform, and each is left to CI:check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt.Changeset
minoron@objectstack/driver-memory. Above the floor the seat ruling sets, and justified rather than defaulted: the change is not merely internal repair. A face this package publishes as its reference evaluator now selects rows it previously did not (the accept-set direction), so a consumer that drives it as a driver double, or that comparesfind()against it, sees a different row set. That is an envelope change on behaviour rather than a new key or export, which is theminorrung;majoris refused by CI and would overstate a change no published type or export makes.Acceptance notes
$in,$nin, the text family ($contains/$notContains/$startsWith) and the ordering family still read a stored array as one opaque value while the live path reads members, and the two exclusion arms diverge in the WIDENING direction. Measured on this branch tip with a firing control. Not fixed here: those are separate lines with separate cells, and at least three need a reading of their own (mingo matches$gtwhen ANY element satisfies it, and its text arms DO descend into a nested array where its equality arm does not), so a mechanical sweep would invent semantics on cells nobody has ruled.singleValueEquals' docblock says the rule is@objectstack/formula'slooseEq"arm for arm", but the last arm differs —formulaends in===, this ends in==. The three Date arms do match. The==is deliberate and recorded ("undefined/null mismatch or string/number coercion"), so the inaccuracy is in the word "arm for arm", not in the code; it is out of this card's cell and left for whoever next rules on that coercion. Carrier: the next PR to touch this function.🤖 Generated with Claude Code
https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Generated by Claude Code