fix(objectql)!: a string comparand against a boolean field is narrowed to its boolean, or refused 400, at the engine filter door - #21372
Conversation
…and its spec contract Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…n-comparand contract Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
…olean-comparand-door
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 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 a53ecdf88e9011b4dfd30885bf81d779eefc9ce4 && git checkout a53ecdf88e9011b4dfd30885bf81d779eefc9ce4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3937ad2f327450c0ce4d4ded30acae815ef98810 6f74eb444c1a4961aac2251b9733ee85e3f7a232 && git checkout -B drift-repro 3937ad2f327450c0ce4d4ded30acae815ef98810 && git merge --no-ff 6f74eb444c1a4961aac2251b9733ee85e3f7a232
node scripts/docs-audit/affected-docs.mjs --json 3937ad2f327450c0ce4d4ded30acae815ef98810
|
Contract reviewServed-tier: Inputs, these and nothing else: card #21333 (body; comments 5946403646 the ruling, 5946729384 the claim, 5948452642 the dev report, 5948515811 the claim amendment moving Check-runs on the head (35, all completed at the time of writing): 32 success — Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates (the job that carries ① Derived judgmentsThe public surface — right, with three redundancies named, none wrong. 26 additions and 0 removals in The accepted set — right. Coverage, one door — right; no door in the ruling's list is missed. The arm sits in The operators — right. Number-door regressions — none for a non-boolean field, and none reachable for a formula. The only control-flow change is the Answers that change — a correction under the ruling, correctly named, not a widening. The ruling's accept set names ② Semver levelThe
ADR-0087 markers. objectql, Changeset sentences against the diff and the PR body's measurements. Supported, sentence by sentence: the before-rows ( ③ Boundary flagsDeviation (1), the pins — accepted for this head; a CI-visible per-driver cell escalated as a follow-up. The committed pins prove, through a recording driver, that every accepted spelling reaches the driver byte-identical to its boolean control after the shared lowering, at every position and every door, and that a refusal reads nothing — so each driver's answer for Deviations (2) to (4) — accepted. (2) Stopping the dev's own detached spec run by its recorded PGID and re-running everything on the merged head is process hygiene, and every number in the report is at the merged head The open question (a non-string outside the set) — shipping the ruling's letter is right; closing the set is its own card. The ruling refuses strings; The two out-of-scope findings — reach evidence sufficient; one family card is the right carrier. RLS: The Implemented-by: Why FAIL and not PASS: ① every derived judgment is right and the head's 32 gates are green; ② the governance declaration on this head is false three times — VERDICT: FAIL Generated by Claude Code |
…pec; scope sentences to the measured drivers The objectql changeset carries the PR's Clause-② line, yes (narrowing), and keeps its BREAKING banner. Its sentences are now scoped to what was measured (InMemoryDriver, SqlDriver over SQLite, through findData). The spec changeset reads Clause-② yes, with no banner, no arm and no ADR-0087 marker: the module is additive, and the narrowing is objectql's. Claude-Session: https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, these and nothing else: card #21333 (body and all eleven comments, among them the ruling 5946403646, the claim 5946729384 and its amendments 5948515811 and 5948799813, the dev reports 5948452642 and 5949409460, triage's answer A 5948860364, the release and hand-off 5949483316, triage's route swap 5949706861 and the adopting claim 5949833323); the previous record 5948769828 on PR #21372 (FAIL on the earlier head its own Head-sha line names, 1c184d7 in short, judged on ② alone); PR #21372 (body, the 12-file list, the net diff from the merge base Check-runs on the head (read at 2026-10-02T10:14Z): 42 check-runs, all ① Derived judgmentsThe code delta since the previous record's head is empty. So every ① judgment of record 5948769828 stands on unchanged code: the 26 additive
One note, not a condition. The engine suite's header table ( ② Semver levelThe three
Bang, banner and ADR-0087 marker. objectql: Level. Every sentence the patch round added or rescoped, against the PR body's measurements (the card's table, driven through
The PR body's added "Review round 1" section is consistent with the delta: "six sentences" (the objectql delta is the ③ Boundary flagsThe previous record's two escalations are closed by filing, as the hand-off (5949483316) and triage's route swap (5949706861) both carry them. #21376 takes the RLS compile seam and analytics NativeSQL as one family card, both seams named, as the previous record asked. #21382 takes the non-string half (the dev's recommendation B), with the round-1 findings folded in as the same comparand family: PostgreSQL answering The Round-1 deviations, accepted. (1) Throwaway PostgreSQL and MySQL instances were started outside the scratchpad for the measurement, stopped by their PIDs and removed; no repository file was touched, and the committed diff is the two changesets alone. (2) Unchanged flags. The Why PASS: ① the code is unchanged since the head whose derived judgments the previous record found right, and Implemented-by: VERDICT: PASS |
Fixes #21333
Clause-②: yes (narrowing)
What this does
A comparand against a declared
boolean/togglefield (or aformulareturningboolean) is now judged at the engine's one field-aware filter walk, the same walk that judges number comparands:true/falsereach the driver as written (the controls);1/0,"1"/"0"and"true"/"false"are narrowed totrue/false, copy-on-write, so every driver receives the one boolean each spelling names;"yes","TRUE"," true ","", a{placeholder}) is refusedINVALID_FILTER/ 400, naming the field and its declared type, before any driver is resolved.That holds at
where(object form andFilterArraysugar), the per-aggregationfilterandhaving, on every verb that collects a filter (find/findOne/count/aggregate/update/delete, plusjudgeFilter). Every REST spelling reaches that one walk unchanged (the POST bodywhere,?filter=JSON,?$filter=, the filter AST, and bare query parameters), so there is no per-door coercion.The accepted set is exactly the one the record validator's boolean arm admits on WRITE (
record-validator.ts), per the triage ruling (5946403646).Files
packages/spec/src/data/filter-boolean-comparand-declared-type.ts(new, exported from@objectstack/spec/data). This is the contract:BOOLEAN_COMPARAND_SPELLINGS,readBooleanComparand,booleanComparandFieldVerdict/booleanComparandDoorVerdict,booleanComparandRefusalMessage, the reading table, the fixture and the derivedBOOLEAN_COMPARAND_DOOR_CASES. It is additive. The judged positions are the number door's lists by identity (pinned).packages/objectql/src/boolean-comparand-declared-type-door.ts(new). This is the boolean arm: the field meta, the routed verdict and the words. ⛔ It walks nothing.packages/objectql/src/number-comparand-declared-type-door.ts. The existingwalkConditionasks the boolean arm at every field key the number arm does not judge.judgeFieldSpecnow takes the judging arm, so both arms judge at the same positions by construction. ⛔ No second walker.packages/objectql/src/engine.ts. This file only gets[#21333]notes at the four existing call sites (where, lowered where, per-aggregation filter, having). No new call site.filter-boolean-comparand-declared-type.test.ts(spec) andengine-boolean-comparand-declared-type-door.test.ts(objectql). The number engine suite gets a named partition for its two census rows onf_boolean/f_toggle: they pass the number verdict and are now refused by the boolean arm, and that partition is pinned in the direction it answers.packages/spec/api-surface/data.json,packages/spec/export-origins/data.json: regenerated (26 additions, 0 removals).objectqlminor, BREAKING,Clause-②: yes (narrowing), with an ADR-0087not-required (no-migration-prescription)disposition; andspecminor, additive,Clause-②: yes, with no ADR-0087 marker (it is not a breaking changeset). See the review round below.The card's table, measured before and after, on both drivers
Two rows (one
true, onefalse). Measured with a one-time harness throughengine.find,engine.aggregate(wherecount and per-aggregationfiltercount) andfindDatafor all five REST spellings, on InMemoryDriver and SqlDriver/SQLite. The harness used freshly built dists: base6c5bef5f4, and after on the merged head1c184d7695. Every cell is a 200 unless it says otherwise."true"(implicit,$eq,$in, all 5 REST spellings, aggregate count)$ne "true"/$nin ["true"]"false"/$ne "false""yes"/$ne "yes"/"TRUE"INVALID_FILTERINVALID_FILTER1/"1"/0/"0"$ne 1/$ne "1"true/false/$ne true/$in [true]filter:"true"/$ne "true"havingovergroupBy f_boolean:"true"/$ne "true"/"yes"The after-run had zero mismatches against the card's correct column on either driver. ⚠ The ruling's premise that
1/0/"1"/"0"are "already answered correctly" holds on SQLite only: InMemoryDriver answered them with no row (stricttruevs1). Narrowing every accepted spelling to its boolean is what makes the ruling's own pin ("the card's table answers its correct column on both drivers") true there. That is a measured refinement of the premise, ⛔ not a switch of the ruling.Mechanism hypotheses (dispatch Zone 2)
walkCondition, not a copied walker, and the four engine sites run both arms with no new call.GET /api/v1/data/:objecthandsreq.querytofindData, which folds leftover keys into an implicitwhereof strings (metadata-protocolprotocol.ts, the implicit-filters block) and callsengine.find. The engine door therefore sees"true", and no REST-side coercion is needed. The GET-door rows are pinned throughfindDatain objectql's suite (bare-param spelling included), so nopackages/restpin was added.$ne,$in,$nin(and$between) members follow the same narrowing. The baseline$ne "true"is 2 rows on both drivers, as in the table.packages/spec/src/data/anyway, beside the number contract, for three reasons. The record validator's write arm carries the identical accepted set as a literal, and one grammar both sides can import belongs where both can reach it (theparseNumericStringprecedent). The refusal words and case table are the public contract the door answers in. And a consumer outside objectql (see H5) can read it without importing the engine. The module is additive and exported.Reverse verification
The implementation was committed first (HEAD
1c184d7695). The arm was then ablated throughscripts/ablation-replace.mjs(anchorconst booleanMeta = booleanArmFieldMeta(facts.boolean);, replaced bynull; anchor 1 to 0, blobcc4b1198tob001e9b3), and both engine suites were run:judgeFilter, aggregatewhere, per-aggregationfilter,having, and all ten REST-door cases), plus the number suite's boolean-arm partition (1).true/false/null/ flag /$fieldcases reaching the driver unchanged, the by-reference guard), the boolean suite's pure partition guard, and all 36 other number-door pins.cc4b1198, andgit diff HEADis empty.Tests and gates (all at HEAD
1c184d7695, freshly built dists)@objectstack/spec:test599 files / 17541 passed (1 todo),test:repo48 / 849, exit 0.typecheckexit 0. The new test is intsconfig.test.json's program (--listFiles).@objectstack/objectql:test363 files / 7301,test:repo1 / 5, exit 0.typecheckexit 0 (new files intsconfig.test.json's program).@objectstack/rest:test254 files / 4805 passed (316 skipped),test:repo5 / 177 (1 skipped), exit 0.typecheckexit 0.@objectstack/driver-memory: 70 files / 1718, exit 0.typecheckexit 0.@objectstack/driver-sql: 215 files passed (11 skipped) / 3594 passed (202 skipped), exit 0.typecheckexit 0.@objectstack/dogfood: 166 files passed (1 skipped) / 1369 passed (3 skipped), exit 0.typecheckexit 0.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 91 families on this head and all 91 ran;--ranreconciles 91 / 91, 0 unrun. Each family's exit code was captured before any pipe. All were 0 exceptcheck:dual-build-cjs-loads, whose first run wasPREREQUISITE NOT MET(eight unrelated packages had nodist/; nothing measured); after building those eight it ran to exit 0. The run includescheck:dispatcher-error-vocabulary(no new code:INVALID_FILTERthrough the existinginvalidFilterError),check:adr-0087-registration,check:empty-changeset,spec check:generated,check:nul-bytes,check:driver-memory-censusandcheck:test-source-alias.Acceptance notes
packages/restpin (domain:cli). A committed InMemoryDriver cell needs a ruling on the closedcheck:driver-memory-census. objectql depends on neither driver.1/0, aDate, or an array member against a boolean field passes the verdict and reaches the driver as written (no stored boolean equals2). The ruling refuses strings only. Whether to close the set is the analogue of the number door's later widening, so it is an open question for the maintainer, ⛔ not done here.record-validator.ts's boolean write arm still spells the accepted set as a literal rather than readingBOOLEAN_COMPARAND_SPELLINGS. They are equal today, and the spec test pins the set's content. Carrier: none named.Out-of-scope findings (for the seat to file; ⛔ not fixed here)
usingpredicates compare a boolean as written. The compiled policy filter is composed after the caller's filter door, by design, sorecord.flag != 'true'keeps the true row and== 'true'keeps none. Measured after this change throughSecurityPlugin's real middleware over a real engine on SqlDriver/SQLite, withengine.findand a member context, on two rows:== truegives the true row,== 'true'gives none,!= 'true'gives both,== 'yes'gives none (silently). Reach: exception (security: a policy's exclusion is not applied). No in-repo producer writes such a predicate today.runtimeFilteritself.POST /api/v1/analytics/dataset/queryover SqlDriver/SQLite (AnalyticsServicePlugincomposition, NativeSQL answered) gives:{"flag":"true"}200 count 0 (should be 1),{"flag":{"$ne":"true"}}200 count 2 (should be 1),{"flag":"yes"}200 count 0 (the engine door refuses it 400). Reach: public door measured. Seam:spec:booleanComparandDoorVerdictto runtimeservice-analyticsNativeSQLwherecompilation.Review round 1 (head
6f74eb444c, after the at-tier contract review FAIL 5948769828)Section added by the
domain:engine#1seat, from the dev's patch-round report (5949409460 on #21333):Clause-②: no (narrowing)was false. The 26 additive@objectstack/spec/dataexports widen the public surface, and the line was the seat's own false declaration on the claim, corrected by the claim amendments 5948515811 and 5948799813. This body's first lines now readClause-②: yes (narrowing),scripts/pm/clause2-line.mjs's spelling for a diff that widens one surface and narrows another.6f74eb444c, the only change in this round; no code moved):objectql:Clause-②: yes (narrowing), still BREAKING. Six sentences are scoped to what was measured:InMemoryDriverandSqlDriverover SQLite, atfindDatarather than the HTTP route, and "every door that reaches the engine's filter walk". One of them is the sentence the review named.spec:Clause-②: yes. The BREAKING banner, the narrowing arm and the ADR-0087 marker are removed, which isb285508188's shape. Its "before" and "remedy" sentences, which describedobjectql's engine, are replaced by "What moves for consumers".6f74eb444c: 91 derived, 91 run, all exit 0.check-adr-0087-registrationlists one declared-breaking changeset (objectql's).check-changeset-no-major's level axis, driven offline with this body's line, exits 0.check-widening-tells --diffexits 0 under--declaration yes, and exits 4 under the oldnowith 31 tells (26 × T3, 5 × T2). That confirms the review.1c184d7695.500 DATABASE_ERRORat every slot, and an array$inmember splits 200 / 400 across drivers;packages/restSQLite cell is an acceptance note.objectqlhalf declared. Thedomain:engineseat hands the card over without marking this PR ready or enqueueing it.Generated by Claude Code