Found while closing #7047 (the ADR-0112 envelope on having's refusals), by driving the having face against FILTER_TEXT_CASES for the first time. Out of #7047's fence — that card is about the ENVELOPE on refusals that already happen; this is a refusal that does not happen at all.
Measured (2026-08-10, origin/main @ 3e8e669, by execution)
FILTER_TEXT_CASES declares five expectRejection rows. Driven against matchesHaving / applyHaving over FILTER_TEXT_ROWS:
| row |
having |
$regex is REFUSED |
refused (enveloped by #7047) |
$regex + $options refused as one mistake |
refused (enveloped by #7047) |
a dangling $options is REFUSED |
refused (enveloped by #7047) |
an empty $icontains comparand is REFUSED |
NOT refused — evaluated, matched ALL NINE rows |
a non-string $icontains comparand is REFUSED |
NOT refused — evaluated, matched none |
matchesHaving({ name: 'ACME Corp' }, { name: { $icontains: '' } }) // => true
applyHaving(FILTER_TEXT_ROWS, { name: { $icontains: '' } }) // => all 9 rows
matchesHaving({ name: 'ACME Corp' }, { name: { $icontains: 42 } }) // => false
Consequence
The empty-comparand row is the sharp one. Every string contains the empty substring, so the predicate constrains NOTHING — a filter that compiles to nothing does not narrow a query, it widens it (#3948). The author wrote a constraint and got the unfiltered aggregate back, with no error.
The non-string row is the quieter half: 42 is answered false — "no rows" — for a comparand the declared type (StringOperatorSchema: $icontains: z.string()) does not permit, which is the silent-wrong-answer shape #4706 retired $regex over.
What the other faces do
The driver faces get this from a comparand gate having has never had:
driver-memory — filter-refusal.ts icontainsComparandError(), both rejections in one constructor, INVALID_FILTER / 400.
driver-sql — the twin icontainsComparandError, word for word.
having is the sixth JS evaluation face of the same vocabulary (#6520 gave it the shared ASCII fold) and the only one with no comparand gate.
Why this is not #7047
Closing this means REFUSING filters this face evaluates today — a behaviour change beyond an envelope card, with its own changeset and its own client-visible effect. #7047 kept the same fence #6993 kept before it.
Current state of the coverage
packages/objectql/src/having-filter-text-conformance.test.ts (added by #7047) enrolls the 13 rows cases and the 3 retired-operator rejection rows, and pins these two exclusions as measurements — asserting that the face does not refuse them, and that the empty comparand matches all nine rows. So the gap is red-on-change rather than a silent hole: adding the gate turns those pins red and forces the two rows into the enrolment.
Update that file's UNENROLLED_REJECTION_CASES block when closing this.
Found while closing #7047 (the ADR-0112 envelope on
having's refusals), by driving thehavingface againstFILTER_TEXT_CASESfor the first time. Out of #7047's fence — that card is about the ENVELOPE on refusals that already happen; this is a refusal that does not happen at all.Measured (2026-08-10,
origin/main@3e8e669, by execution)FILTER_TEXT_CASESdeclares fiveexpectRejectionrows. Driven againstmatchesHaving/applyHavingoverFILTER_TEXT_ROWS:having$regexis REFUSED$regex+$optionsrefused as one mistake$optionsis REFUSED$icontainscomparand is REFUSED$icontainscomparand is REFUSEDConsequence
The empty-comparand row is the sharp one. Every string contains the empty substring, so the predicate constrains NOTHING — a filter that compiles to nothing does not narrow a query, it widens it (#3948). The author wrote a constraint and got the unfiltered aggregate back, with no error.
The non-string row is the quieter half:
42is answeredfalse— "no rows" — for a comparand the declared type (StringOperatorSchema:$icontains: z.string()) does not permit, which is the silent-wrong-answer shape #4706 retired$regexover.What the other faces do
The driver faces get this from a comparand gate
havinghas never had:driver-memory—filter-refusal.tsicontainsComparandError(), both rejections in one constructor,INVALID_FILTER/ 400.driver-sql— the twinicontainsComparandError, word for word.havingis the sixth JS evaluation face of the same vocabulary (#6520 gave it the shared ASCII fold) and the only one with no comparand gate.Why this is not #7047
Closing this means REFUSING filters this face evaluates today — a behaviour change beyond an envelope card, with its own changeset and its own client-visible effect. #7047 kept the same fence #6993 kept before it.
Current state of the coverage
packages/objectql/src/having-filter-text-conformance.test.ts(added by #7047) enrolls the 13 rows cases and the 3 retired-operator rejection rows, and pins these two exclusions as measurements — asserting that the face does not refuse them, and that the empty comparand matches all nine rows. So the gap is red-on-change rather than a silent hole: adding the gate turns those pins red and forces the two rows into the enrolment.Update that file's
UNENROLLED_REJECTION_CASESblock when closing this.