fix(driver-memory)!: refuse an array comparand and compare Date comparands by time value - #16840
Conversation
…rands by time value
`checkCondition` routed both `Date` and `Array` into `value == condition` and
called it "exact match" two lines above. Between two objects `==` compares
REFERENCES, so it is neither: a deep-equal array and an equal-instant `Date`
both answered false, fail-closed and silent.
The two halves get different dispositions, and the difference is the contract's:
- `Date` is a member of `ACCEPTED_FILTER_COMPARAND_TYPES` and the conformance
table requires a Date comparand to "pass the door and execute everywhere", so
it is EVALUATED — by time value, arm for arm with `@objectstack/formula`'s
`looseEq`, including the Date-against-stored-ISO-text case this driver's own
datetime canonicalisation produces.
- An array in an implicit or scalar-operator position is a cell the spec's
comparand door names and declines to rule; `ACCEPTED_FILTER_COMPARAND_TYPES`
has no array member and `driver-sql` refuses one. So it is REFUSED, from
`assertFilterConditionShape` — the one gate every face of this package runs —
so the live query path, the matcher and the analytics face answer alike.
Measured before the change, one row `{ tags: ['a','b'] }`, filter
`{ tags: ['a','b'] }`: the live path returned the row, the reference matcher
returned none. One filter, one package, two answers.
A scalar comparand against a stored ARRAY is deliberately untouched and now
pinned: that is the value side, which the comparand door does not judge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…ality repair Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…mory-matcher-array-date-comparand
…string `check:doc-authoring` red on the new message: a runtime string reaches authors, operators and generated surfaces, none of whom can resolve `#NNNN`. The id stays in the adjacent docblock, where the reader who can resolve it reads the source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift Check5 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 133 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 2e27ab0605c9b73559c78f33df63dba766d5572f && git checkout 2e27ab0605c9b73559c78f33df63dba766d5572f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 12babac137cc826fa5ed888ce63c266c4d219ce3 02f3fbe1d28f31a2a2b37aaf6ab45dda7a059f88 && git checkout -B drift-repro 12babac137cc826fa5ed888ce63c266c4d219ce3 && git merge --no-ff 02f3fbe1d28f31a2a2b37aaf6ab45dda7a059f88
node scripts/docs-audit/affected-docs.mjs --json 12babac137cc826fa5ed888ce63c266c4d219ce3 |
⛔ HELD — do not flip ready, do not enable auto-merge, do not landThis PR is complete and its verification is not in question. It is held because its card, #16810, is The dev's own measurement is the reason: the live query path was never broken. The Date half is not in dispute and is not the reason for the hold. Full seat note, including two corrections to the record that this seat owns: #16810 (comment) Generated by Claude Code |
Contract review (
|
CI is red, and here is exactly what is failing — root cause measured, not guessed
Every other step in the job passed, including the changeset's presence, its non-empty frontmatter and its ADR-0087 disposition. ⇒ the changeset is well-formed; its LEVEL is the defect. It declares ⭐ The correct level for this act is already demonstrated one PR over. PR #16848 makes the same shape of change — a published constructor's accept set is narrowed, so it is breaking — and lands it as ⛔ This is a separate defect from the hold and does not lift it. The hold stands for the reason already on record: the dev measured that the live mingo path was never broken, so this diff withdraws a working, user-visible behaviour and narrows a published accept-set with no ruling authorising it (#16810 (comment)). Green CI would not make it landable and red CI is not why it is held. Why the fix is not pushed in this message: ⛔ the PM seat never writes code, and its dispatch concurrency is at its instructed ceiling of three (#16693, #16820, #16818). The changeset correction is queued as the next dispatch on this branch and is a one-file change —
Generated by Claude Code |
⛔ Correction — this seat's root cause above was WRONG, and its hold is obsoleteTwo withdrawals, both this seat's. 1. The root cause was inferred from a step NAME, not read from its logThe seat's comment ⇒ there is no The gate names two routes and says in as many words that they are not interchangeable:
Route 1 applies: the 2. The hold is lifted — the ruling this seat asked for existsThe seat's hold ( ⇒ ⛔ the hold no longer applies, and this seat should not have restated it at Landing sequence, unchanged in substance: F1's one-word fix lands → Generated by Claude Code |
…use-② declaration The level axis of scripts/check-changeset-no-major.mjs refuses a `patch` on a package whose packages/*/src/** the PR moves while the PR body declares `Clause-②: yes`. Raising the level is route 1 of the two the gate names; the declaration is the claim's and is not touched here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Landing — ready + auto-merge armed.
|
Fixes #16810
checkConditionrouted bothDateandArrayintovalue == condition. Between two objects==performs no conversion — it compares REFERENCES — so the arm was neither the "exact match" the comment two lines above claimed nor a match at all, and it failed closed and silently on a published driver that calls itself a Reference Implementation.The fossil, read before the guard moved
Both comments, verbatim from the tree:
:251-252—// Case A: Implicit Equality (e.g. status: 'active')/// If condition is a primitive or Date/Array (exact match), treat as equality.:259-260—// Loose equality to handle undefined/null mismatch or string/number coercion if desired./// But stick to == for JS loose equality which is often convenient in weakly typed queries.Triage's reading holds and I confirm it: every reason the author recorded for
==is about PRIMITIVES — an undefined/null mismatch, string/number coercion, weakly typed convenience — and not one of them coversDateorArray, which:252nonetheless routes in and calls exact. This is not a deliberate trade-off implemented badly; two adjacent comments describe different things and==on objects delivers neither. Those primitive reasons are still true, so==is KEPT for exactly the cases they name.I found no fossil anywhere saying the current behaviour is deliberate. Searched: the ADRs (only
docs/adr/0053-date-and-datetime-semantics.mdnames comparands, and about temporal storage forms, never about equality by reference), this package'sfilter-refusal.tsheader and its recorded "deliberately NOT refused" list, and the spec door's own list of cases it does not rule.Subject sweep — what I checked before editing
driver-memorycarries a maintainer ruling on a different subject:#6915, row-level tenant isolation, disposition B, ruled 2026-08-12. I readmemory-tenancy-guard.tsin full rather than assuming it was unrelated. It is a BOOT-time refusal keyed on deployment tenancy posture and objecttenancy.enabled; it names no filter, no comparand and no matcher arm, and its own direction — refuse loudly rather than answer silently and wrongly — is the same direction as this change, not opposed to it. Nothing here reverses it.Deliverable zero — the four probes, before and after, with a firing control
Run against THIS repo's matcher and, beside it, this package's live query path, on a declared schema. The control fires on both legs in every run: a scalar comparand that legitimately matches (
true/ 1 row) and its negative twin (false/ 0 rows). Readings whose control did not fire were discarded — the first live-path run passed the schema in the wrong shape, itsCONTROL-NEGreturned a row, and it was re-taken rather than reported.{ tags: ['a','b'] }vs stored deep-equal array{ created_at: Date }vs stored equal-instant Date{ created_at: Date }vs stored ISO string{ tags: 'a' }vs stored['a','b']{ tags: 'a,b' }vs stored['a','b']{ tags: 'a' }vs stored['a']{ tags: { $eq: ['a','b'] } }{ tags: { $ne: ['a','b'] } }{ tags: { $in: ['a','z'] } }{ created_at: { $gte: Date } }Three readings the card and triage did not have:
#5240/#5328/#5347shape this package has spent five cards removing. The card's premise (the matcher is wrong) holds; the parenthetical's premise about "the document stores" was true of this driver's live path and false of its matcher, which is why the correction below names dispositions rather than families.$eq/$necarry the same defect, and$necarries it in the WIDENING direction (P4b answeredtrue, i.e. the row survives an exclusion the author wrote). Repaired with the same helper — one predicate must not answer two ways depending on which spelling was used.The before column was re-taken as a reverse verification AFTER the change was committed: the two sources were restored to the branch point, proven at the branch point by
git hash-objectagainst the base blobs, re-probed, then restored fromHEADand proven restored by an emptygit diff HEAD— not by an exit code.The two halves take different dispositions, and the contract decides which
Array — refused, as ruled. The spec door names an array outside
$in/$nin/$betweenas a position it deliberately does not rule;ACCEPTED_FILTER_COMPARAND_TYPEShas no array member;driver-sqlrefuses one. This driver now refuses it in its ownINVALID_FILTER/ 400 envelope, with a message naming the field, the received shape, the accepted set and the operators that DO take a list.Date — evaluated, NOT refused. This is the one place the order and the contract disagree, and I followed the contract.
DateIS a member ofACCEPTED_FILTER_COMPARAND_TYPES, andFILTER_COMPARAND_TYPE_CASEScarries the case "Date compiles" whose note reads: "A Date comparand must pass the door and execute everywhere". Refusing a Date comparand would contradict a cell the spec DOES rule, and would make this the only face in the platform refusing an accepted comparand type. So the Date half is compared by time value, arm for arm with@objectstack/formula'slooseEq— the sibling record-at-a-time matcher this face's conformance suites are held against. The order's stop condition is not triggered: it fires if I conclude array-equality should be IMPLEMENTED, and I conclude it should be refused.Also measured, and correcting the record:
@objectstack/formuladoes not REFUSE an array comparand — it answersfalsefrom an explicit// A bare array value is not a valid field specarm.driver-sqlgenuinely throws. The refusal here followsdriver-sql.Where the refusal lives — a declared widening of the fenced surface
The dispatch fenced this to
memory-matcher.ts. The refusal is infilter-refusal.tsinstead, one file over in the same package, and the matcher's own docblock is why:A refusal written INSIDE the matcher is by construction one the live path does not make — it would have left the matcher refusing P1 while mingo kept returning the row, replacing one two-answer divergence with another.
memory-matcher.tskeeps the totality floor for a direct call that skips the gate, and the Date repair.The naming in the dispatch drifted too:
refuseFilterNodeis objectui's helper and does not exist in this repository (zero occurrences). This package's idiom isunsupportedFilterErrorplus a named per-condition constructor, and the newarrayComparandErrorfollows it — leading sentence shaped ondriver-sql'sunbindableComparandError, the accepted-set sentence quoted from the spec rather than hand-copied.Deliverable 2 — the spec parenthetical, re-derived by symbol
packages/spec/src/data/filter-comparand-type.ts, the line found by searching for its text rather than by line number:`driver-sql` refuses it with its own message; the document stores give it array-equality semantics`driver-sql` and `driver-memory` refuse it, each with its own message; `driver-mongodb` hands it to MongoDB and inherits that engine's array semanticsThe paragraph's point is deliberately unchanged: the door still does not rule the position.
driver-mongodb's clause is read from itsmongodb-filter.tsdefault arm, which excludes arrays from the operator branch and passes the value through to MongoDB.One in-place correction beyond the fence, declared:
filter-comparand-shape.test.tscarried the SAME stale sentence as a comment on its pass-through pin ("keeps its array-equality semantics"). Same defect class, same package, comment-only, no assertion touched — the pin still asserts pass-through, which is what triage said it pinned and what I verified.Verification
@objectstack/driver-memory—test1118 passed / 45 files,typecheckclean.@objectstack/spec—test12953 passed / 464 files,typecheckclean.@objectstack/runtime— 3367 passed / 242 files.@objectstack/cli— unit tier 2555 passed / 186 files. Both are consumers of the narrowed face; both needed their dependency closure built first, and their first runs were PREREQUISITE NOT MET rather than red.{ code: [] }in this package's face-agreement table. It was a row-set case whose two silent answers happened to coincide at "no rows"; it is now an assertion that BOTH faces refuse the shape alike, which is the property the entry existed for.where/filters/filterobject finds ZERO bare-array comparands. Whether an out-of-repo host authors one is not measured and is not claimed to be zero.Run reconciliation — 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN.Verdicts, stated separately from that coverage line: 76 of 76 exited 0. Two gates were red or unmeasured on a first pass and are green on a real verdict —check:doc-authoringwas a genuine red on this diff (a tracker id inside the new runtime string; the id now lives only in the adjacent docblock), andcheck:dual-build-cjs-loadsreturned exit 3 PREREQUISITE NOT MET until its named closure was built.Clause-②: yes
验收备注 — out of scope
Filed: #16838 — the VALUE side of the same
==line. A scalar comparand against a stored ARRAY: the matcher joins the array to"a,b"and answers a query nobody wrote (a false positive), while the live path reads membership. Measured in both directions with a firing control; that cell is pinned unchanged by this PR so the refusal could not move it by accident. ⛔ Out of scope for this PR, and #16838 remains open — no ruling in the tree names the value side, and the comparand door does not judge it.Noted, not filed:
$gt/$gte/$lt/$lte) compare aDatecomparand against a stored ISO string through JS relational coercion, which yieldsNaNand is therefore always false on an undeclared field. Untouched here: that cell belongs to ADR-0053's temporal conformance, whose own note says row agreement for a Date comparand "legitimately differs per storage form".driver-sqlrefuses an array comparand on the TEXT family as well; this driver keeps its recorded fail-closed disposition there instead, becausefilter-refusal.tslists that exact case among the shapes it deliberately does not refuse. The refusal added here is no wider than the ruled cell.Related, referenced without any verb beside them and qualified by repository: objectui#8514 · objectui#8529 · objectui#8530 · objectui#8447 · objectui#8512 · objectstack#4775.
Generated by Claude Code