Skip to content

Commit 9f13c94

Browse files
fix(objectql)!: a string comparand against a boolean field is narrowed to its boolean, or refused 400, at the engine filter door (#21372)
Fixes #21333 Clause-②: yes (narrowing) ## What this does A comparand against a declared `boolean` / `toggle` field (or a `formula` returning `boolean`) is now judged at the engine's one field-aware filter walk, the same walk that judges number comparands: - `true` / `false` reach the driver as written (the controls); - `1` / `0`, `"1"` / `"0"` and `"true"` / `"false"` are **narrowed** to `true` / `false`, copy-on-write, so every driver receives the one boolean each spelling names; - any other string (`"yes"`, `"TRUE"`, `" true "`, `""`, a `{placeholder}`) is **refused** `INVALID_FILTER` / 400, naming the field and its declared type, before any driver is resolved. That holds at `where` (object form and `FilterArray` sugar), the per-aggregation `filter` and `having`, on every verb that collects a filter (`find` / `findOne` / `count` / `aggregate` / `update` / `delete`, plus `judgeFilter`). Every REST spelling reaches that one walk unchanged (the POST body `where`, `?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 derived `BOOLEAN_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 existing `walkCondition` asks the boolean arm at every field key the number arm does not judge. `judgeFieldSpec` now 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. - Tests: `filter-boolean-comparand-declared-type.test.ts` (spec) and `engine-boolean-comparand-declared-type-door.test.ts` (objectql). The number engine suite gets a named partition for its two census rows on `f_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). - Two changesets: `objectql` `minor`, BREAKING, `Clause-②: yes (narrowing)`, with an ADR-0087 `not-required (no-migration-prescription)` disposition; and `spec` `minor`, 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`, one `false`). Measured with a one-time harness through `engine.find`, `engine.aggregate` (`where` count and per-aggregation `filter` count) and `findData` for all five REST spellings, on InMemoryDriver and SqlDriver/SQLite. The harness used freshly built dists: base `6c5bef5f4`, and after on the merged head `1c184d7695`. Every cell is a 200 unless it says otherwise. | comparand | memory before | SQLite before | memory after | SQLite after | |:--|:--|:--|:--|:--| | `"true"` (implicit, `$eq`, `$in`, all 5 REST spellings, aggregate count) | 0 rows | 0 rows | 1 (true row) | 1 (true row) | | `$ne "true"` / `$nin ["true"]` | **2 rows** | **2 rows** | 1 (false row) | 1 (false row) | | `"false"` / `$ne "false"` | 0 / 2 | 0 / 2 | 1 / 1 | 1 / 1 | | `"yes"` / `$ne "yes"` / `"TRUE"` | 0 / 2 / 0 | 0 / 2 / 0 | 400 `INVALID_FILTER` | 400 `INVALID_FILTER` | | control `1` / `"1"` / `0` / `"0"` | **0 rows** | 1 | 1 | 1 | | control `$ne 1` / `$ne "1"` | **2 rows** | 1 | 1 | 1 | | control `true` / `false` / `$ne true` / `$in [true]` | 1 | 1 | 1 | 1 | | per-aggregation `filter`: `"true"` / `$ne "true"` | 0 / 2 | 0 / 2 | 1 / 1 | 1 / 1 | | `having` over `groupBy f_boolean`: `"true"` / `$ne "true"` / `"yes"` | no group / both | no group / both | true group / false group / 400 | true group / false group / 400 | 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 (strict `true` vs `1`). 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) - **H1, holds.** The number door's walk is shareable. The boolean twin is an arm of `walkCondition`, not a copied walker, and the four engine sites run both arms with no new call. - **H2, holds.** `GET /api/v1/data/:object` hands `req.query` to `findData`, which folds leftover keys into an implicit `where` of strings (`metadata-protocol` `protocol.ts`, the implicit-filters block) and calls `engine.find`. The engine door therefore sees `"true"`, and no REST-side coercion is needed. The GET-door rows are pinned through `findData` in objectql's suite (bare-param spelling included), so no `packages/rest` pin was added. - **H3, holds.** `$ne`, `$in`, `$nin` (and `$between`) members follow the same narrowing. The baseline `$ne "true"` is 2 rows on both drivers, as in the table. - **H4.** The contract could live entirely in objectql without weakening "one door": every surface reaches the one engine walk, and the walk is the only runtime consumer today. It lives in `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 (the `parseNumericString` precedent). 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. - **H5.** Two consumers answer the card's rows wrongly at doors this PR does not touch. They are reported below as out-of-scope findings, ⛔ not widened into. ## Reverse verification The implementation was committed first (HEAD `1c184d7695`). The arm was then ablated through `scripts/ablation-replace.mjs` (anchor `const booleanMeta = booleanArmFieldMeta(facts.boolean);`, replaced by `null`; anchor 1 to 0, blob `cc4b1198` to `b001e9b3`), and both engine suites were run: - **26 red**: every refusal pin and every narrowing pin of the boolean suite (25: the case table's refusals and narrowings, the card's table and refusals, every verb, FilterArray, nested structure, placeholder, `judgeFilter`, aggregate `where`, per-aggregation `filter`, `having`, and all ten REST-door cases), plus the number suite's boolean-arm partition (1). - **37 green**: the controls (`true` / `false` / `null` / flag / `$field` cases reaching the driver unchanged, the by-reference guard), the boolean suite's pure partition guard, and all 36 other number-door pins. - Restore proven by the tool: blob after restore equals the HEAD blob `cc4b1198`, and `git diff HEAD` is empty. ## Tests and gates (all at HEAD `1c184d7695`, freshly built dists) - `@objectstack/spec`: `test` 599 files / 17541 passed (1 todo), `test:repo` 48 / 849, exit 0. `typecheck` exit 0. The new test is in `tsconfig.test.json`'s program (`--listFiles`). - `@objectstack/objectql`: `test` 363 files / 7301, `test:repo` 1 / 5, exit 0. `typecheck` exit 0 (new files in `tsconfig.test.json`'s program). - `@objectstack/rest`: `test` 254 files / 4805 passed (316 skipped), `test:repo` 5 / 177 (1 skipped), exit 0. `typecheck` exit 0. - `@objectstack/driver-memory`: 70 files / 1718, exit 0. `typecheck` exit 0. - `@objectstack/driver-sql`: 215 files passed (11 skipped) / 3594 passed (202 skipped), exit 0. `typecheck` exit 0. - `@objectstack/dogfood`: 166 files passed (1 skipped) / 1369 passed (3 skipped), exit 0. `typecheck` exit 0. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 91 families on this head and all 91 ran; `--ran` reconciles 91 / 91, 0 unrun. Each family's exit code was captured before any pipe. All were 0 except `check:dual-build-cjs-loads`, whose first run was `PREREQUISITE NOT MET` (eight unrelated packages had no `dist/`; nothing measured); after building those eight it ran to exit 0. The run includes `check:dispatcher-error-vocabulary` (no new code: `INVALID_FILTER` through the existing `invalidFilterError`), `check:adr-0087-registration`, `check:empty-changeset`, `spec check:generated`, `check:nul-bytes`, `check:driver-memory-census` and `check:test-source-alias`. ## Acceptance notes 1. **Live-driver pins.** The committed pins drive a recording driver. The arm answers before any driver is resolved, and a narrowed comparand is pinned to reach the driver as the byte-identical filter its boolean control does. The per-driver row counts above were measured with a one-time harness, not committed. A committed SQLite cell would be a `packages/rest` pin (`domain:cli`). A committed InMemoryDriver cell needs a ruling on the closed `check:driver-memory-census`. objectql depends on neither driver. 2. **Not ruled, left as written.** A number other than `1` / `0`, a `Date`, or an array member against a boolean field passes the verdict and reaches the driver as written (no stored boolean equals `2`). 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. 3. **One grammar, two sides, one literal.** `record-validator.ts`'s boolean write arm still spells the accepted set as a literal rather than reading `BOOLEAN_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) - **RLS `using` predicates compare a boolean as written.** The compiled policy filter is composed after the caller's filter door, by design, so `record.flag != 'true'` keeps the true row and `== 'true'` keeps none. Measured after this change through `SecurityPlugin`'s real middleware over a real engine on SqlDriver/SQLite, with `engine.find` and a member context, on two rows: `== true` gives 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. - **Analytics NativeSQL strategy compiles `runtimeFilter` itself.** `POST /api/v1/analytics/dataset/query` over SqlDriver/SQLite (`AnalyticsServicePlugin` composition, 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:booleanComparandDoorVerdict` to runtime `service-analytics` NativeSQL `where` compilation. ## Review round 1 (head `6f74eb444c`, after the at-tier contract review FAIL 5948769828) Section added by the `domain:engine#1` seat, from the dev's patch-round report (5949409460 on #21333): - **The FAIL was on ② alone.** `Clause-②: no (narrowing)` was false. The 26 additive `@objectstack/spec/data` exports 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 read `Clause-②: yes (narrowing)`, `scripts/pm/clause2-line.mjs`'s spelling for a diff that widens one surface and narrows another. - **The changesets** (commit `6f74eb444c`, the only change in this round; no code moved): - `objectql`: `Clause-②: yes (narrowing)`, still BREAKING. Six sentences are scoped to what was measured: `InMemoryDriver` and `SqlDriver` over SQLite, at `findData` rather 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 is `b285508188`'s shape. Its "before" and "remedy" sentences, which described `objectql`'s engine, are replaced by "What moves for consumers". - **Gates at `6f74eb444c`:** 91 derived, 91 run, all exit 0. - `check-adr-0087-registration` lists 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 --diff` exits 0 under `--declaration yes`, and exits 4 under the old `no` with 31 tells (26 × T3, 5 × T2). That confirms the review. - No suite was re-run, because the code is unchanged since `1c184d7695`. - **The review's escalations:** - closing the accepted set to non-string comparands is a follow-up card. The patch round measured it on four drivers: PostgreSQL 16 answers `500 DATABASE_ERROR` at every slot, and an array `$in` member splits 200 / 400 across drivers; - the RLS seam and analytics NativeSQL positions are filed as #21376; - a CI-visible `packages/rest` SQLite cell is an acceptance note. - **Lane.** Triage's answer 5948860364 on #21333: the spec lane lands this PR whole, with the `objectql` half declared. The `domain:engine` seat hands the card over without marking this PR ready or enqueueing it. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d487d2 commit 9f13c94

12 files changed

Lines changed: 1697 additions & 17 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a comparand against a declared boolean field is narrowed to its boolean at the engine's filter door, and any string other than "true" / "false" / "1" / "0" is refused with `INVALID_FILTER` / 400
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal and a narrowing of filter COMPARANDS at the engine's query door, against a declared boolean or toggle field. No authorable key, spelling, export or stored shape moves: every query shape, every FilterCondition and every object definition parse as before, @objectstack/objectql exports nothing new and nothing less, and no stored row is read or rewritten. What is refused is a comparand that matched no row (and every row under $ne) on InMemoryDriver and on SqlDriver over SQLite (PostgreSQL and MySQL not measured), and which boolean a caller meant by "yes" is not something a ledger entry can decide. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a filter comparand, and this diff adds none (not registered / already-registered); and the change is runtime behaviour, not a declaration (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING**: this narrows what a filter may compare a declared `boolean` or `toggle` field with, at every filter position and through every door that reaches the engine's filter walk (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`, and every spelling the data API hands it). It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**What was accepted before.** A string compared with a boolean field was neither refused nor read as a boolean: the engine handed it to the driver as written, and every answer was a 200. Measured on two rows (one `true`, one `false`) on InMemoryDriver and on SqlDriver over SQLite, through `engine.find`, `engine.aggregate` and the protocol's `findData` with each spelling the `POST /api/v1/data/:object/query` and `GET /api/v1/data/:object` routes hand it:
14+
15+
- `"true"` (implicit, `$eq`, `$in`) and `"false"` (implicit), and both through `?filter=`, `?$filter=`, the filter AST and the bare query parameter (`?flag=true`), matched no row on either driver;
16+
- `$ne "true"` and `$nin ["true"]` returned both rows, the true row included;
17+
- `"yes"` matched no row, and `$ne "yes"` both rows;
18+
- `1`, `"1"`, `0` and `"0"` at `where` (and `"1"` / `"0"` through every spelling above) matched the right row on SQLite and no row on InMemoryDriver (`$ne 1` returned both rows there);
19+
- the per-aggregation `filter` and `having` (the engine's own evaluator) answered `"true"` with no row and no group, and `$ne "true"` with every one.
20+
21+
**What is answered now.** At `where` (both spellings), the per-aggregation `filter` and `having`, on every verb that collects a filter, before any driver is asked for a row:
22+
23+
- `true` / `false` are handed to the driver as written;
24+
- `1` / `0`, `"1"` / `"0"` and `"true"` / `"false"` are narrowed to `true` / `false`, so every driver receives the one boolean each names. Measured on InMemoryDriver and on SqlDriver over SQLite, `?flag=true` and `?flag=1` now return the true row; any other driver receives the same narrowed boolean by mechanism (PostgreSQL and MySQL not measured);
25+
- any other string, a different letter case (`"TRUE"`), surrounding whitespace, a blank and a `{placeholder}` included, is refused `INVALID_FILTER` / 400. The message names the field, its declared type, the comparand and its position, and says what is wrong with it.
26+
27+
The accepted set is the one the record validator already admits when a boolean field is WRITTEN. The rule lives in `@objectstack/spec/data`'s `filter-boolean-comparand-declared-type.ts`, and the engine applies it in the same walk that judges number comparands.
28+
29+
**The remedy.** Write `true` or `false`. In a querystring, where every value is a string, write `true` / `false` or `1` / `0`.
30+
31+
**Unchanged.** A boolean comparand, `null` (the null test) and the flag operators (`$null`, `$exists`, `$empty`) answer as before, and so does every comparand against a field that is not boolean. A number other than `1` / `0` against a boolean field is still handed to the driver as written. A filter on a `formula` field is still refused one step earlier, as before.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the boolean-comparand declared-type contract in `@objectstack/spec/data` — the comparands a declared boolean field accepts in a filter, the boolean each narrows to, and the refusal words
6+
7+
Clause-②: yes
8+
9+
**What it declares.** `filter-boolean-comparand-declared-type.ts`, the boolean twin of `filter-number-comparand-declared-type.ts`:
10+
11+
- `BOOLEAN_COMPARAND_SPELLINGS`: the accepted non-boolean spellings, `1` / `0`, `"1"` / `"0"` and `"true"` / `"false"`, each with the boolean it narrows to. This is the set the record validator admits when a boolean field is written. `readBooleanComparand` reads a comparand by it, and names why a string is not one (`NON_BOOLEAN_STRING_FORMS`: `empty`, `padded`, `letter-case`, `placeholder`, `not-a-boolean`).
12+
- `BOOLEAN_COMPARAND_DOOR_JUDGED_TYPES` (`BOOLEAN_VALUE_TYPES` itself), and the judged positions, which are the number door's lists by identity.
13+
- `booleanComparandFieldVerdict` and `booleanComparandDoorVerdict`, the pure verdict: `narrows`, `door-refusal` (`INVALID_FILTER` / 400), `passes` or `deferred`.
14+
- `booleanComparandRefusalMessage`: the refusal words, inside the 500-character client bound.
15+
- `BOOLEAN_COMPARAND_READING_CASES`, `BOOLEAN_COMPARAND_DOOR_FIXTURE` and the derived `BOOLEAN_COMPARAND_DOOR_CASES`, for a door's suite to drive.
16+
17+
**What the verdict answers `door-refusal` for.** A string other than the four accepted ones, compared with a declared boolean field, at the value positions of a filter (the implicit comparand, `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte`, and each member of `$in` / `$nin` / `$between`).
18+
19+
**What moves for consumers.** Nothing in this package refuses or narrows a filter, and every existing export is unchanged. The door that applies the verdict ships in the same release in `@objectstack/objectql`, whose changeset states what changes for a caller.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21333] The BOOLEAN-comparand arm of the engine's one field-aware filter
5+
* walk — the boolean twin of the number-comparand door (#20351), at the same
6+
* seam, the same positions and the same three filter positions (`where` on
7+
* both spellings, the per-aggregation `filter`, `having`).
8+
*
9+
* ## What it answers
10+
*
11+
* A comparand against a declared boolean field (`boolean`, `toggle`, a
12+
* `formula` returning `boolean`):
13+
*
14+
* - `true` / `false` pass, as written;
15+
* - `1` / `0`, `"1"` / `"0"` and `"true"` / `"false"` NARROW to `true` /
16+
* `false`, copy-on-write, so every backend receives the one boolean each
17+
* spelling names — a bare query parameter (`?active=true`) is always a
18+
* string, and before this arm `"true"` matched no row on any driver while
19+
* `1` matched no row on InMemoryDriver;
20+
* - any other string (`"yes"`, `"TRUE"`, `""`, a `{placeholder}`) is refused
21+
* `INVALID_FILTER` / 400, naming the field and its declared type, before
22+
* any driver is resolved.
23+
*
24+
* The contract — the accepted spellings, the pure verdict, the refusal words,
25+
* the case table — is lane (1), `@objectstack/spec/data`'s
26+
* `filter-boolean-comparand-declared-type.ts`. ⛔ Nothing here reads a
27+
* spelling: the verdict does.
28+
*
29+
* ## Why an arm and not a door of its own
30+
*
31+
* `number-comparand-declared-type-door.ts`'s `walkCondition` is the one filter
32+
* walk the engine runs at all three positions with each column's declaration
33+
* in hand, and every position-specific fact (the site kind, the relation arm,
34+
* the copy-on-write discipline, the depth bound and the combinators descended)
35+
* lives in it. A second walk would redraw every one of those boundaries. So
36+
* the walk asks this arm at each field key the number arm does not judge (the
37+
* two classes are disjoint), exactly as it asks the no-operator-object arm
38+
* (`no-operator-object-door.ts`) — and, like that module, ⛔ nothing here
39+
* walks a filter.
40+
*
41+
* ## One door for every surface
42+
*
43+
* Every REST spelling reaches the walk unchanged: the `POST …/query` body's
44+
* `where`, the `filter` / `$filter` JSON and the `FilterArray` sugar
45+
* (`parseFilterAST` lowers it first), and the bare query parameters, which
46+
* `metadata-protocol`'s `findData` folds into an implicit `where` of strings.
47+
* ⛔ No per-door coercion: the REST layer and the protocol hand the strings
48+
* through, and this arm is the only place one becomes a boolean.
49+
*
50+
* @see booleanComparandDoorVerdict — the pure verdict (lane 1, `@objectstack/spec`).
51+
* @see https://github.com/objectstack-ai/objectstack/issues/21333
52+
*/
53+
54+
import {
55+
booleanComparandDoorVerdict,
56+
booleanComparandFieldVerdict,
57+
booleanComparandRefusalMessage,
58+
type BooleanComparandDoorFieldMeta,
59+
type BooleanComparandRefusalSite,
60+
} from '@objectstack/spec/data';
61+
62+
/** A comparand the arm refuses: the site the contract's words are written from. */
63+
export type NonBooleanComparand = BooleanComparandRefusalSite;
64+
65+
/** The arm's answer for one comparand: kept (possibly narrowed), or refused at a site. */
66+
export type BooleanArmAnswer =
67+
| { readonly refused: false; readonly value: unknown }
68+
| { readonly refused: true; readonly site: NonBooleanComparand };
69+
70+
/**
71+
* The field meta the arm judges, or `null` when it does not judge this
72+
* declaration — the spec's field verdict decides (`deferred` and
73+
* `not-judged` are both "nothing to judge here"), never a list here.
74+
*/
75+
export function booleanArmFieldMeta(meta: BooleanComparandDoorFieldMeta | null): BooleanComparandDoorFieldMeta | null {
76+
return meta !== null && booleanComparandFieldVerdict(meta) === 'judged' ? meta : null;
77+
}
78+
79+
/**
80+
* One comparand at a judged position: the spec's verdict, routed. `aggregated`
81+
* is the walk's site fact — `having`'s columns are the aggregated row's, not a
82+
* declared field — and only ever written when true.
83+
*/
84+
export function judgeBooleanComparand(
85+
meta: BooleanComparandDoorFieldMeta,
86+
field: string,
87+
comparand: unknown,
88+
path: string,
89+
aggregated: boolean,
90+
): BooleanArmAnswer {
91+
const verdict = booleanComparandDoorVerdict(meta, comparand);
92+
if (verdict.verdict === 'narrows') return { refused: false, value: verdict.value };
93+
if (verdict.verdict !== 'door-refusal') return { refused: false, value: comparand };
94+
return {
95+
refused: true,
96+
site: {
97+
field,
98+
declaredType: meta.type,
99+
...(meta.returnType === undefined ? {} : { returnType: meta.returnType }),
100+
path,
101+
value: comparand,
102+
form: verdict.form,
103+
...(aggregated ? { aggregated: true as const } : {}),
104+
},
105+
};
106+
}
107+
108+
/** The arm's refusal words — the contract's, behind the engine's caller prefix. */
109+
export function nonBooleanComparandRefusalMessage(site: NonBooleanComparand, context: string): string {
110+
return booleanComparandRefusalMessage(site, context);
111+
}

0 commit comments

Comments
 (0)