fix(spec)!: a number other than 1 / 0, a Date or an array compared against a boolean field is refused like a non-boolean string (#21382) - #21404
Conversation
…Date and an array Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…/ MySQL where provisioned Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…boolean value-form exports Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
…objectql Claude-Session: https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 5 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 98e607fd9432e24aa77959002eee0a6876fe2f0a && git checkout 98e607fd9432e24aa77959002eee0a6876fe2f0a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 85986144c2ef6f379955137677c5cbfb00e194d2 196afd75cd74fabf2b4e995ad1172540943c82bb && git checkout -B drift-repro 85986144c2ef6f379955137677c5cbfb00e194d2 && git merge --no-ff 196afd75cd74fabf2b4e995ad1172540943c82bb
node scripts/docs-audit/affected-docs.mjs --json 85986144c2ef6f379955137677c5cbfb00e194d2
|
Contract reviewServed-tier: Isolated at-tier review of PR #21404 (card #21382), written 2026-10-02T12:19Z. Inputs: the card's body and its four comments (grade ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged:
② Semver level
③ Boundary flagsReport
Check-runs on the head, read 2026-10-02T12:18Z (34 runs): 26 Implemented-by: VERDICT: PASS |
Fixes #21382
Clause-②: yes (narrowing)
What this changes
One verdict, widened, as triage directed on the card (grade
5949762416, inheriting the number door's direction5877498426): the published boolean-comparand verdict now refuses any comparand against a declaredboolean/togglefield (or aformulareturningboolean) that is outside its accepted set. The engine's boolean arm consumes that verdict and nothing else; it carries no second rule. This is the boolean twin of PR #20545 (b05743433b) on the number door, and it follows that PR's shape and words where the two contracts have the same parts.packages/spec/src/data/filter-boolean-comparand-declared-type.ts.booleanComparandDoorVerdict(field, comparand)answersdoor-refusal(INVALID_FILTER/ 400) for a number other than1/0(2,-1,0.5,NaN), aDateand an array, at a scalar slot or as a list member. The string rule is byte-for-byte what PR fix(objectql)!: a string comparand against a boolean field is narrowed to its boolean, or refused 400, at the engine filter door #21372 published. The accepted set is unchanged:true,false,1,0,"true","false","1","0";nullis still the null test.bigintis read as the number it names:1n/0nnarrow like1/0, and any otherbigintis refused as a number. See The bigint reading below for why this is the only reading that gives one answer at every position.null, a{ $field }reference, and every value outside the comparand-type door's accepted set (undefined, a plain object, aMap). That door already refuses those on every field, in its own words (see Objects below).NON_BOOLEAN_VALUE_FORMS(number,date,array) and the typesNonBooleanValueForm/NonBooleanComparandForm. The refusal'sformwidens fromNonBooleanStringFormtoNonBooleanComparandForm, onBooleanComparandDoorVerdict,BooleanComparandRefusalSiteandBooleanComparandDoorRefusalCase. The site'svaluewas alreadyunknown; it now carries a non-string.booleanComparandDoorVerdict's signature is unchanged (relevant to A boolean comparand is judged only at the engine door: the RLS compile seam and analytics NativeSQL pass a string against a declared boolean field as written (the family of #21333) #21376, which will consume it).whereonly. The engine evaluates the per-aggregationfilterandhavingitself, and this contract has no driver-bound site flag. ADaterenders asDate(ISO)and a non-finite number by name, not as JSON (NaNwould otherwise print asnull, the null test).valuegroup:-1at$neon every judged field, and2and aDateat every judged position off_boolean. An array sits at every judged position except the equality slots, where the comparand-shape door refuses one first. Beside them are four passing rows. The2/-1reading rows, and a new0.5row, now derive refusals.freshComparandcopies aDateand an array per filter, as the number table does. Nobigintrow is in either table, so every filter a suite builds survivesJSON.stringify.packages/objectql/src/boolean-comparand-declared-type-door.ts: comments only. The walk already routed every comparand to the verdict and carried the refused value as written.number-comparand-declared-type-door.ts(the shared walk) is untouched.packages/rest/src/data-boolean-comparand-door.test.ts, besidedata-number-comparand-door.test.ts. In that cell SQLite always runs, and PostgreSQL / MySQL are named skips without their URL.packages/spec/api-surface/data.jsonandexport-origins/data.json, three added lines each, regenerated bygen:api-surface/gen:export-originsaftercheck:generatednamed exactly those two.minorwith!, aClause-②: yes (narrowing)line and one ADR-0087 marker (not-required (no-migration-prescription)):.changeset/21382-spec-boolean-comparand-non-string.mdand.changeset/21382-objectql-boolean-comparand-non-string.md.Clause-②, measured
node scripts/pm/check-widening-tells.mjsongit diff 69a12a0952...HEAD:--declaration no: exit 4, three T3 tells. These are the three new rows inpackages/spec/api-surface/data.json(lines 469, 505, 507).--declaration yes: exit 0.So this PR declares
Clause-②: yes (narrowing). It adds an export listing row, and what it changes in behaviour is a narrowing. No T1 / T2 / T4 tell fired.Before and after
A declared
booleanfield, two rows (rttrue,rffalse), throughengine.find/engine.aggregate. The probe is a scratch script against freshly built dists, not a committed test. Awherecell reads implicit,$eq,$ne, and a$inmember besidefalse. Before: base69a12a0952. After: this branch, spec and objectql source as at196afd75cd. PostgreSQL ran on a private PostgreSQL 16.14 cluster in this container, started for the run and stopped after.where2,-1,0.5, aDate(the card)DATABASE_ERRORat every slotINVALID_FILTERat every slotfilterhavingon a groupBy of the fieldwhere[true]as a$inmember (the card)filter/having[true]at implicit /$eq/$newhere2n(in-process)where1n(in-process)$ne 1n: both rows on memory){ "a": 1 }true,1,"true"(the controls)Premise check, and the dispatch's hypotheses
69a12a0952,readBooleanComparandreturnednullfor every non-string except1/0, the verdict answeredpasses, and the reading rows readunread(2)/unread(-1). The card's table reproduces on memory, SQLite and PostgreSQL (rows above). The number contract's non-string branch was the template.judgeBooleanComparandalready passedvalue: comparandandform: verdict.formstraight through, so widening the verdict reacheswhere(both spellings), the per-aggregationfilterandhavingwith no code change in objectql. The shared walk file is not in the diff.formulareturningbooleanis still refused one door earlier, by the unmaterializable-field door, withINVALID_FIELD/ 400. The arm suite's NAMED DIVERGENCE test drives every formula row, including the newvaluerow onf_formula_boolean.The bigint reading
The ruling does not name
bigint. Measured on the base,1nagainst the boolean field gave different answers per driver atwhere: no row on InMemoryDriver, the true row on SQLite and PostgreSQL.2ngave PostgreSQL's 500. That is the card's defect class, through an in-process caller. The comparand-type door rewrites abigintto its number. It runs AFTER this door on the object spelling ofwhereand on a per-aggregationfilter, and BEFORE it on theFilterArrayspelling and athaving. So:bigintleaves the divergence;bigintwould refuse1non one spelling and narrow it on the other;This is not a widening. A
bigintpassed the verdict before, so it was accepted, and now it is either narrowed correctly or refused. The arm suite pins it on both spellings and at all three positions.Objects: served by the comparand-type door, deliberately
Triage's list names objects. On the base, an object comparand against the boolean field is already refused with
INVALID_FILTER/ 400 by the comparand-type door, on all three drivers and at all three positions (table above). The widened verdict passes such a value rather than refusing it a second time. The reason is the one PR #20545 measured: the engine runs the two doors in a different order per position, so a second refusal would answer one mistake with two sets of words. The arm suite pins it: for a plain object,undefinedand aMap, the engine's message equals the comparand-type door's own message.Tests (at
196afd75cd, the final head)Each heavy run went through
scripts/pm/os-verify-lock.shwith exit codes from the lock'sVERDICT command-exitlines.@objectstack/spec:filter-boolean-comparand-declared-type.test.ts34/34.--project local, run as two--shardhalves to fit the container's foreground cap: 300 files / 8983 passed + 1 todo, and 299 files / 8580 passed (599 files in all, 0 failed).typecheckexit 0;check:test-typecheckholds its ledger (52 files / 246 errors, none added).@objectstack/objectql:engine-boolean-comparand-declared-type-door.test.ts34/34, andengine-number-comparand-declared-type-door.test.tsstill green (its boolean-arm partition reads the widened verdict).--project localas two shards: 182 files / 3583 passed, and 182 files / 3777 passed.typecheckexit 0 (ledger 40 / 234, none added).@objectstack/rest:data-boolean-comparand-door.test.tswithOS_TEST_POSTGRES_URLset: 6 passed, 3 skipped (the MySQL leg). The SQLite and PostgreSQL legs both ran.--project local(no live URL): 255 files, 4808 passed, 322 skipped.typecheckexit 0 (test layer 0 / 0).pnpm lintis CI's):eslint.config.mjsitself: all 5 touched lintable files answerisPathIgnoredfalse. The other 4 changed files are JSON / Markdown, outside its globs.eslint --no-inline-config --format jsonover them: 5 files, 0 errors, 0 warnings.parserOptions.project, noprojectService), so this diff cannot move any untouched file's verdict.node scripts/pm/dispatch-gates.mjs --commands(no paths) at196afd75cdderives 90 families; all 90 ran with exit 0, and--ranreconciles 90 derived / 90 run / 0 NOT-MEASURED.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET, because the workspace was not fully built. It was re-run after a fullturbo run build, and exited 0.Ablation (reverse verification)
From the committed head
196afd75cd, withscripts/ablation-replace.mjsin wrap mode. The mutation lives only while the tool's trap is armed. It removes the widening at its one routing line in the verdict:if (form === null) return { verdict: 'passes' };gains|| (globalThis as Record[string, unknown]).ABLATION_21382 === undefined(angle brackets spelled as square brackets here), so every non-string passes again. The string rule is untouched.41a24373to064410ff. Afterpnpm --filter @objectstack/spec build,ablation-dist-preflightfound the marker in 4 built files (exit 0). Results:valuegroup);where, per-aggregationfilter,havingand bigint tests);41a24373equal to HEAD,git diff HEADempty, andgit status --porcelainempty for the whole tree. After a rebuild,ablation-dist-preflight --absentfound the marker absent from all 230 built files (exit 0). Results: spec 34/34, objectql 34/34, REST 6 passed / 3 skipped.Acceptance notes
comparandPreviewis a private copy in the boolean module. The number module's copy is private, and PR feat(spec)!: FieldSchema refuses a select / radio with neither options nor picklist #21390 holds that file, so this PR does not touch it. Moving both copies intofilter-comparand-refusal-text.tsis a consolidation for whichever PR next touches both. No carrier is named.engine.tsaround thenarrowNumberComparandscall ("any other string is refused"), and the shared walk's[#21333]header section. Both are incomplete, not false. No carrier is named.OS_TEST_POSTGRES_URLforpackages/rest), as with the number cell. This PR carries their local PostgreSQL run.Generated by Claude Code