diff --git a/.changeset/boolean-aggregand-ruled-answers.md b/.changeset/boolean-aggregand-ruled-answers.md new file mode 100644 index 0000000000..1996e27e6d --- /dev/null +++ b/.changeset/boolean-aggregand-ruled-answers.md @@ -0,0 +1,5 @@ +--- +'@objectstack/driver-sql': patch +--- + +Boolean aggregands now answer the ruled #11249 contract on every SQL dialect. On Postgres, `sum`/`avg`/`min`/`max` over a declared `boolean` field are lowered with a cast (`avg(cast("flag" as int))`) instead of reaching the server as `avg("flag")` — which PostgreSQL refuses with SQLSTATE `42883`, so those aggregations previously failed with `DATABASE_ERROR`/500. On every dialect, `min`/`max` results over a declared boolean are now presented as JSON booleans (`false`/`true`) at the driver boundary — previously MySQL (`tinyint(1)` storage) answered `0`/`1`. `sum`/`avg` answer arithmetic (`3` / `0.5` over a 3-true/3-false column); `count`/`count_distinct` are unchanged, and `min`/`max` over an empty window still answer `null`. diff --git a/packages/drivers/driver-sql/src/sql-driver-11455-aggregate-fault-envelope.test.ts b/packages/drivers/driver-sql/src/sql-driver-11455-aggregate-fault-envelope.test.ts index ba38baacf9..2710c104ba 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11455-aggregate-fault-envelope.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11455-aggregate-fault-envelope.test.ts @@ -6,11 +6,11 @@ * ## The measurement this suite is built from * * On live PostgreSQL 16.13, `driver-sql` maps a `boolean` field to a real PG - * `boolean` column and `SQL_AGGREGATE_FUNCTIONS` lowers the arithmetic - * aggregates to a bare function name with no cast, so the statement reaches the - * server as `avg("flag")`. Postgres has no `avg`/`sum`/`min`/`max` over - * `boolean`, and on `origin/main` the failure escaped `SqlDriver.aggregate()` - * un-enveloped: + * `boolean` column, and — until #11635 delivered the cast — the lowering + * emitted a bare function name, so the statement reached the server as + * `avg("flag")`. Postgres has no `avg`/`sum`/`min`/`max` over `boolean`, and + * on the `origin/main` of #11455's day the failure escaped + * `SqlDriver.aggregate()` un-enveloped: * * ``` * sum(flag) => THREW code=42883 status=undefined @@ -23,23 +23,27 @@ * * ## ⛔ THE FENCE — this suite is the ENVELOPE half and nothing else * - * Whether the platform should ANSWER A NUMBER here (by casting boolean to int - * in the lowering) or REFUSE is a contract question, and it belongs to #11152 - * (spec seat) and — for `min`/`max` — #11249. Nothing in this file decides it. - * What is asserted is the half that holds either way: **when it fails, the - * failure carries a catalogued code and a status.** + * When this suite landed, whether the platform should ANSWER A NUMBER over a + * boolean aggregand (by casting boolean to int in the lowering) or REFUSE was + * a contract question belonging to #11152 (spec seat) and — for `min`/`max` — + * #11249, so this file pinned only the half that held either way: **when it + * fails, the failure carries a catalogued code and a status.** The envelope + * must still never be `INVALID_QUERY` / 400 — that code would say *"asking + * for a mean over a flag column is your mistake"*, a verdict about the + * request the exit's signal cannot support. * - * Read the two pins below that exist to keep that fence honest: - * - * - the envelope must NOT be `INVALID_QUERY` / 400. That code is the platform - * saying *"asking for a mean over a flag column is your mistake"* — which is - * precisely one of the two answers #11152 has yet to choose between, and - * declaring it here would decide the card from the driver. - * - the three dialects' ARITHMETIC answers are deliberately NOT pinned. Measured - * 2026-08-24 while taking this card's readings: SQLite answers (`sum` 3, - * `avg` 0.5, `min` false, `max` true), MySQL answers too (`tinyint(1)`: - * `sum` 3, `avg` 0.5000, `min` 0, `max` 1), Postgres refuses. Freezing that - * divergence in a test is the same pre-emption by another route. + * #11249 has since RULED (maintainer 2026-08-23): the face answers — `sum` / + * `avg` arithmetically, `min` / `max` with `false` / `true` in JSON — and + * [#11635] delivered the Postgres cast, so a boolean aggregand no longer + * produces the `42883` this suite's original PG-only block was built from. + * That block carried its own retirement clause ("if #11152 / #11249 rule that + * the lowering should CAST, these cases stop failing and this block is + * RETIRED by that card") and is retired by #11635 accordingly; the ruled + * ANSWERS are pinned across all three dialects by + * `sql-driver-11635-boolean-aggregand-answers.test.ts`. What this file keeps + * is unchanged by the ruling: an error the classifier does not claim — a + * missing table, a wording no dialect parser reads — still leaves as the + * terminal envelope, on every cell. * * ## Why `DATABASE_ERROR` / 500, from the code rather than from taste * @@ -94,7 +98,6 @@ import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dial const TABLE = 'agg_fault_task'; const MISSING_TABLE = 'agg_fault_never_created'; -const BOOL_TABLE = 'agg_fault_bool'; /** * The caller's value, distinctive on purpose, asserted absent from every @@ -304,106 +307,19 @@ for (const cell of DIALECT_CELLS) { } // ───────────────────────────────────────────────────────────────── -// POSTGRES-ONLY — the card's own route +// RETIRED [#11635] — the POSTGRES-ONLY boolean row // ───────────────────────────────────────────────────────────────── - -/** - * The boolean aggregate is a fact about POSTGRES' function catalog: it is the - * only one of the three dialects that stores a `boolean` field as a real - * `boolean` column with no arithmetic aggregates defined over it. SQLite and - * MySQL both answer arithmetically (see the head note), so neither cell can - * show this row at all. - * - * ⚠️ If #11152 / #11249 rule that the lowering should CAST, these cases stop - * failing and this block is RETIRED by that card — it is not weakened here in - * anticipation of a ruling that has not happened. - */ -const PG = DIALECT_CELLS.find((c) => c.id === 'pg')!; - -declareDialectCell(PG, 'aggregate backend-fault envelope — the boolean row', (cell) => { -describe('[#11455] postgres — an arithmetic aggregate over a boolean column', () => { - let driver: SqlDriver; - - beforeAll(async () => { - driver = new SqlDriver(cell.config()); - await driver.execute(`drop table if exists ${BOOL_TABLE}`).catch(() => {}); - await driver.initObjects([ - { name: BOOL_TABLE, fields: { flag: { type: 'boolean' }, note: { type: 'string' } } }, - ]); - for (const [id, flag] of [['b1', true], ['b2', false], ['b3', true]] as const) { - await driver.create(BOOL_TABLE, { id, flag, note: SECRET_LITERAL }, { bypassTenantAudit: true }); - } - }); - - afterAll(async () => { - await driver.execute(`drop table if exists ${BOOL_TABLE}`).catch(() => {}); - await driver.disconnect(); - }); - - // ⭐ THE CARD. On `origin/main` each of these four left the driver as - // Postgres' own error object: `code: '42883'`, `status: undefined`, and - // `select avg("flag") as "n" from "…" - function avg(boolean) does not exist` - // as the message. - it.each(['sum', 'avg', 'min', 'max'] as const)( - '%s over a boolean column carries a catalogued code and a status', - async (func) => { - const err = await caught(() => - driver.aggregate(BOOL_TABLE, { aggregations: [{ function: func, field: 'flag', alias: 'n' }] }), - ); - expect(err.code, `${func}: code`).toBe('DATABASE_ERROR'); - expect(err.status, `${func}: status`).toBe(500); - expect(typeof err.status, `${func}: status is declared, not undefined`).toBe('number'); - expectNoStatementShape(String(err.message), BOOL_TABLE, func); - // The SQLSTATE the card measured, kept where an operator and every - // cause-following predicate can still read it. - expect((err as { cause?: any }).cause?.code, `${func}: the pg SQLSTATE`).toBe('42883'); - }, - ); - - // ⛔ THE FENCE, asserted rather than promised. `INVALID_QUERY` / 400 is the - // platform saying "a mean over a flag column is YOUR mistake" — one of the - // two answers #11152 (and #11249 for min/max) has yet to choose between. The - // driver declares no verdict about the request here. - it('claims NOTHING about whether a boolean aggregate should answer (#11152 / #11249)', async () => { - const err = await caught(() => - driver.aggregate(BOOL_TABLE, { aggregations: [{ function: 'avg', field: 'flag', alias: 'n' }] }), - ); - expect(err.code, 'not a request verdict').not.toBe('INVALID_QUERY'); - expect(err.status, 'not a request verdict').not.toBe(400); - expect(String(err.message), 'no verdict prose about the function or the field') - .not.toMatch(/boolean|avg|flag/i); - }); - - // POSITIVE CONTROL — the table, the column and the rows are real, and the - // aggregate door still answers over the very same boolean column for the two - // functions Postgres DOES define. Without this the block above could go green - // against a table that never existed. - it('CONTROL count / count_distinct over the same boolean column still answer', async () => { - const counted = await driver.aggregate(BOOL_TABLE, { - aggregations: [{ function: 'count', field: 'flag', alias: 'n' }], - }); - expect(Number(counted[0].n), 'count over the boolean column').toBe(3); - const distinct = await driver.aggregate(BOOL_TABLE, { - aggregations: [{ function: 'count_distinct', field: 'flag', alias: 'n' }], - }); - expect(Number(distinct[0].n), 'count_distinct over the boolean column').toBe(2); - }); - - // The disclosure half, on the route that carries a caller-authored value: the - // dialect text goes to the log, never to the caller. - it('the dialect text reaches the SERVER LOG and not the caller', async () => { - const { err, logged } = await withLog(driver, () => - driver.aggregate(BOOL_TABLE, { aggregations: [{ function: 'avg', field: 'flag', alias: 'n' }] }), - ); - expect(err.code).toBe('DATABASE_ERROR'); - expectNoStatementShape(String(err.message), BOOL_TABLE, 'avg'); - const line = logged.find((l) => l.includes('DATABASE_ERROR')); - expect(line, 'the operator must still be able to read the backend diagnostic').toBeDefined(); - // POSITIVE CONTROL — the words really were in the dialect's text, so their - // absence from the caller's message is a withholding, not a statement about - // a string that never held them. - expect(String(line)).toContain('function avg(boolean) does not exist'); - expect(String(line)).toContain('42883'); - }); -}); -}); +// +// A PG-only block here pinned the four `42883` failures this suite's head +// note was measured from (`sum`/`avg`/`min`/`max` over a real `boolean` +// column, each leaving as DATABASE_ERROR/500 with the SQLSTATE in `cause`). +// It carried its own retirement clause — "if #11152 / #11249 rule that the +// lowering should CAST, these cases stop failing and this block is RETIRED by +// that card" — and #11249's ruling (maintainer 2026-08-23) fired it: the +// lowering now casts on Postgres, the statements answer, and there is no +// `42883` left on this route to envelope. The ruled answers — and this +// block's `count`/`count_distinct` controls, which the acceptance criterion +// keeps unchanged — are pinned on ALL THREE dialects by +// `sql-driver-11635-boolean-aggregand-answers.test.ts`. The envelope +// invariant itself did not move: the all-dialect sweep above measures it on a +// route (a table that was never provisioned) no ruling can reach. diff --git a/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts b/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts new file mode 100644 index 0000000000..7a31f5ffec --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-11635-boolean-aggregand-answers.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11635] Boolean aggregands answer the RULED values on every dialect this + * driver speaks — the #11249 contract, executed at the driver boundary. + * + * ## The ruling this suite pins + * + * #11249 (maintainer 2026-08-23, recorded in its comment 5386670755, verbatim + * and untranslated: 「10950 不考虑存量,其他接受你的建议」) adopted: + * + * - **`min` / `max` over a boolean aggregand answer `false` / `true` in + * JSON** — order statistics return a member of the input domain, and SQL + * drivers convert at the driver boundary. + * - **`sum` / `avg` answer arithmetic** (`3` / `0.5` on the 3-true/3-false + * fixture) — the settled #11065 family shape. + * + * ## The two measured gaps this suite exists to keep closed + * + * Measured 2026-08-24 through `SqlDriver.aggregate()` on `origin/main` @ + * `2a6122bd9d`, before the fix: + * + * - **Postgres 16.13 refused all four** — the lowering emitted a bare + * `avg("flag")` over a real `boolean` column, PG has no arithmetic/order + * aggregates over `boolean`, and every call left as the #11455 envelope + * wrapping SQLSTATE `42883`. A face that refuses cannot satisfy the ruled + * contract; the lowering now casts (`avg(cast("flag" as int))`) on PG only. + * - **MySQL 8.0.46 answered `min` = `0`, `max` = `1`** over `tinyint(1)` — + * the backend computes, but the boolean read-presentation was gated to + * SQLite (mirroring `formatOutput`'s row reads), so the driver boundary + * leaked the storage form. `min`/`max` results over a declared boolean are + * now presented on every dialect. + * + * ## Assertion conventions, and why they differ per function + * + * `min` / `max` are asserted STRICTLY (`toBe(false)` / `toBe(true)`): the JSON + * boolean IS the ruled contract, and `0`/`1` — the exact value this suite went + * red on — satisfies a `Number()` reading. `sum` / `avg` / `count` / + * `count_distinct` are asserted through `Number(...)`: node-pg and mysql2 both + * hand EXACT-numeric results (`sum` → bigint/DECIMAL, `avg` → numeric) back as + * strings — `"3"`, `"0.5000"` — for boolean and integer aggregands alike, so a + * literal comparison would pin the dialect client's wire type, not this card's + * values (the same reading the #11455 suite's control records). + * + * ## The fixture + * + * `AGGREGATION_ROWS` — the shared aggregate-vocabulary fixture — plus a `flag` + * boolean column, 3 true / 3 false, declared `type: 'boolean'` (the #11635 + * acceptance shape). The per-group split is deliberately asymmetric: `west` + * holds `[T,F,F,F]` and `east` `[T,T]`, so `east`'s grouped `min` is TRUE — + * a presentation that computed over the whole table, or answered a sticky + * per-column constant, goes red on that cell rather than passing by symmetry. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { AGGREGATION_ROWS } from '@objectstack/spec/data'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const TABLE = 'bool_aggregand_answers'; + +/** + * The `flag` column, keyed by fixture row id: 3 true / 3 false, with `east` + * (rows 5–6) all-true — see the head note for why the asymmetry is the point. + */ +const FLAG_BY_ID: Record = { + '1': true, + '2': false, + '3': false, + '4': false, + '5': true, + '6': true, +}; + +const aggOn = (func: string, field = 'flag'): DriverQuery => + ({ aggregations: [{ function: func, field, alias: 'n' }] }) as DriverQuery; + +function declareAnswers(cell: DialectCell): void { +describe(`[#11635] driver-sql — boolean aggregands answer the ruled values (${cell.label})`, () => { + let driver: SqlDriver; + + beforeAll(async () => { + driver = new SqlDriver(cell.config()); + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.initObjects([ + { + name: TABLE, + fields: { + region: { type: 'string' }, + stage: { type: 'string' }, + score: { type: 'number' }, + flag: { type: 'boolean' }, + }, + }, + ]); + for (const row of AGGREGATION_ROWS) { + await driver.create( + TABLE, + { ...row, flag: FLAG_BY_ID[row.id] }, + { bypassTenantAudit: true }, + ); + } + }); + + afterAll(async () => { + await driver.execute(`drop table if exists ${TABLE}`).catch(() => {}); + await driver.disconnect(); + }); + + // The fixture read back rather than trusted: 3 true / 3 false, and a seed + // that dropped a row or folded the flags would turn every value below into + // a test of the wrong table. + it('the fixture is six rows, 3 true / 3 false', async () => { + const rows = (await driver.find(TABLE, {})) as Array<{ flag: unknown }>; + expect(rows).toHaveLength(6); + const truthy = rows.filter((r) => Boolean(r.flag)).length; + expect(truthy, 'true count').toBe(3); + }); + + // ─── The ruled arithmetic half ─────────────────────────────────────────── + + it('sum(flag) answers 3', async () => { + const rows = await driver.aggregate(TABLE, aggOn('sum')); + expect(Number(rows[0].n)).toBe(3); + }); + + it('avg(flag) answers 0.5', async () => { + const rows = await driver.aggregate(TABLE, aggOn('avg')); + expect(Number(rows[0].n)).toBe(0.5); + }); + + // ─── The ruled order-statistic half — JSON booleans, STRICTLY ──────────── + + it('min(flag) answers false — the JSON boolean, not 0', async () => { + const rows = await driver.aggregate(TABLE, aggOn('min')); + expect(rows[0].n).toBe(false); + }); + + it('max(flag) answers true — the JSON boolean, not 1', async () => { + const rows = await driver.aggregate(TABLE, aggOn('max')); + expect(rows[0].n).toBe(true); + }); + + it('grouped min/max answer per-group members: west [T,F,F,F], east [T,T]', async () => { + const rows = await driver.aggregate(TABLE, { + groupBy: ['region'], + aggregations: [ + { function: 'min', field: 'flag', alias: 'lo' }, + { function: 'max', field: 'flag', alias: 'hi' }, + ], + } as DriverQuery); + const byRegion = Object.fromEntries( + (rows as Array<{ region: string; lo: unknown; hi: unknown }>).map((r) => [ + String(r.region), + { lo: r.lo, hi: r.hi }, + ]), + ); + // `east` is the load-bearing cell: all-true, so its `min` is `true` — a + // whole-table computation or a sticky `false` fails here and only here. + expect(byRegion.east, 'east').toEqual({ lo: true, hi: true }); + expect(byRegion.west, 'west').toEqual({ lo: false, hi: true }); + }); + + // ─── The empty window: null stays null, never a manufactured false ─────── + + // `min`/`max` over no rows is undefined — the same judgement + // `emptyGroupValueFor` (@objectstack/spec/data) records — and the boolean + // presentation must pass the backend's NULL through, not fold it to `false`. + it('min(flag) over an empty window answers null, not false', async () => { + const rows = await driver.aggregate(TABLE, { + where: { region: 'north' }, + aggregations: [{ function: 'min', field: 'flag', alias: 'n' }], + } as DriverQuery); + expect(rows[0].n).toBeNull(); + }); + + // ─── Controls — what the cast and the presentation must NOT move ───────── + + it('CONTROL count(flag) / count_distinct(flag) are unchanged', async () => { + const counted = await driver.aggregate(TABLE, aggOn('count')); + expect(Number(counted[0].n), 'count over the boolean column').toBe(6); + const distinct = await driver.aggregate(TABLE, aggOn('count_distinct')); + expect(Number(distinct[0].n), 'count_distinct over the boolean column').toBe(2); + }); + + it('CONTROL sum/avg over the numeric column are untouched by the boolean path', async () => { + const summed = await driver.aggregate(TABLE, aggOn('sum', 'score')); + expect(Number(summed[0].n), 'sum(score)').toBe(210); + const averaged = await driver.aggregate(TABLE, aggOn('avg', 'score')); + expect(Number(averaged[0].n), 'avg(score)').toBe(35); + }); +}); +} + +// A matrix that silently finds zero cells reports OK — assert the axis is real +// before iterating it (the #11455 suite's own guard, kept in force here). +describe('[#11635] the dialect axis this suite runs', () => { + it('runs every dialect this driver speaks', () => { + expect(DIALECT_CELLS.map((c) => c.id)).toEqual(['sqlite', 'pg', 'mysql']); + }); +}); + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, 'boolean aggregand ruled answers', declareAnswers); +} diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 383ea31dd6..b4666850c7 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -7750,7 +7750,31 @@ export class SqlDriver implements IDataDriver { // their own mistake instead of a dialect's syntax error wrapped in a // 500 (see {@link refuseDistinctAggregateWithoutField}). if (lowering.distinct && fieldExpr === '*') refuseDistinctAggregateWithoutField(funcName); - const rawFunc = lowering.distinct ? `${lowering.sql}(distinct ??)` : `${lowering.sql}(??)`; + // [#11635] A boolean aggregand is CAST for the arithmetic/order + // aggregates on Postgres — the one dialect that stores `Field.boolean` + // as a real `boolean` column and defines no `sum`/`avg`/`min`/`max` + // over it (SQLSTATE `42883`, measured on PG 16.13; SQLite stores 0/1 + // INTEGER and MySQL `tinyint(1)`, so both compute natively). #11249 + // ruled the answers (maintainer 2026-08-23): `sum`/`avg` answer + // arithmetic over 1/0, and `min`/`max` answer `false`/`true` — a + // member of the input domain — so this face must ANSWER; refusing + // cannot satisfy the ruled contract. `cast(?? as int)` keeps the + // column in a knex identifier binding exactly as the uncast form does. + // `count`/`count_distinct` are deliberately NOT cast (both lower to + // `count`, defined over boolean everywhere — their answers were + // correct before this and must not move). The 1/0 the cast computes + // for `min`/`max` is presented back as a JSON boolean below, where the + // result column is tracked. + const castBooleanAggregand = + this.isPostgres && + lowering.sql !== 'count' && + fieldExpr !== '*' && + table !== null && + (this.booleanFields[table]?.includes(fieldExpr) ?? false); + const argExpr = castBooleanAggregand ? 'cast(?? as int)' : '??'; + const rawFunc = lowering.distinct + ? `${lowering.sql}(distinct ${argExpr})` + : `${lowering.sql}(${argExpr})`; if (agg.alias) { if (fieldExpr === '*') { builder.select(this.knex.raw(`${lowering.sql}(*) as ??`, [agg.alias])); @@ -7765,7 +7789,21 @@ export class SqlDriver implements IDataDriver { // (`max("closed_at")` on SQLite, `max` on Postgres) and is defensive // only, so it is deliberately not tracked. if ((funcName === 'min' || funcName === 'max') && agg.field) { - const kind = this.readPresentationKind(table, agg.field); + // [#11249/#11635] A boolean aggregand presents on EVERY dialect, + // not only under `readPresentationKind`'s SQLite gate. That gate + // mirrors `formatOutput`'s ROW reads, where the native dialects + // hand storage back as-is — but on this door the backend answers + // `min`/`max` as 1/0 on MySQL (`tinyint(1)`) and on Postgres (the + // `cast(?? as int)` above), and the ruled contract is `false` / + // `true` in JSON: order statistics return a member of the input + // domain, and SQL drivers convert at the driver boundary. + // `presentReadValue('boolean', …)` leaves `null` (no rows / all + // NULL) untouched and is idempotent on a value already boolean. + const kind = + this.readPresentationKind(table, agg.field) ?? + (table !== null && this.booleanFields[table]?.includes(agg.field) + ? ('boolean' as const) + : null); if (kind) presentedOutput.set(agg.alias, kind); } } else { @@ -7794,13 +7832,13 @@ export class SqlDriver implements IDataDriver { // forward, which is the #1116/#1117 gap {@link mapAggregateFunc} closed for // the FUNCTION NAME while leaving the STATEMENT half open. // - // ⛔ Nothing here decides whether a boolean aggregate should ANSWER (by - // casting boolean to int in {@link SQL_AGGREGATE_FUNCTIONS}) or REFUSE — - // that contract question is #11152's, and #11249's for `min`/`max`. The - // three dialects genuinely disagree today (SQLite and MySQL's `tinyint(1)` - // both answer arithmetically, Postgres refuses), which is exactly why the - // envelope is the half that can land first: *when* it fails, the failure - // carries a catalogued code and a status, either way that card is ruled. + // When #11455 landed, whether a boolean aggregate should ANSWER or REFUSE + // was deliberately left to #11152 / #11249 — the envelope was the half + // that could land first. #11249 has since RULED (maintainer 2026-08-23): + // the face answers, and [#11635] delivered the Postgres cast in the + // statement builder above, so a boolean aggregand no longer reaches this + // exit at all. The envelope itself is unchanged — it never recognised + // `42883`, so removing one of its inputs removes nothing from it. // // ⛔ And no boolean-specific recognizer: the envelope comes from the EXIT, // not from matching `42883` or the words `does not exist`. That is the