Repository navigation
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 |
…ainst a boolean field is refused like a non-boolean string (objectstack-ai#21382) (objectstack-ai#21404) Fixes objectstack-ai#21382 Clause-②: yes (narrowing) ## What this changes One verdict, widened, as triage directed on the card (grade `5949762416`, inheriting the number door's direction `5877498426`): the published boolean-comparand verdict now refuses any comparand against a declared `boolean` / `toggle` field (or a `formula` returning `boolean`) 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 objectstack-ai#20545 (`b05743433b`) on the number door, and it follows that PR's shape and words where the two contracts have the same parts. - **The verdict**, `packages/spec/src/data/filter-boolean-comparand-declared-type.ts`. `booleanComparandDoorVerdict(field, comparand)` answers `door-refusal` (`INVALID_FILTER` / 400) for a number other than `1` / `0` (`2`, `-1`, `0.5`, `NaN`), a `Date` and an array, at a scalar slot or as a list member. The string rule is byte-for-byte what PR objectstack-ai#21372 published. The accepted set is unchanged: `true`, `false`, `1`, `0`, `"true"`, `"false"`, `"1"`, `"0"`; `null` is still the null test. - **A `bigint` is read as the number it names**: `1n` / `0n` narrow like `1` / `0`, and any other `bigint` is refused as a number. See *The bigint reading* below for why this is the only reading that gives one answer at every position. - **What still passes**: a boolean, `null`, a `{ $field }` reference, and every value outside the comparand-type door's accepted set (`undefined`, a plain object, a `Map`). That door already refuses those on every field, in its own words (see *Objects* below). - Three additive exports: `NON_BOOLEAN_VALUE_FORMS` (`number`, `date`, `array`) and the types `NonBooleanValueForm` / `NonBooleanComparandForm`. The refusal's `form` widens from `NonBooleanStringForm` to `NonBooleanComparandForm`, on `BooleanComparandDoorVerdict`, `BooleanComparandRefusalSite` and `BooleanComparandDoorRefusalCase`. The site's `value` was already `unknown`; it now carries a non-string. `booleanComparandDoorVerdict`'s signature is unchanged (relevant to objectstack-ai#21376, which will consume it). - Three refusal clauses. None names a backend, because PostgreSQL's server error was measured at `where` only. The engine evaluates the per-aggregation `filter` and `having` itself, and this contract has no driver-bound site flag. A `Date` renders as `Date(ISO)` and a non-finite number by name, not as JSON (`NaN` would otherwise print as `null`, the null test). - The derived case table gains a `value` group: `-1` at `$ne` on every judged field, and `2` and a `Date` at every judged position of `f_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. The `2` / `-1` reading rows, and a new `0.5` row, now derive refusals. `freshComparand` copies a `Date` and an array per filter, as the number table does. No `bigint` row is in either table, so every filter a suite builds survives `JSON.stringify`. - **The arm**, `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. - **Tests**: the spec contract suite, the objectql arm suite, and a new REST three-dialect cell `packages/rest/src/data-boolean-comparand-door.test.ts`, beside `data-number-comparand-door.test.ts`. In that cell SQLite always runs, and PostgreSQL / MySQL are named skips without their URL. - **Generated**: `packages/spec/api-surface/data.json` and `export-origins/data.json`, three added lines each, regenerated by `gen:api-surface` / `gen:export-origins` after `check:generated` named exactly those two. - **Changesets**, one per published package moved, both BREAKING `minor` with `!`, a `Clause-②: yes (narrowing)` line and one ADR-0087 marker (`not-required (no-migration-prescription)`): `.changeset/21382-spec-boolean-comparand-non-string.md` and `.changeset/21382-objectql-boolean-comparand-non-string.md`. ## Clause-②, measured `node scripts/pm/check-widening-tells.mjs` on `git diff 69a12a0...HEAD`: - `--declaration no`: exit 4, three T3 tells. These are the three new rows in `packages/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 `boolean` field, two rows (`rt` true, `rf` false), through `engine.find` / `engine.aggregate`. The probe is a scratch script against freshly built dists, not a committed test. A `where` cell reads implicit, `$eq`, `$ne`, and a `$in` member beside `false`. Before: base `69a12a0952`. After: this branch, spec and objectql source as at `196afd75cd`. PostgreSQL ran on a private PostgreSQL 16.14 cluster in this container, started for the run and stopped after. | position | comparand | before: memory · SQLite · PostgreSQL 16 | after, all three | |:--|:--|:--|:--| | `where` | `2`, `-1`, `0.5`, a `Date` (the card) | no row, no row, both, the false row · the same · **500 `DATABASE_ERROR` at every slot** | 400 `INVALID_FILTER` at every slot | | per-aggregation `filter` | the same | count 0, 0, 2, 1 on all three | 400 | | `having` on a groupBy of the field | the same | no group, no group, both, the false group on all three | 400 | | `where` | an array `[true]` as a `$in` member (the card) | **the false row (200)** · a driver 400 · a driver 400 | 400, in the contract's words | | per-aggregation `filter` / `having` | the same | count 1 / the false group on all three: the member silently dropped | 400 | | all three | `[true]` at implicit / `$eq` / `$ne` | 400 (the comparand-shape door) | the same | | `where` | `2n` (in-process) | no row · no row · 500 | 400 | | `where` | `1n` (in-process) | **no row · the true row · the true row** (`$ne 1n`: both rows on memory) | the true row on all three | | all three | `{ "a": 1 }` | 400 (the comparand-type door) | the same, same words | | all three | `true`, `1`, `"true"` (the controls) | the true row, count 1, the true group | the same | ## Premise check, and the dispatch's hypotheses - **H1 holds.** On `69a12a0952`, `readBooleanComparand` returned `null` for every non-string except `1` / `0`, the verdict answered `passes`, and the reading rows read `unread(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. - **H2 holds: no walk change.** `judgeBooleanComparand` already passed `value: comparand` and `form: verdict.form` straight through, so widening the verdict reaches `where` (both spellings), the per-aggregation `filter` and `having` with no code change in objectql. The shared walk file is not in the diff. - **H3**: the after-column is re-measured on memory, SQLite and PostgreSQL 16.14 (above). MySQL is NOT MEASURED, because no MySQL server is available in this container. The REST cell's MySQL leg is a named skip. - **H4 holds.** A `formula` returning `boolean` is still refused one door earlier, by the unmaterializable-field door, with `INVALID_FIELD` / 400. The arm suite's NAMED DIVERGENCE test drives every formula row, including the new `value` row on `f_formula_boolean`. ### The bigint reading The ruling does not name `bigint`. Measured on the base, `1n` against the boolean field gave different answers per driver at `where`: no row on InMemoryDriver, the true row on SQLite and PostgreSQL. `2n` gave PostgreSQL's 500. That is the card's defect class, through an in-process caller. The comparand-type door rewrites a `bigint` to its number. It runs AFTER this door on the object spelling of `where` and on a per-aggregation `filter`, and BEFORE it on the `FilterArray` spelling and at `having`. So: - passing a `bigint` leaves the divergence; - refusing every `bigint` would refuse `1n` on one spelling and narrow it on the other; - reading it as the number it names gives one answer at every position. This is not a widening. A `bigint` passed 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 objectstack-ai#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, `undefined` and a `Map`, 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.sh` with exit codes from the lock's `VERDICT command-exit` lines. - **`@objectstack/spec`**: `filter-boolean-comparand-declared-type.test.ts` 34/34. `--project local`, run as two `--shard` halves to fit the container's foreground cap: 300 files / 8983 passed + 1 todo, and 299 files / 8580 passed (599 files in all, 0 failed). `typecheck` exit 0; `check:test-typecheck` holds its ledger (52 files / 246 errors, none added). - **`@objectstack/objectql`**: `engine-boolean-comparand-declared-type-door.test.ts` 34/34, and `engine-number-comparand-declared-type-door.test.ts` still green (its boolean-arm partition reads the widened verdict). `--project local` as two shards: 182 files / 3583 passed, and 182 files / 3777 passed. `typecheck` exit 0 (ledger 40 / 234, none added). - **`@objectstack/rest`**: `data-boolean-comparand-door.test.ts` with `OS_TEST_POSTGRES_URL` set: 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. `typecheck` exit 0 (test layer 0 / 0). - **Lint, a proven narrowing** (the repo-wide `pnpm lint` is CI's): 1. Population, from `eslint.config.mjs` itself: all 5 touched lintable files answer `isPathIgnored` false. The other 4 changed files are JSON / Markdown, outside its globs. 2. `eslint --no-inline-config --format json` over them: 5 files, 0 errors, 0 warnings. 3. The config never enables type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move any untouched file's verdict. - **Gates**: `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at `196afd75cd` derives 90 families; all 90 ran with exit 0, and `--ran` reconciles 90 derived / 90 run / 0 NOT-MEASURED. `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET, because the workspace was not fully built. It was re-run after a full `turbo run build`, and exited 0. ## Ablation (reverse verification) From the committed head `196afd75cd`, with `scripts/ablation-replace.mjs` in 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. - **Mutated leg**: anchor 1 to 0, blob `41a24373` to `064410ff`. After `pnpm --filter @objectstack/spec build`, `ablation-dist-preflight` found the marker in 4 built files (exit 0). Results: - spec suite: **3 failed** / 31 passed (the non-string verdict, the bigint reading, the `value` group); - objectql arm suite: **5 failed** / 29 passed (the GUARD's form set, and the `where`, per-aggregation `filter`, `having` and bigint tests); - REST cell: **4 failed** / 2 passed / 3 skipped (the refusal tests on both the SQLite and PostgreSQL legs). - The control tests stayed green on every suite. The direction is the expected one: red. - **Restore leg**: blob back to `41a24373` equal to HEAD, `git diff HEAD` empty, and `git status --porcelain` empty for the whole tree. After a rebuild, `ablation-dist-preflight --absent` found the marker absent from all 230 built files (exit 0). Results: spec 34/34, objectql 34/34, REST 6 passed / 3 skipped. ## Acceptance notes - `comparandPreview` is a private copy in the boolean module. The number module's copy is private, and PR objectstack-ai#21390 holds that file, so this PR does not touch it. Moving both copies into `filter-comparand-refusal-text.ts` is a consolidation for whichever PR next touches both. No carrier is named. - Two comments outside this claim's file surface still describe the string half only: `engine.ts` around the `narrowNumberComparands` call ("any other string is refused"), and the shared walk's `[objectstack-ai#21333]` header section. Both are incomplete, not false. No carrier is named. - The REST cell's live legs are red-capable and un-run in CI (no job provisions `OS_TEST_POSTGRES_URL` for `packages/rest`), as with the number cell. This PR carries their local PostgreSQL run. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…eference matcher (objectstack-ai#20822 group 4) (objectstack-ai#21395) Fixes objectstack-ai#20822 Clause-②: no objectstack-ai#20822 group 4, the card's last group: the comments and docblocks outside `driver-memory` that still named its retired reference matcher (`memory-matcher.ts`, retired by commit `8fec76a2b`) as a live surface. This is the carry group 1's ACCEPT put on the card's last group PR. Claim: the PM's `Claim:` comment 5948997842 (branch `claude/issue-20822-retired-matcher-pointers`). Cross-lane declarations: spec seat post (5949027331) and services seat post (5949038856). **The seat confirms `Fixes` at ACCEPT.** Hypothesis H1 (the site list is complete outside `driver-memory`) is falsified: 28 more sites outside the claim's file surface still name the matcher as live (25 comments and docblocks, one JSON ledger note, and 2 string literals in code). They are not edited here. They are listed under "Sites outside the claim's surface" below, and the route is the open question in the `os-dev-report` on objectstack-ai#20822. Base `11905a4f8b`; `origin/main` `db0cf2231b` merged once (merge `ff241ad71a`, no conflict, no file in this diff). Head `ff241ad71a`. Net diff against `main`: 10 files, +68 / -35. Not governed. ## What changes Comment and docblock prose only. A sentence that named the matcher as a live surface now names what carries the semantics today, measured per site, or says the matcher is retired. Historical sentences stay. | Site | Reading at base | Action | |---|---|---| | spec `filter-logic-conformance.ts:15` | live: the backend table's "In-memory matcher / `memory-matcher`" row | now "In-memory query path / `driver-memory` `normalizeFilterCondition`, then mingo", with the retirement in the same row (H3) | | spec `filter-comparand-shape.ts:127` | live: "the matcher's own answers ... are sealed behind this refusal" | past tense, plus the retirement | | spec `filter-comparand-shape.ts:142` and `:144` | live: "compares through JS coercion", "the matcher is not repaired" | past tense, plus the retirement | | service-analytics `objectql-strategy.ts:1687` | live: "`driver-memory`'s matcher ... pin" `{$not: {}}` | now `driver-memory`'s query path (`memory-driver-document-not.test.ts` pins it) | | service-analytics `objectql-strategy.ts:2055` (the unlock read it at `:2014`) | live: `memory-matcher.ts` "does" read `$regex` | past tense, until `$regex` and then the matcher were retired | | service-analytics `filter-normalizer-not-null-safe.test.ts:50` | live: points at the deleted `memory-matcher-not-null-safe.test.ts` | now `memory-driver-document-not.test.ts`, which holds its cells | | service-analytics `objectql-contains-canonical-operator.test.ts:31`, `:103`-`:110`, `:118`, `:305` | live: the mirror evaluates "the way `memory-matcher.ts` does", and "`driver-memory`'s `$regex` arm is deliberate and serves a real producer" | past tense; the producer's move to `$contains` is pointed at in `filter-refusal.ts`. `:118` and `:305` are in the same file but not in the unlock's list | | service-storage `attachment-read-visibility.test.ts:13` | live: "Mirrors `memory-matcher.ts` and `formula`'s `matches-filter.ts`" | now mirrors `formula`'s `matches-filter.ts`; the matcher is past tense | | service-storage `attachment-read-visibility.test.ts:326` | live: points at the deleted `memory-matcher-or-semantics.test.ts` | now `memory-driver-filter-logic-conformance.test.ts`, which holds its cases | | plugin-security `claim-seed-ownership.ts:91` | live, and wrong before the retirement: the `id IN (...)` scan attributed to `memory-matcher.ts` | now mingo's `$in`, which `InMemoryDriver` hands the list to (measured below) | | formula `matches-filter-not-null-safe.test.ts:17` | live: points at the deleted `memory-matcher-not-null-safe.test.ts` | now `memory-driver-document-not.test.ts` | | formula `matches-filter-not-null-safe.test.ts:120` | live: the matcher "answers the opposite" | the query path answers the same as this face (`memory-driver-document-not.test.ts` pins `['1']`); the matcher answered the opposite until PR objectstack-ai#13356 and is retired | | `docs/design/predicate-compilation-convergence.md:44`, `:56`, `:358` | census rows anchored at `3711e0b763` | past tense plus the retirement, as PR objectstack-ai#21336 did for the F7 row | Read and left as they are, because each is already historical or not a claim about a live matcher: spec `filter-logic-conformance.ts:184`, `:211` (a measurement table dated by its commits), `:244`, `:480`; `:492` names `memory-matcher-no-value-negated-operators.test.ts`, which still exists under that name and holds the live path's cells; `:503` and `:509` are string literals in the past tense; `filter-comparand-shape.ts:122`-`:124` (the reason for the 2026-08-31 ruling); formula `matches-filter-icontains.test.ts:91` ("what the reference matcher was moved onto"); the design doc's `:510` (the D6 decision row) and `:570` (a commit-table row). ## Measurements **H3, what `driver-memory` evaluates a filter with today.** `InMemoryDriver.find`, `count`, `updateMany`, `deleteMany` and the others call `convertToMongoQuery` (`memory-driver.ts:1421`). It runs `assertFilterConditionShape` (`filter-refusal.ts`), then `normalizeFilterCondition` (`memory-driver.ts:1600`), and hands the result to mingo's `Query`. `memory-driver-filter-logic-conformance.test.ts` runs `FILTER_LOGIC_CASES` through `InMemoryDriver.find`, and `check:driver-conformance` holds it. So the spec table's in-memory row names the query path. **`claim-seed-ownership.ts`'s `id IN (...)` sentence.** `normalizeFieldOperators`' `$in` arm (`memory-driver.ts:1862`) passes `$in` through. mingo 7.2.4's `$in` predicate (`operators/_predicates.js`) is built once per query and called once per document; each call runs `intersection([values, list])` (`util/_internal.js`), which fills a hash map from the whole list. So the sentence's "linear scan of the id list PER ROW" holds, through mingo, and the attribution to the matcher was wrong. **Code-token guard (PR objectstack-ai#21357's two readings), base `11905a4f8b` against the working tree at head, TypeScript 6.0.3.** Reading 1 is the parser's leaf nodes from a `forEachChild` walk, so comments are trivia and JSDoc is never visited; a leaf that is not a token is re-scanned with trivia skipped. Reading 2 is the token stream from a `getChildren` walk, with JSDoc nodes skipped. Identifiers and string, template and numeric literals are compared in full. - Real run over all 8 touched `.ts` files: 26,645 base tokens (reading 2), **0 files with a token change** (exit 0). - Comment control ("invents no second one" to "invents NO second one", `objectql-strategy.ts`): 0 files changed (exit 0). - Positive control, an identifier (`filterNodeToCondition` to `filterNodeToConditionX`, `objectql-strategy.ts`): DIFFER on both readings (exit 1). - Positive control, a string literal (a `FILTER_LOGIC_CASES` `name` gains an `X`, `filter-logic-conformance.ts`): DIFFER on both readings (exit 1). - Positive control, a numeric literal (`MAX_BULK_PER_ROW_HOOK_ROWS / 2` to `/ 3`, `claim-seed-ownership.ts`): DIFFER on both readings (exit 1). Each mutation went through `scripts/ablation-replace.mjs` in wrap mode (anchor 1 to 0). Each restore was proven: blob equal to `HEAD` and `git diff HEAD` empty. **`dist` reach (H4), three legs plus a determinism leg.** The five packages' `dist` files were hashed after each build. Every build exited 0 and ran under the shared verify lock. All four legs ran at `3ba971f1b6`; the later commits change no file in the five packages. - Leg 1: a turbo build of the five packages and their closure (20 tasks, all five cache misses). - Leg 2: the 8 changed files of the five packages back at their base blobs (8 of 8 proven equal), then each package's own `build`. - Leg 3: the 8 files restored (8 of 8 equal to their `HEAD` blob, `git diff HEAD` empty), then the same five builds. - Leg 4: `@objectstack/spec`'s own `build` again. It equals leg 3 in all 230 files, so that build path is deterministic. | Package | Changed | Added non-test lines found verbatim in `dist` | Leg 2 against leg 3 | Changeset | |---|---|---|---|---| | `@objectstack/spec` | 2 src files | 1 of 8: the table row, in `data/index.d.ts` and `data/index.d.mts` | 34 files differ: `data/index.d.ts` / `.d.mts`, 30 source maps (line offsets), 2 build-input hashes | `patch` | | `@objectstack/service-analytics` | 1 src + 2 test files | 5 of 5, in `index.js` / `index.cjs` (one also in `index.d.ts` / `index.d.cts`) | 6 of 6 differ | `patch` | | `@objectstack/plugin-security` | 1 src file | 0 of 4 | only `index.js.map` and `index.mjs.map` differ: line offsets, because the docblock grew by two lines; the maps carry no `sourcesContent` | none | | `@objectstack/formula` | 1 test file | none | 0 differ | none | | `@objectstack/service-storage` | 1 test file | none | 0 differ | none | `.changeset/20822-retired-matcher-pointers.md` therefore declares `patch` for `@objectstack/spec` and `@objectstack/service-analytics`, comment text only, with `Clause-②: no`. Each changeset sentence maps to a diff line: the spec table row at `filter-logic-conformance.ts:15`, and the two `ObjectQLStrategy` comments at `objectql-strategy.ts:1687` and `:2055`. ## Gates and tests (head `ff241ad71a`) - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `ff241ad71a` (10 paths against merge base `db0cf2231`) derived 89 commands. All 89 ran, each exit code captured before any pipe. 87 exited 0 on the first run. `check:dual-build-cjs-loads` and `check:i18n` exited 3 (PREREQUISITE NOT MET: unbuilt workspace packages), not a measurement. Both exited 0 after a whole-workspace build (`turbo run build --filter=!@objectstack/docs`, 72 tasks, VERDICT command-exit 0). `--ran` reports "89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN" and exits 0. - **Tests, under the verify lock, `vitest run --maxWorkers=2`:** - spec `--project local`: 598 files, 17,529 passed, 1 todo; - spec `--project repo`: 48 files, 849 passed; - formula: 43 files, 1,257 passed; - service-analytics: 167 files, 3,786 passed, 83 skipped; - service-storage: 41 files, 629 passed; - plugin-security: 159 files, 3,479 passed, 23 skipped. - **Typecheck:** `pnpm --filter ... typecheck` exits 0 for spec (with `check:scripts-typecheck` and `check:test-typecheck`), formula, service-analytics, service-storage and plugin-security. Formula, service-storage and plugin-security also run `check:test-typecheck`, and service-analytics' `tsc --listFiles` program holds 164 of its `__tests__` files, both touched ones included. - **Lint, as a proven narrowing:** `eslint --no-inline-config --format json` over the 8 touched `.ts` files plus `service-analytics/dist/index.js` as a control gives 9 results, 0 errors and 1 warning: the control's ignore notice. None of the 8 is reported ignored, and each resolves under `--print-config`. `eslint.config.mjs` never enables type-aware linting (its lines 327-328 say so), so a comment edit cannot move the verdict on an untouched file. The repo-wide `pnpm lint` is CI's run. - **Bytes:** `pnpm check:nul-bytes` exits 0. A control-byte scan over the 10 changed files finds none. ## Sites outside the claim's surface (round 0; superseded by patch round 1 below) Read at base `11905a4f8b` with every spelling: `memory-matcher`, `reference matcher`, `in-memory matcher`, `memory matcher`, `match()`, the `memory-matcher-*` test-file names, and "`driver-memory`'s matcher". `driver-memory`, `CHANGELOG.md` and `content/docs/releases/**` are excluded. Each of these still names the matcher as a live surface: - `service-analytics` `read-scope-not-null-safe.test.ts:43`: points at the deleted `memory-matcher-not-null-safe.test.ts`. It is on group 1's list and inside the ACCEPT's "service-analytics test docblocks", but not in the unlock's list or the claim. - `driver-mongodb` `mongodb-filter.ts:1151` ("it is the oracle both drivers agree with"). - `driver-turso` `remote-transport-boolean-identity.test.ts:43` and `remote-transport-not-operator.test.ts:40`. - `formula` `matches-filter.ts:729` (`asciiCaseInsensitiveContains` is "the same one `driver-memory`'s matcher ... call[s]"). - `objectql`: - `having-filter.ts:23`, `:26` and `:2064`; - `having-filter.test.ts:8` and `:60`; - `number-comparand-declared-type-door.ts:46` ("the memory matcher compares"; which face it means is ambiguous); - `validation/record-validator.ts:533` ("five hand-rolled shape tests", one of them the matcher); - `tsconfig.test.json:22` and `test-typecheck-debt.json:3` (a JSON string). - `plugin-security`: - `bootstrap-declared-capabilities.test.ts:39` and `bootstrap-system-capabilities.test.ts:29`; - `rls-check-stored-form.ts:40`, written after the retirement, so it probably means the query path. - `service-analytics` `strategies/filter-normalizer.ts:450` ("the in-memory matcher ... already held to" the table). - `spec`: - `filter.zod.ts:411`, `:940` and `:3111` (live implementation-status tables), `:1198`, `:1242` (the exported `asciiCaseInsensitiveContains` docblock) and `:3142` (the `$empty` table); - `filter-text-conformance.ts:342` ("both then and now"); - `ui/view.zod.ts:605` ("`match()` runs `assertFilterConditionShape`"). - **String literals**, outside this group's form: - spec `filter.zod.ts:470`, the author-facing refusal for a `null` ordering comparand. Measured on the published schema door: `FieldOperatorsSchema.safeParse({ $gt: null })` answers "... its reference matcher compares through JS coercion ...", in the present tense. - spec `filter-operator-vocabulary.test.ts:86`, an assertion message that prescribes editing the reference matcher. The governed `.claude/skills/pm-dispatch/references/compile-surfaces.md:16` stays on the seat post's protocol observation, as the ACCEPT placed it. 34 more hits outside the surface are historical (past tense, dated measurements, or string literals in the past tense). The `os-dev-report` on objectstack-ai#20822 lists them. ## Patch round 1 (head `54c8e70e88`) Section added by the `domain:engine#1` seat, from the dev's patch-round report (5952834398 on objectstack-ai#20822). - **The seat's answer to round 0's open question:** A, minus the two string literals (claim amendment 5950842658). The round corrects the 26 comment, docblock and ledger-note sites listed there, under the same rule. - `read-scope-not-null-safe.test.ts:43` comes first, in its own commit `68ca26224f`. It is the site inside group 1's ACCEPT carry that the seat's unlock had dropped, so **every site that ACCEPT carried is now corrected, and `Fixes objectstack-ai#20822` stands.** - The added surfaces are declared on the spec (5950854566) and services (5950863357) seat posts. - **Not edited:** - the two string literals `filter.zod.ts:470` (the author-facing null-ordering refusal) and `filter-operator-vocabulary.test.ts:86` (an assertion message). They are objectstack-ai#21397's (spec lane), so this PR stays comment-only; - `filter.zod.ts:3106` ("that driver's matcher", generic staging reasoning with an ambiguous referent); - the governed `compile-surfaces.md`. - **Commits:** - `1118eebbcd` merges `origin/main` `56238d890d` once, with no conflict. objectstack-ai#21372's hunks moved no listed line; - then `68ca26224f`, `1bf9b24f26`, `57d675df10`, and `54c8e70e88` (the changeset). The net diff against `main` is 28 files, +152/−81, not governed. - **Measured per site:** each rewritten sentence names what carries the semantics today, or says commit `8fec76a2b` retired the matcher. The probes behind the sentences that state a behaviour: - mingo's string ordering for `filter.zod.ts:411`; - mingo over the declared-number table for `number-comparand-declared-type-door.ts:46`; - `InMemoryDriver`'s null-or-missing match for the two `plugin-security` bootstrap tests; - the callers of `asciiCaseInsensitiveContains`. Historical sentences are untouched. - **Code-token guard** (both readings, base = merge base `56238d890`, so it covers the whole PR): - all 24 touched `.ts` files: **0 files changed**; - the two JSON files through `ts.parseJsonText`, with `test-typecheck-debt.json`'s `_note` masked: 0 changed. That `_note` is the text this round edits by design; an `entries` value control still DIFFERS under the mask; - controls (identifier, string, numeric, and JSON value) each DIFFER. - **`dist` reach** (three legs; the legs agree byte for byte in 276 of 276 files): | package | rewritten lines in `dist` | entry | |---|---|---| | `@objectstack/spec` | 12 of 21: 9 in the filter declaration chunk and 3 in the data bundles; `filter.zod.ts` and `view.zod.ts` also ship as source | `patch`, extended | | `@objectstack/formula` | 3 of 3 | `patch`, added | | `@objectstack/objectql` | 3 of 19 | `patch`, added | | `@objectstack/service-analytics` | its round-0 entry stands; this round's line reaches only the source maps | unchanged | | `driver-mongodb`, `plugin-security` | maps only, or nothing | none | | `driver-turso` | test files only | none | - **Tests and typecheck** at `54c8e70e88`: - the suites of `spec`, `formula`, `objectql`, `driver-turso`, `driver-mongodb`, `plugin-security` and `service-analytics` are green. `driver-mongodb`'s real-mongod suites are not measured here: there is no `mongod`; - every touched package's typecheck exits 0; - gates: 95 derived, 95 run, 0 not measured. ## Acceptance notes - **Build path, not this diff.** Leg 1 (turbo) and leg 3 (each package's own `build`) are both at the same text. They differ in 14 `@objectstack/spec` declaration files (`api`, `automation`, `contracts`, `marketplace`, `system` and two `node-executor.zod` chunks, `.d.ts` / `.d.mts`). Leg 3 equals leg 4 byte for byte, so each path is deterministic and the rewrite's effect was read on legs 2 and 3, which share a path. The difference between the paths is not explained here. - **`main` moved after the merge.** Seven more commits landed after `db0cf2231b` (to `69a12a0952` when this was written); none touches a file here. The derivation's only stale input among them is `scripts/sdui-manifest.record.json`. CI judges the merge ref. - **Contract review** is owed at tier: the path limb is `packages/spec/src/**`, non-test. The seat runs it. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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