LYT-515 | Lex unquoted negative numeric literals as signed literals in FilterQL - #58
Conversation
FilterQL rejected unquoted `-1`/`-1.5` in `=` comparisons and hard-failed on them in `IN (...)` lists, while the quoted form parsed fine. Gate the lexer's `-` handling on lastToken so a `-` in a value position (after a comparator, operator, `(`, `,`, logic, IN/BETWEEN, or start-of-input) lexes as one signed TokenInteger/TokenFloat instead of TokenMinus, so it parses to a single *expr.NumberNode and round-trips to a bare `-1` instead of `- (1)`. Binary subtraction is unaffected. Co-authored-by: Onkar Jaliminche <onkar.jaliminche@contentstack.com> Co-authored-by: Vedant Karle <vedant.karle@contentstack.com> Co-authored-by: Claude <noreply@anthropic.com>
ryan-cstk
left a comment
There was a problem hiding this comment.
Review — signed negative numeric literals in the FilterQL lexer
Reviewed at e136cba across three lenses (security / correctness / quality). The diagnosis in this PR is right and the IN (-1) breakage is real — but the current implementation introduces a CPU-exhaustion hang and a parse regression on FilterQL that works today, both from a single missing state-stack push. Everything below was measured by running both commits side by side; no worktree was modified.
CRITICAL 1 — new infinite loop, reachable from the segment API
FILTER x = -1- and FILTER x = -1/ spin forever inside one NextToken() call. No further tokens, no EOF, no panic.
34c67a3 (master): TERMINATES | FILTER x = -1-
e136cba (this PR): *** HANGS *** | FILTER x = -1-
34c67a3 (master): TERMINATES | FILTER x = -1/
e136cba (this PR): *** HANGS *** | FILTER x = -1/
A differential fuzz (8000 inputs x 2 dialects) found 12 hanging inputs on this branch and 0 of them on master; master's own pre-existing hangs reproduce on both.
This is reachable from untrusted input in lio: src/api/rw/segment.go:164 passes the raw request body into models.NewSegmentFromQL -> rel.ParseFilterQL. The lexer takes no context.Context and has no iteration bound, so a request deadline does not stop the goroutine, and lio's recover() does not catch it — a live-lock is not a panic. Each malicious POST permanently pins one core.
CRITICAL 2 — FilterQL that parses today stops parsing
Any negative literal followed by an infix AND/OR now fails. Same probe, both commits:
| FilterQL | 34c67a3 (master) |
e136cba (this PR) |
|---|---|---|
visitct = -1 |
OK -> - (1) |
OK -> -1 ✅ fix |
visitct IN (-1) |
ERR | OK ✅ fix |
visitct IN (-1, 3) |
ERR | OK ✅ fix |
visitct = -1 AND city = "x" |
OK | ERR ❌ |
visitct = -1 OR city = "x" |
OK | ERR ❌ |
visitct > -1 AND visitct < 5 |
OK | ERR ❌ |
visitct != -1 AND city = "x" |
OK | ERR ❌ |
visitct = -1.5 AND city = "x" |
OK | ERR ❌ |
visitct = -.5 |
OK | ERR ❌ |
visitct = 1 AND city = "x" |
OK | OK (unchanged) |
a - b > 1 |
OK | OK (unchanged) |
visitct BETWEEN -5 AND -1 |
OK | OK (unchanged) |
Three fixes, six regressions — and the regressing shape (= -1 AND ...) is considerably more common in real segments than the IN (-1) shape being fixed.
This breaks stored data, not just new input: rel.ParseFilterQL sets Raw = m.l.RawInput(), and lio persists that raw text verbatim. Once lio bumps past its current pin, already-saved segments containing = -1 AND ... fail to load; segmentlist.go marks them Invalid, the enrichment pipe skips them, and invalidity cascades to every parent segment that includes them. Affected audiences silently empty out.
See the inline comments for the root cause and a verified fix.
Verified clean
Security
- Binary subtraction is genuinely untouched — 91-query differential corpus (
SELECT a-1,SELECT a - b,WHERE x > y-1,WHERE (x - 1) > 0,now() -1h,SELECT count(*) - 1,SELECT a--1inline comment) produces byte-identical token streams and ASTs on both commits. The gate set cannot be reached with an identity/number/)as the previous token, so no binary->unary flip is possible. - The SQL dialect is unaffected —
SELECT * FROM users WHERE age > -1 AND name = "bob"parses identically. The clause-continuation bug is FilterQL-specific. lastTokenhygiene is sound — written only insideEmit(both branches, before the channel send).errorfbypasses it but terminates the scan, so no stale read follows. Fresh lexer per statement, so nothing leaks across statement boundaries.PeekXclamps atlen(l.input), sonumericAfterSigncannot read past end of input. No panic originated in the new code across 16,000 fuzz cases.- No logging or credential path touched.
Correctness
.String()round-trip is idempotent for every input that still parses, includingBETWEEN -1 AND -2,IN (-1, -2)and nestedAND ( ..., OR ( ... ) ).- Pre-existing and not caused by this PR (confirmed identical on master):
FILTER x = -nil-deref;SELECT (2)-1hang;UPDATE ... SETString()defect;FILTER x IN (-1) AND y = 2failing. - The PR does fix real breakage:
IN (-1),IN (-1, 3),IN (1, -3),IN ("a", -1),NOT IN (-1),eq(x, -1),INSERT ... VALUES (-1, 2)all go from parse-error to correct.
Quality
valueExpectedTokensis a package-level map built once at init — right shape for a lexer hot path.- Test layering is good: token-level in
dialect_filterql_test.go, AST-level inparse_filterql_test.go, plus a direct-AST-construction case that correctly isolates the stored-AST scenario from the lexer fix.t.Parallel()usage matches each file's existing convention. go vet ./...reports only the pre-existingtestutil/testsuite.go:215unreachable-code warning — confirmed pre-existing (unchanged since 2021, not in this diff), exactly as the PR body claims.gofmt -lflags four files, none of them in this diff.
Recommendation
Request changes. The approach is sound and the fix is small — the two lexer changes need to be reworked together (see inline), plus the numericAfterSign/scanNumericOrDuration disagreement and regression tests for the shapes that currently slip through. Two reviewers independently applied the combined fix and measured zero regressions against master across 56 FilterQL + 63 SQL + 30 malformed inputs, with ./lex ./rel ./expr ./vm green.
Reviewed with /lio-review.
The signed-literal branch in LexExpression delegated to LexNumber inline without pushing a continuation. LexNumber ends with `return nil`, which pops a frame, so it consumed the enclosing clause's frame instead of its own: anything after the literal (an infix AND/OR) lost the state needed to lex it, and a trailing operator (`= -1-`) live-locked the state machine in a push/pop cycle that consumed no input. Push the clause continuation and return the StateFn. LexListOfArgs was compensating for that missing push, so the two were coupled and neither could be fixed alone. Route a list-position sign straight to LexNumber instead of bouncing through LexExpression, matching what the positive-literal path already does. Gate numericAfterSign on what scanNumericOrDuration will actually accept. It committed to `.`-digit and `0x` forms the scanner then refuses, and a committed gate has no fallback, so `-.5` and `-0x1A` became hard parse errors where they had lexed as TokenMinus plus a value. Tests cover the shapes that slipped through: a negative before an infix AND/OR, a negative that is not first in a list, the scanner-disagreement forms, and termination on a trailing operator. The binary-minus guard now uses WHERE clauses, which traverse the changed branch; its SELECT column cases did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kPV5skWvF1tz7Hdpmashz
The BETWEEN case only exercised a sign after the AND separator, where the previous token is TokenLogicAnd. A sign on the lower bound is the only shape whose previous token is TokenBetween itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kPV5skWvF1tz7Hdpmashz
ryan-cstk
left a comment
There was a problem hiding this comment.
Approving — both criticals verified fixed
Co-reviewed: the original round was mine; this verification pass was run with Claude Code and I've reviewed the results. Every measurement below is reproducible — commands and corpora are described inline.
Nice work on this. You fixed the root cause rather than the symptom, the two coupled changes landed together in d24ab0a as they had to, and the lextrace=1 stack comparison in your reply is exactly the right way to have confirmed the diagnosis before touching anything.
I re-ran the differential rather than reading the diff, across three commits: 34c67a3 (lio's current pin), e136cba (what I reviewed), and 7b72862 (now).
Both criticals: resolved
CRITICAL 1 — the hang is gone. Across a 480-input FilterQL corpus: 0 hangs on 7b72862, versus the 12 hanging inputs the previous head produced. FILTER visitct = -1- and = -1/ now emit output byte-identical to master:
34c67a3 : PANIC(runtime error: invalid memory address or nil pointer dereference)
e136cba : *** HANG ***
7b72862 : PANIC(runtime error: invalid memory address or nil pointer dereference)
Worth stating why that's a genuine fix and not a lateral move: lio's recover() catches a panic, so the request fails cleanly. It cannot catch a live-lock — that pinned a core until the pod died. Returning to master's pre-existing nil-deref is the correct outcome for this PR. (That nil-deref on trailing-operator input is a real master bug, but it predates you and deserves its own issue.)
CRITICAL 2 — no regressions. Same 480-input corpus, 34c67a3 vs 7b72862:
ERR -> OK (fixes) : 25
OK -> ERR (REGRESSIONS) : 0
OK -> OK (representation change) : 205
panic/hang transitions : 0
All seven shapes I flagged are back, including the one your newest commit targets:
| FilterQL | 34c67a3 |
e136cba |
7b72862 |
|---|---|---|---|
visitct = -1 AND city = "x" |
OK | ERR | OK |
visitct = -1 OR city = "x" |
OK | ERR | OK |
visitct > -1 AND visitct < 5 |
OK | ERR | OK |
visitct != -1 AND city = "x" |
OK | ERR | OK |
visitct = -1.5 AND city = "x" |
OK | ERR | OK |
visitct = -.5 |
OK | ERR | OK |
visitct = -0x1A |
OK | ERR | OK |
visitct BETWEEN -5 AND -1 AND city = "x" |
OK | ERR | OK |
visitct IN (-1, 3) |
ERR | OK | OK ✅ |
eq(visitct, -1) |
ERR | OK | OK ✅ |
The 205 representation changes are semantically identical. - (1) → -1 is a different AST, so I didn't assume equivalence — I evaluated every query that parses on both commits through vm.Matches against rows spanning negative, zero and positive (visitct ∈ {-1, 0, 1, -5, -1.5, -0.5}). 424 of 424 identical verdicts. Cosmetic in the AST, no behavioural change.
Tests. ./lex ./rel ./expr ./vm green on both commits under TZ=UTC. The expr/builtins extract() failure some of us will see locally is pre-existing and timezone-dependent — it fails identically on 34c67a3, and passes on both under UTC. Not yours.
One suggestion, non-blocking
My earlier warning about test coverage is mostly addressed — the new tests genuinely pin both criticals. Mutation-verified, harness validated with a deliberate syntax error first:
| Mutation | Result |
|---|---|
revert the LexExpression push (CRITICAL 1 root cause) |
KILLED |
revert the LexListOfArgs push (CRITICAL 2 coupling) |
KILLED |
disable the numericAfterSign gate |
KILLED — TestFilterQLNegativeLiteral, TestFilterQLNegativeLiteralInfix, TestLexSignedLiteralInWhere |
drop valueExpectedTokens[l.lastToken.T] from the gate |
SURVIVED |
That last one is the gap. The lastToken guard is what prevents a binary minus being lexed as a signed literal, and removing it passes the entire suite while breaking ten real inputs:
FILTER a -1 > 0 guard: a - 1 > 0 no guard: ERR
FILTER visitct -1.5 > 0 guard: visitct - 1.5 > 0 no guard: ERR
FILTER (a) -1 > 0 guard: a - 1 > 0 no guard: ERR
FILTER a.b -1 > 0 guard: a.b - 1 > 0 no guard: ERR
I checked it isn't an equivalent mutant before raising it — 582 inputs, and those ten distinguish the two versions.
a -1 > 0 (space before the minus, none after) is ordinary arithmetic someone will write, and preventing exactly this binary→unary flip is the property the whole gate exists for. One fixture would pin it:
{`FILTER a -1 > 0`, `a - 1 > 0`}, // lastToken guard: binary minus must not become a signed literalHappy for that to land here or as a follow-up — it doesn't block.
Two notes for whoever bumps lio's pin
Neither is yours to action, but they belong on the record:
- The range carries a second PR.
34c67a3..7b72862includes82ddc14— qlbridge #56,feat(esgen)Painless queries, ~380 lines undergenerators/esgen/. That's the qlbridge half of lio #38998 so it's expected, but the bump review needs to cover both changes, not just this one. - The signed-literal change reaches the SQL dialect too, not only FilterQL. 8 of 28 SQL probes changed representation (
WHERE age > - (1)→WHERE age > -1), with no regressions andSELECT a - 1preserved. Benign, but worth knowing it isn't FilterQL-scoped.
Dismissing my changes-requested. 👍
🤖 Opened by an AI agent — not a person. This PR was created by the
lytics-developer-agentskill (Claude, Anthropic) running unattended. It shows under the assignee's GitHub account because it uses their token, but a human did not hand-write it — and comment replies on this PR from this account are also posted by the agent, not typed by a person. Review, approval, and merge stay human decisions; the agent never marks the PR ready-for-review and never merges.Merge order
This is the producer in a multi-PR change for LYT-515:
master, which is what unblocksIN (-1)end to end.What was broken
FilterQL / SegmentQL rejected unquoted negative numeric literals in value positions, while the quoted form was accepted. This blocked the customer-facing remainder of LYT-486 (P&G escalation). Two distinct failure modes, both rooted here in the lexer:
FILTER visitct IN (-1) FROM userUnrecognized input)LexListOfArgsbacks up on-→LexExpressionemits a standaloneTokenMinus, desyncing the array token streamFILTER visitct IN (-1, 3) FROM userFILTER visitct = -1 FROM userUnaryNode{Minus, Number}→ printed as- (1)-is torn off asTokenMinusbefore number-lookaheadThe number scanner already accepted a leading sign (
scanNumericOrDuration), but that path was never reached in comparison-RHS /IN-list positions.The fix
The lexer is a state machine with an explicit stack of continuations, where a
StateFnreturningnilmeans pop a frame and run it.LexNumberends that way, so the invariant is: push your continuation before delegating to it.When a
-appears in a value position — the previous emitted token is a comparator, arithmetic operator,(,,, logic op,IN/BETWEEN, or start-of-input (TokenNil) — and the next rune begins a literal the scanner will accept, lex it as a single signed numeric token instead ofTokenMinus:LexExpressionpushesl.clauseState()and returnsLexNumber. Without the push,LexNumber'snilconsumed the enclosing clause's frame, so anything after the literal (an infixAND/OR) lost the state needed to lex it, and a trailing operator (= -1-) live-locked the state machine in a push/pop cycle that consumed no input.LexListOfArgspushes itself and goes straight toLexNumber, matching what it already does for a positive literal. Routing throughLexExpressionmade the two changes mutually dependent — each only worked while the other was wrong.numericAfterSignrequires a digit immediately after the sign and rejects0x, so the gate agrees with whatscanNumericOrDurationwill actually accept. A committed gate has no fallback, so a disagreement turned-.5and-0x1Ainto terminalbad number syntaxerrors instead of leaving them lexable.Gating is strictly on
l.lastToken.T, so binary subtraction (a - b, where the previous token is an identity/number/value/)) is unchanged — important becauseLexExpressionis shared with the SQL dialect.Result
= -1,city = -1,= -1.5,IN (-1),IN (-1, 3),IN (1, -3),IN ("a", -1),NOT IN (-1),eq(x, -1)andBETWEEN -5 AND -1all parse; each negative is a single*expr.NumberNodewhose.Textcarries the sign;.String()canonicalizes to the bare signed form (never- (1)) and re-parses idempotently.Negatives followed by an infix
AND/ORkeep parsing,-.5/-0x1Akeep their previousTokenMinushandling, and= -1-/= -1/terminate. Positive, quoted, and binary-minus behavior is unchanged.The SQL dialect changes shape for the same inputs —
WHERE age > -1now yields aNumberNodewhere it previously yieldedUnaryNode{Minus, Number}. It parses either way; flagging it because consumers that type-switch on the RHS see a different tree.Testing
lex/dialect_filterql_test.go— token streams for= -1,= -1.5,INsingle/multi, string-named fields, negatives before an infixAND/OR, negatives not first in a list, sign directly afterBETWEEN, the-.5/-0x1Afallbacks, and a termination test for= -1-/= -1/that runs on a goroutine so a live-lock fails the test rather than hanging the suite.lex/lexer_test.go— binary-minus guards now includeWHERE (x - 1) > 5andWHERE x > 5 - 3, which traverse the changed branch (the originalSELECTcolumn-list cases did not), plus signed-literal coverage in a SQLWHERE.rel/parse_filterql_test.go— parse-level equivalents, with the infix rows added to the sharedFilterTestsround-trip corpus.go test -race ./...→ 19 packages, 0 failures.Known observations (not changed here)
go vet ./...reports a pre-existingunreachable codeattestutil/testsuite.go:215— present on the base commit, untouched by this PR, and not part of this repo's CI (.github/workflows/test.ymlrunsgo test -race ./...).expr.ParseExpression("-1")hangs onmasteras well as on this branch — a pre-existing bug in the expression dialect, unrelated to this change. It is why theTokenNilentry invalueExpectedTokenshas no test: the only route to it fails on both commits.IN (…) AND …fails onmasterwith all-positive literals (IN (1, 3) AND city = "sf"→Unrecognized input). A pre-existing FilterQL limitation, not sign-related; the prefix formAND ( x IN (…), … )works and is what the tests use.Files
lex/lexer.go— value-position signed-literal lexing (valueExpectedTokens,numericAfterSign(), theLexExpression/LexListOfArgs-handling).lex/lexer_test.go,lex/dialect_filterql_test.go,rel/parse_filterql_test.go— tests.