Skip to content

Push never-match for always-UNKNOWN predicates instead of declining to a two-valued engine (#734) - #743

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-734
Aug 13, 2026
Merged

Push never-match for always-UNKNOWN predicates instead of declining to a two-valued engine (#734)#743
philcunliffe merged 3 commits into
masterfrom
fix/issue-734

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Pushes hyparquet's never-match for predicates that are UNKNOWN for every row, instead of declining them to an engine whose WHERE is two-valued and returns them all.

This implements option 2 of the three #734 lists, and does not close the issue. Option 1 (Kleene 3VL in squirreling's engine) is the correct-everywhere fix and is untouched here; NOT (... LIKE ...) and other negations over non-convertible subtrees stay SQL-wrong on nullable columns. Deliberately Refs, not Fixes.

The bug

Declined predicates fall back to squirreling's engine, where applyBinaryOp returns false on a NULL comparand and unary NOT is JS !. So NOT <anything UNKNOWN> evaluates true instead of UNKNOWN and returns rows SQL excludes:

WHERE NOT (ts = NULL)   ->  got [1,2,3,4,5]   SQL says []

The shapes, and why each qualifies

SQL rule: any comparison with a NULL operand is UNKNOWN, and NOT UNKNOWN is UNKNOWN - so no row is ever TRUE and no negation depth rescues one. That makes these safe to push as {col: {$in: []}}, the never-match already used for col NOT IN (..., NULL):

  • Every comparison operator against a NULL literal (=, ==, !=, <>, <, <=, >, >=), because the UNKNOWN comes from the operand, not the operator.
  • The same eight with the literal on the left (NULL = col). The operator mirror is irrelevant when the result is UNKNOWN either way.
  • LIKE NULL in both directions, under the same any-operand-NULL rule.
  • All of the above under any number of NOTs, including zero and two. The non-negated ones were already answered correctly by the two-valued fallback, but pushing costs nothing, gains row-group pruning, and removes an asymmetry that would otherwise need defending.
  • BETWEEN NULL AND x, which squirreling desugars to >= AND <= and so inherits the rule.
  • col IN (NULL) where every member is NULL, via the list path.

Composition was checked, not assumed: a never-match leaf zeroes an $and (UNKNOWN AND anything is never TRUE) and defers to its sibling in an $or. So NOT (ts = NULL AND ts = 300) correctly returns [1,5], not [].

Deliberately left declining

Conservatism matters here, because a wrong never-match silently returns zero rows, which is worse than the current over-return.

  • NOT (col LIKE 'pattern') and any negation over a non-convertible subtree. These are UNKNOWN only for NULL rows, not for every row, so a never-match would be a wrong answer. They stay SQL-wrong until option 1.
  • A NULL literal opposite an expression rather than a bare column (NOT (ts + 1 = NULL)): provably UNKNOWN-for-every-row, but there is no leaf column to hang $in: [] on. Declines, asserted.
  • A NULL literal opposite another literal (NOT (NULL = 1)): same, no column at all. Declines, asserted.
  • Arithmetic against a NULL literal: never TRUE, but these are value expressions rather than predicates, so the branch gates on isComparisonOp(op) || op === 'LIKE' rather than on value === null alone.

The IN-list nit, and it is stronger than the issue said

ts IN (300, NULL) pushed {ts: {$ne: null, $in: [300n, null]}}. Matching is unaffected either way (the NULL disjunct is UNKNOWN for every row, and the $ne: null guard already excludes NULL rows), so dropping NULL members is row-identical. Verified against the installed hyparquet: compareParquetValues returns undefined for a non-string against a BYTE_ARRAY bound, and one undecidable member fails canSkipStats's every. A string column filtered on label IN ('zz', NULL) read 199 bytes - more than the 109-byte unfiltered scan, since it must also read label - versus 0 bytes with the member dropped, every row group skipped on statistics. On INT64 the member happens to be harmless (null coerces to 0 and still orders).

Evidence

Tests written first, run against the unmodified converter; independently re-derived by the reconciler:

not ok 10 - negated comparisons against a NULL literal match no rows (issue #734)
  + 'WHERE NOT (ts = NULL) -> got [1,2,3,4,5], SQL says []'
  + 'WHERE NOT (ts < NULL) -> got [1,2,3,4,5], SQL says []'
  + 'WHERE NOT (NULL = ts) -> got [1,2,3,4,5], SQL says []'
  + 'WHERE NOT NOT NOT (ts = NULL) -> got [1,2,3,4,5], SQL says []'
  + 'WHERE NOT (ts BETWEEN NULL AND 500) -> got [1,2,3,4,5], SQL says []'
  + 'WHERE NOT (ts = NULL OR ts = 300) -> got [1,2,4,5], SQL says []'
  ... 17 shapes total
not ok 12 - a NULL member in an IN list does not cost row-group pruning
  199 !== 0
# pass 19  # fail 4

After: 23/23. The conservative test passed pre-fix and post-fix, which is what makes it a baseline rather than a co-failure - it proves the shapes left declining still return SQL-correct rows.

Full suite 3998 pass / 0 fail / 1 pre-existing skip; typecheck clean; llp-ref-hygiene 11/11.

LLP

LLP 0098 is Accepted and this does not contradict it: it settled that a source claims appliedWhere only for a faithful filter, and a never-match is faithful for an always-UNKNOWN predicate. Its "an unconvertible predicate such as LIKE leaves appliedWhere: false" consequence remains true. Following PR #730's precedent (which introduced this never-match and touched no LLP), no doc edit was made; one [constrained-by] ref was added on convertBinary with a gloss worth having - the decline is not a no-op, it is a handoff to a two-valued filter.

Refs #734

test and others added 2 commits August 13, 2026 11:12
`whereToParquetFilter` declined every comparison against a NULL literal, so
the predicate fell back to squirreling's WHERE. That engine is two-valued: a
comparison with a NULL operand is FALSE rather than UNKNOWN, and unary `NOT`
is JS `!`. It therefore answered `ts = NULL` right by accident and every
negation of it wrong, `NOT (ts = NULL)` returning all five rows of the
nullable fixture where SQL returns none.

Under three-valued logic `col <cmp> NULL` is UNKNOWN for every row whichever
operand holds the literal, and `NOT UNKNOWN` is UNKNOWN, so no negation depth
rescues a row. That is a never-match, and `$in: []` already spells it here:
`convertInValues` pushes it for `col NOT IN (..., NULL)`. Push it for the
comparison family too (`=`, `==`, `!=`, `<>`, `<`, `<=`, `>`, `>=`, and
`LIKE`), regardless of `negate`. `BETWEEN NULL AND x` rides along, since it
desugars to two comparisons. The never-match composes as Kleene says it
should: it zeroes an `$and` and leaves an `$or` to its sibling, so
`NOT (ts = NULL AND ts = 300)` still returns the rows where the second
conjunct is FALSE.

Deliberately still declining, because neither is provably UNKNOWN for every
row: a negated LIKE over a real pattern, and any negation over a subtree the
converter cannot convert. Those stay SQL-wrong on a nullable column until the
engine itself speaks three-valued logic (#734, option 1). Also still
declining, for want of a leaf to name: a NULL literal compared against an
expression rather than a bare column, and one compared against another
literal.

A NULL member of a non-negated `IN` list is now dropped rather than pushed.
The rows are the same either way, but `compareParquetValues` cannot order
`null` against a BYTE_ARRAY bound and one undecidable member fails the whole
`canSkipStats` fold, so a string column filtered on `IN ('zz', NULL)` read
199 bytes of row groups it could have skipped entirely.
#734)

- Add id + NULL / name || NULL assertions so the conservative-exclusions
  test reaches the op !== LIKE gate at parquet-pushdown.js:119 instead of
  short-circuiting on operand shape earlier in extractColumnAndValue.
- Add non-empty BETWEEN NOT-negation cases so a bug that pushed
  never-match for the whole desugared AND, rather than only the NULL
  conjunct, would fail instead of coincidentally matching empty fixture
  data.
- Relabel the convertBinary @ref to LLP 0098 as [constrained-by], matching
  guardNulls's identical citation of the same appliedWhere-trust fact.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 of 572a01b. Verdict: findings - 3 minor, all fixed and pushed as de438df. The core correctness claim holds: no shape the code pushes is anything other than always-UNKNOWN, across 372 differentially-checked predicates with 0 mismatches.

The reviewer built a hand-written Kleene 3VL reference evaluator over the squirreling AST and ran it against a 7-column fixture covering every physical type the writer emits, 5 rows with interleaved NULLs, 2-row row groups. Coverage: every operator x every type x both operand orders x 0/1/2/3 NOTs; LIKE NULL both directions; NULL = NULL; all BETWEEN variants; IN/NOT IN with NULL members per type; and nested composition including never-match under NOT, inside $and/$or, inside NOT (a OR b) (which De Morgans to $and), three-deep OR chains, and mixed (a OR b) AND (c OR d).

The 6 mismatches it found were all on declined predicates - the pre-existing over-return this PR explicitly leaves for option 1. Critically, the declining-sibling case does not leak a false empty: NOT (ts = NULL AND label LIKE 'a%') declines wholesale and returns all 5 rows, the old wrong answer, not [].

1. minor - the conservative-exclusions test did not pin the new gate. FIXED

The PR presents if (!isComparisonOp(op) && op !== 'LIKE') return undefined as a deliberate narrowing. The conservative test did not hold that line: both its assertions returned undefined earlier, in extractColumnAndValue, because neither has a bare identifier opposite the literal. Execution never reached the gate. Proven by deleting the line outright and running the suite: 3998 pass / 0 fail, unchanged.

It matters because that gate is the only thing between "a NULL literal opposite a bare column" and "...under any BinaryOp". Removing it is not currently a wrong answer (ts + NULL evaluates to NULL, which is falsy, so never-match coincides with SQL) - which is exactly why it was silently deletable. An untested guard whose failure mode is a silent empty result is worth pinning.

Fixed with assertions where the operator declines rather than the operand shape, and verified both ways: with the guard deleted, test 7 now fails with + { id: { '$in': [] } } - undefined.

2. minor - both BETWEEN cases expected [], so neither discriminated. FIXED

ts BETWEEN NULL AND 500 and its negation were the only BETWEEN coverage, and both expected empty. The fixture's ts maxes at 500, so ts > 500 is empty by accident of the data, not by the logic - a bug that pushed never-match for the whole desugared AND, rather than only the >= NULL conjunct, would have satisfied both.

That is the failure class this PR is most exposed to, and BETWEEN is the one shape reaching the branch through a desugaring rather than directly. Fixed with three non-empty expectations, each measured against the real fixture rather than assumed: NOT (ts BETWEEN NULL AND 50) and ts NOT BETWEEN NULL AND 50 give [1,3,5], NOT (ts BETWEEN 400 AND NULL) gives [1,3].

3. minor - the @ref relation was wrong and contradicted its sibling. FIXED

convertBinary cited LLP 0098 as [implements], but 0098's Decision settles normalizeScanColumn and wrapper flag duties - it decides nothing about which predicates the converter should accept, and certainly not "prefer pushing a never-match over declining", which is this PR's own reasoning. The engine-trusts-appliedWhere property is a constraint 0098 records, which is exactly how guardNulls 100 lines below already labels the identical fact. Changed to [constrained-by], gloss kept.

The qualifying set, derived independently

A predicate is safe to push as never-match iff it is TRUE for no row of any table. Under Kleene, NOT UNKNOWN = UNKNOWN, UNKNOWN AND x is TRUE for no x, and UNKNOWN OR x is TRUE exactly where x is - so an always-UNKNOWN leaf stays never-TRUE under any negation depth, and composes as a zero under AND and an identity under OR.

Qualifying: col <op> NULL and NULL <op> col for all eight comparison operators at any negation depth; LIKE NULL both directions; col IN (<all-NULL list>); col NOT IN (<list with a NULL>); col BETWEEN NULL AND x. Not qualifying, and correctly excluded: IS NULL, IS NOT NULL, IS DISTINCT FROM, <=> - these are decidable against NULL and must not be conflated.

The code matches exactly, and the gate is closed rather than accidental. BinaryOp is the closed union AND | OR | LIKE | '||' | ComparisonOp | ArithmeticOp, and AND/OR return earlier, so the gate is precisely "the predicate ops" with || and arithmetic excluded. IS NULL/IS NOT NULL are UnaryNode ops and cannot reach convertBinary at all - the conflation is structurally impossible. The trigger is also unforgeable: value === null can only come from the NULL keyword, there is no parameter node type in the AST, and array literals carry value: [...], so col = [1, NULL] does not take the branch.

Also checked, clean

  • $in: [] really is never-match in every position. Row level: matchesIn(v, []) folds to false. Row-group level: matchingNulls is false and canSkipStats's $in branch folds [].every(...) to true, so the group is skipped. Page level: filterPageRanges yields an empty keep. And when statistics are absent entirely the rows are read and matchFilter still returns false, so correctness never depends on pruning.
  • appliedWhere honesty end to end. The filter genuinely matches nothing, so zero rows is the right answer rather than a masked one. Wrappers exercised: withLocalOnlyVisibility passes where: undefined whenever suppression applies, so a never-match cannot interact with it; unionSources ANDs the flags and every partition pushes the same filter. Aggregates, LIMIT/OFFSET, MIN/MAX over a never-match all returned SQL-correct results.
  • The IN-list NULL drop is row-identical on every physical type: the sibling $ne: null guard sits in the same condition object, so any surviving row already required !equals(v, null), and for such a row the dropped member contributed false anyway. Negated lists are provably untouched - the early return fires first.
  • The 199-byte claim, re-measured rather than trusted: {label: {$ne:null, $in:['zz', null]}} reads 199 bytes, {label: {$ne:null, $in:['zz']}} reads 0. On INT64 both read 72, harmless as the PR says.
  • The LIKE reordering is behaviour-neutral: every non-NULL LIKE still declines identically; only col LIKE NULL / NULL LIKE col changed.
  • LLP 0098 is not contradicted. Its "an unconvertible predicate such as LIKE leaves appliedWhere: false" consequence remains true, and it nowhere enumerates the convertible set, so widening it touches nothing Accepted. Only the ref relation was off.
  • Conventions clean; npm test 3998 pass / 0 fail / 1 pre-existing skip; typecheck clean; llp-ref-hygiene 11/11.

The head has moved to de438df, so the next tick reviews that head (round 2).

The comment justifying the three non-empty BETWEEN cases misattributed
why the two cases above them are empty. Only the negated one,
`NOT (ts BETWEEN NULL AND 500)`, is a data accident: it converts to an
$or whose non-NULL conjunct is `ts > 500`, FALSE for every row only
because the fixture's `ts` maxes at 500. The non-negated case converts
to an $and holding the never-match, so it is empty at any bound and for
any data, and no choice of bound could make it discriminating.

Also name the right conjunct: the third case,
`NOT (ts BETWEEN 400 AND NULL)`, carries its NULL on the upper bound,
not the `>= NULL` lower one, which is the side it is there to cover.

Comment only; no assertion or bound changed.
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 2 of de438df. Verdict: findings - 1 minor, fixed and pushed as aab586f. All three round-1 fixes landed correctly and were confirmed by independent experiment rather than by report.

1. minor - the comment justifying the new BETWEEN cases was itself inaccurate. FIXED

That comment exists because round 1 found the original BETWEEN cases passing for the wrong reason, so its whole job is to record which case pins what. It was wrong in three ways:

  • "both cases above empty by accident of the data" - only one is. NOT (ts BETWEEN NULL AND 500) converts to {$or: [{ts:{$in:[]}}, {ts:{$ne:null,$gt:500n}}]} and is empty because ts > 500 is FALSE for every row here: genuinely a data accident. But ts BETWEEN NULL AND 500 converts to {$and: [{ts:{$in:[]}}, {ts:{$ne:null,$lte:500n}}]}, which is empty for any bound and any data, because a never-match zeroes an $and. No choice of bound could have made it discriminating.
  • "a bound of 500 makes every row's non-NULL conjunct FALSE" - true of the negated form, false of the non-negated one, whose conjunct ts <= 500 is TRUE for every non-NULL row.
  • "rather than only the >= NULL conjunct" - the third new case carries its NULL in the <= NULL conjunct. It is the only one of the three on the upper bound, and it is there precisely to cover that side.

As written it told the next reader that the non-negated case is a data-dependent coincidence, inviting either deleting it as redundant or "fixing" its bound. It is neither: it is the structural $and-zeroing case.

Fixed, and the replacement's own claims were measured rather than assumed - this being the second attempt at the comment, the fixer drove the real whereToParquetFilter and fixture rather than reasoning from SQL text:

ts BETWEEN NULL AND 500      -> {$and:[{ts:{$in:[]}},{ts:{$ne:null,$lte:500n}}]}   []
ts BETWEEN NULL AND 50       -> {$and:[{ts:{$in:[]}},{ts:{$ne:null,$lte:50n}}]}    []
ts BETWEEN NULL AND 100000   -> {$and:[{ts:{$in:[]}},{ts:{$ne:null,$lte:100000n}}]} []
NOT (ts BETWEEN NULL AND 500)-> {$or:[{ts:{$in:[]}},{ts:{$ne:null,$gt:500n}}]}     []
NOT (ts BETWEEN NULL AND 50) -> {$or:[{ts:{$in:[]}},{ts:{$ne:null,$gt:50n}}]}      [1,3,5]
NOT (ts BETWEEN 400 AND NULL)-> {$or:[{ts:{$ne:null,$lt:400n}},{ts:{$in:[]}}]}     [1,3]

Bounds were swept at 500, 50, 100000 and -100000: the $and form returns [] at every one. All three claims held, so the text went in verbatim. Verified comment-only: filtering the diff for changed non-comment lines returns zero.

Verified from round 1

  • Fix 1 (operator gate pinned) - landed and genuinely reached. The deletion experiment was re-run independently: removing the gate fails test 7 on WHERE id + NULL (actual {id:{$in:[]}} vs expected undefined). Also confirmed the second assertion reaches it rather than short-circuiting, and that the two pre-existing assertions still return undefined from earlier - so the two new ones are the only pins on the gate, and they do pin it.
  • Fix 2 (BETWEEN cases) - landed, and the expectations are correct by three-valued logic, not merely green. Derived by hand and cross-checked against a Kleene reference: NOT (ts BETWEEN NULL AND 50) is UNKNOWN OR (ts > 50), TRUE for {100,300,500} giving [1,3,5]; the NOT BETWEEN surface form parses to the identical AST; NOT (ts BETWEEN 400 AND NULL) is (ts < 400) OR UNKNOWN, TRUE for {100,300}, with row 5 correctly excluded as FALSE-OR-UNKNOWN. Crucially all three were confirmed to be pushed rather than declined, so they exercise the converter and not the fallback - without that the pin would prove nothing.
  • Fix 3 (@ref relation) - landed as [constrained-by], matching the sibling on guardNulls. Both cite the same appliedWhere-trust fact, and the gloss reads correctly under the new relation: the trust rule is a constraint the converter operates under, and the sentence draws the consequence rather than claiming the code implements the rule.

Also checked, clean

  • scanColumn, which round 1 had not covered, in two parts. Reachability: parquetDataSource is the only consumer of this converter and exposes no scanColumn (verified at runtime), and unionSources only lights the hook when every partition has one - so this never-match only ever reaches the row scan path today. Behaviour if wired: a throwaway scanColumn over the same fixture, driven through squirreling's streaming-aggregate path, yields a zero-length chunk per row group rather than a throw or a full chunk - COUNT(*) 0, MIN/MAX null, and composition survives (NOT (ts BETWEEN NULL AND 50) gives SUM(ts)=900, COUNT(*)=3). hyparquet direct: filter:{ts:{$in:[]}} returns [] whether the filter column is inside the projection or outside it.
  • Worth knowing for the sibling PRs: the live scanColumn in the tree is icebird's icebergDataSource, which uses its own whereToParquetFilter that still returns undefined for NULL comparisons - so the cache and iceberg path keeps the Declined pushdowns fall back to squirreling's two-valued WHERE and return SQL-wrong rows #734 behaviour. Upstream and untouched here, consistent with this PR's stated scope, but it bears on Delete our duplicate WHERE-pushdown converter, use icebird's (LLP 0222) #721 and Heap budget cannot interrupt a single column decode: multi-GB string materialization aborts the process instead of refusing (LLP 0097 gap) #727.
  • Core spot-check at lower intensity (24 predicates, 0 mismatches) weighted to what the delta touched: all four BETWEEN NULL-side placements including BETWEEN NULL AND NULL, both surface forms, a non-NULL control, flipped LIKE operands, never-match composed with IS NULL / IN / LIKE siblings under both AND and OR, and double negation. Every one matched SQL 3VL and every one was pushed.
  • Delta 572a01b..de438df was 16 lines with no production behaviour change beyond the relation label.
  • Conventions clean; npm test 3998 pass / 0 fail / 1 pre-existing skip; typecheck clean; llp-ref-hygiene 11/11; parquet-source 23/23.

The head has moved to aab586f, so the round budget (2) is spent at an unreviewed head: the next tick triages rather than opening a round 3.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage after the review budget (LLP 0017). Two rounds ran (4 findings, all fixed). Judged mergeable. Deferred to #744.

The backend asymmetry: the premise was empirically wrong

I asked the triage whether this PR creates an inconsistency, since round 2 recorded that icebird declines NULL comparisons. That record is factually wrong, and correcting it resolves the question.

icebird/src/sql/whereFilter.js:63 declines only on value === undefined; a NULL literal survives staticLiteral and converts. Probed against the pinned 0.8.20:

SQL icebird pushes rows SQL says
ts = NULL {ts: {$eq: null}} the NULL rows none
NOT (ts = NULL) {ts: {$ne: null}} the non-NULL rows none

and icebergDataSource claims appliedWhere: true for both, so the engine never re-filters. The cache path is actively wrong, not fallback-wrong.

So the backends already disagreed before this PR, in a worse pattern: for ts = NULL, parquet returned [] (accidentally right, via the two-valued fallback) while iceberg returned the NULL rows; for the negation, parquet returned all rows and iceberg the non-NULL rows - two different wrong answers. The choice is not consistent-versus-inconsistent, it is "one correct backend plus one wrong backend" versus "two backends wrong in different ways". Holding buys no consistency and forfeits correctness on the path this repo owns. Same posture in which #730 shipped.

Filed as #744, along with two further defects in icebird's converter that this triage found: no NULL guards on inequalities (a bare {ts: {$gt: 300}}, so NULL leaks past negative bounds) and $nor for negated OR - the exact two defects #730 fixed in this repo's own copy. That bears directly on #721, which proposes replacing this converter with icebird's.

Also checked

Silent-empty risk in deployment, not just fixtures. The only queries whose visible answer changes are the always-UNKNOWN family, and they change from "every row" to [] - what Postgres and DuckDB give. The non-negated shapes (col = NULL, the one a caller with a templating bug actually sends) already returned [] here pre-PR, so no operator sees a new empty result. There is no plan or filter cache to freeze a stale conversion - parquet-source.js:50 converts per scan. The union path is safe: unionSources reports appliedWhere: false and re-filters, and a never-match feeding it zero rows re-filters vacuously to zero.

One shape the tests do not contain, checked directly: name NOT LIKE NULL parses as unary NOT over binary LIKE, so it flows through the existing NOT path. There is no distinct NOT LIKE binary op to slip the gate.

Partial-fix framing is right. The body states in bold that it does not close the issue, names option 1 as untouched, enumerates what stays SQL-wrong, and says Refs not Fixes.

One stale phrase to fix before merging (non-behavioural): the body's LLP section still says "one [implements] ref was added on convertBinary", but the code carries [constrained-by] after the round-1 fix.

@philcunliffe
philcunliffe marked this pull request as ready for review August 13, 2026 13:32
@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 13, 2026
@philcunliffe
philcunliffe merged commit 183a11a into master Aug 13, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-734 branch August 13, 2026 14:16
platypii added a commit that referenced this pull request Aug 13, 2026
…al fold

icebird's converter still disagrees with SQL on NULLs (11 of 24 predicates
in a three-valued battery), so adopting it wholesale would undo #730 and
#743. Take the one thing it had that we lacked instead: constant-folding of
typed literals, plus the truthiness gate on boolean-position casts. Folded
bounds carry the same $ne: null guard plain bounds do.

Renumber the LLP to 0219; 0212 is taken.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant