CQELS 2.0.0-alpha.20

All releases · How to verify this release

# CQELS 2.0.0-alpha.20

A **correctness-and-truthfulness release.** Four engine fixes close public downstream reports
(CQELS4J#54, CQELS4J#55, CQELS4J#56, CQELS4J#57) — static patterns that behaved as `OPTIONAL`, prefixed names that
silently resolved to `null` inside expressions, syntax errors that registered anyway, and reasoning
that never fired for atomically pushed observations — and `sameTerm()` lands as a real
implementation rather than a silent `null` (CQELS4J#65). The multi-stream interval join takes a
capability step: per-subscription state, bounded buffers, multi-triple side patterns and a natural
join over *every* shared variable. And the largest documentation-accuracy campaign this project has
run corrected 89 wrong row-cardinality claims out of 158 enumerated, replaced 69 line-number
citations with method names, and — more durably than the corrections themselves — added gates so
the structural classes cannot rot again. Several merged changes alter results for existing queries;
see **Upgrade notes**.

### Added
- **Multi-triple side patterns and a natural-join key on the multi-stream interval join**, the
  multi-stream epic's stage S4. A `STREAM` block may now declare several triple patterns; `SideBgpMatcher`
  conjoins statements co-resident in that side's own declared window as an ordinary SPARQL BGP and
  emits each newly completed solution once, as a `BgpSolutionElement` timestamped at its newest
  constituent, with co-residency tested on the solution's `[oldest, newest]` span so a conjunction
  cannot reach further than its constituents would singly. `deriveTemporalJoinSpecFromMultiSource`
  now keys on **every** shared variable (SPARQL natural join) instead of rejecting a query with two
  or more; a pair disagreeing on a shared variable emits no row rather than throwing mid-stream.
  **Scope, stated as the PR states it:** the route keeps its *interval-join* semantics — this only
  widens what it accepts. Zero shared variables is still rejected (a cross product is a separate
  feature), three or more sources is still rejected, and on the explicit `operators.temporalJoin`
  JSON-hint path a multi-triple side now **requires** `leftPatternWindow`/`rightPatternWindow` —
  that path has no declared stream window to fall back on, unlike the auto-derived route, which
  takes each side's pattern window from its own `FROM STREAM` declaration. A multi-triple
  side on a zero-extent `[NOW]` window warns at compile time rather than being rejected — that shape
  is the documented same-timestamp decision-cycle contract, now pinned by a hot `[NOW] ⋈ [NOW]`
  regression across all three interleavings. Side-BGP fan-out is bounded by the existing
  `cqels.join.crossProductCap` in two directions — at most `cap` completed solutions and at most
  `cap × patterns` surviving partial bindings — so an unsatisfiable tail that completes nothing
  still trips the guard.
- **Watermark-driven CEP timeout evaluation for quiet streams**. A trailing
  negation (`SYN notFollowedBy ACK within 5s`) previously stayed silent forever on a stream that
  went quiet, because `NFAPatternProcessor.processWithTimeouts` only expired partials when a *later
  data event* advanced event time — continuous missing-event detectors had to inject fake data to
  surface their own deadlines. New public API: `NFAPatternProcessor.advanceWatermark(long)` expires
  every partial whose deadline precedes the watermark and emits the corresponding timeout output —
  exactly what a data event at that timestamp would have done, minus every data-event side effect —
  and `CQELSEngine.advanceCepWatermark(cepQueryId, eventTime)` feeds the compiled directional path.
  No wall clock and no scheduler anywhere (maintainer decision, option C). Data events and
  watermarks share **one serialized channel per subscription** so their arrival order is the
  caller's order; a two-source merge would let a watermark overtake a data event pushed before it
  and expire a partial that event had already killed. The legacy raw-stream CEP arm has no watermark
  input and rejects `advanceCepWatermark` loudly. Non-advancing watermarks are coalesced per
  subscription (documented idempotence), and the frontier advances only *after* a successful
  delivery, so a watermark whose delivery failed stays retryable.
- **`sameTerm(A, B)`** joins the expression function dispatch, matching CQELS4J#65,
  delegating to RDF4J `Value#equals` — verified against the pinned 4.3.8 jar to be an exact
  structural match for SPARQL §17.4.1.1 term identity. Reachable from `FILTER`, `BIND`, both
  `HAVING` paths, `OPTIONAL` inner filters, and the in-batch and `Map`-row routes; unknown arity or
  an unbound argument yields a type error, consistent with the neighbouring functions. The
  `NaN`-vs-`=` divergence holds by construction and is pinned. **Limitation pinned rather than
  hidden:** row-bound *literal* arguments are lossy through the row representation, so two
  numerics differing only in lexical form or hierarchy datatype can false-positive and tagged/typed
  literals can narrow to bare strings; IRI and blank-node arguments are exact.
- **A `cqels-UCs` module carrying tutorials 01–04**, the first slice of a larger effort, re-derived
  against current engine behaviour rather than recovered. Generator timestamps are seed-reproducible
  so window membership cannot depend on when the JVM runs. Tutorials 05–16 are not in this slice.
- **Self-hosted benchmark runner lanes — nightly screening and weekly deep bench**,
  orchestrated through `benchctl` with per-machine concurrency groups, heartbeat/liveness
  signalling, environment fingerprinting, and results pushed to orphan `bench-results/*` branches.
  **Changes nothing about the published artifacts and needs no upgrade action:** both cron triggers
  ship COMMENTED OUT, so merging activates nothing until a runner is registered per
  `scripts/bench/RUNNERS.md` and the `BENCH_JDK_VENDOR` / `BENCH_SNB_ROOT` repo variables are set;
  the bench lanes require a runner label the PR-gating jobs never carry, so they cannot block PR CI
  either way; and the supply-chain boundary holds — no publish or release-path job runs self-hosted,
  `publish.yml` is untouched, and the workflow's token permissions are minimal (`contents:write`
  reaches only the orphan branches).

### Fixed

**Public downstream reports (CQELS4J)**
- **A static BGP outside `STREAM{}` behaved as `OPTIONAL` on the composed route**. Closes the
  live residual of CQELS4J#54 and the wrong-pattern finding from CQELS4J#56.
  `enrichBoundRowWithStatic` returned at most one map and filled each needed variable
  *independently* from the forward-BFS view, so a partially-satisfiable static BGP still produced a
  filled row and a value could come from a triple that never satisfied the whole pattern. It now
  runs `staticBgpMatchesAll` over the enriched view and emits one row per satisfying assignment —
  0..N per input row, dropping the row when the conjunction has no solution — the same contract
  `mapToResultRows` already had on the lookup route. Three defects found while doing it, each
  pinned by a test proven to fail against the code it guards: the matcher seed omitted a stream
  variable shared with the static BGP but not projected (cross-zone false match) and, where present,
  carried the row's lossy Java form (so `xsd:int 42` matched no repository statement) — seeds now
  come from pre-projection `Value`s; `StaticBgpMatcher` now deduplicates candidate **triples** by
  s/p/o, never solutions, because a fixture loading one fact into both the default and a named graph
  produced two byte-identical solutions from one triple; and dedup keys are term-kind-qualified,
  since a blank node's `stringValue()` is its bare ID and `_:urn:x` collided with `<urn:x>`. The
  per-variable fill survives as `fillStaticVariablesIndependently`, used only by the temporal
  route's residual `putIfAbsent` stage, which is behaviourally untouched. The view scope is not
  widened: forward BFS only, null fallback, bounded enumeration under the existing work guard.
- **A prefixed name used as a constant inside an expression resolved to `null` for every prefix the
  query itself declared**, matching CQELS4J#55, so `FILTER(?row = c:REAR)` dropped every row while the
  same `c:` prefix resolved fine in the triple patterns two lines above — with no error at
  registration. `ExpressionEvaluator.resolvePrefixedName` consulted only `GeoVocabulary`'s seven
  built-ins and never the query's own prefix map; its two siblings in the same class,
  `resolveDatatypeIri` and `resolveFunctionIri`, already consulted the query prefixes first, and
  that asymmetry was the bug. The report named `FILTER` only; **every** expression context the
  engine evaluates through `ExpressionEvaluator` was affected — `FILTER`, `BIND` and `HAVING`, on
  both the stream-only and composed stream+static routes and inside an `OPTIONAL` block. The
  compiled-query pipeline needed no change (all seven evaluator constructions already passed the
  prefixes); the three in `CepPatternCompiler` used the no-arg constructor and now pass the prefix
  map they already held for the pattern's triple positions. **Deliberately unchanged:** an
  unresolvable prefix still yields `null` — a SPARQL type error, so `FILTER` drops the row and
  `BIND` leaves the variable unbound — matching what the other two prefixed-name positions and the
  triple-pattern side already do. Rejecting an undeclared prefix per SPARQL 1.1 §4.1.1.1 is a
  query-wide registration-time check that a per-row evaluation site cannot express; pinned as-is by
  two negative tests.
- **Rules never fired for observations pushed atomically**, matching CQELS4J#57.
  `ReactiveReteAdapter.apply` guarded on `isRDFStatement()`, which a `GraphStreamElement` returns
  `false` for by contract (its payload is a `List<Statement>`), so anything pushed through
  `DataStream.push(Collection)` passed through the adapter untouched — silently, with no warning.
  Users had to choose between atomic push (CEP and multi-pattern joins safe, reasoning dead) and
  statement-at-a-time (reasoning works, atomicity lost). `apply` now expands a multi-statement
  element and feeds each statement into the network stamped with the **element's** timestamp, so the
  whole observation shares one window slot and one eviction watermark; the element itself is
  re-emitted as the same instance rather than expanded, because atomicity is what the caller chose
  it for. Elements carrying no statement — a non-RDF payload, or a degenerate empty
  `GraphStreamElement` — still pass through unchanged.
- **Strict parse at every CQELS-QL registration surface**, matching CQELS4J#56 — see
  **Upgrade notes**. Strict parsing immediately caught a live instance of the bug in this tree:
  `cqels-cdsp`'s `energy-efficiency.cqels` read `[SLIDE 60s 10s]`, missing the mandatory `STEP`
  keyword, and the lenient parser had been registering a mangled window for as long as the file
  existed. Fixed here along with the two README rows documenting the same invalid shorthand. The
  spec's "`LATENESS` on `[NOW]`/`[TRIPLES n]` is a parse error" claim is now actually true at
  registration; previously the error was swallowed.

**Query correctness**
- **`FILTER NOT EXISTS` excluded a row only when the block's join-key variable happened to be
  materialised into the row** — SELECT list, expression reference, or an
  earlier pattern's binding. The same query on the same data returned opposite answers depending on
  the `SELECT` list, and the failure was silent-wrong: the anti-join simply went inert. SPARQL
  evaluates `NOT EXISTS` against the full solution mapping, so projection must not reach back into
  pattern evaluation. Each block's join-key variables — those shared with the outer group's stream
  pattern or static BGP, never block-local existentials — are now fetched into the row via
  `staticEnrichmentRowVariables` (memoised `computeNotExistsJoinVars`) and stripped after
  post-processing alongside the filter-only variables. Composition with the static-BGP-conjunction
  fix's matcher-authoritative enrichment was verified, not assumed: the matcher copies
  pre-projection seeds into every solution,
  so the fetch is a scalar column, never a row fan-out. **Route scope, stated honestly:** an
  `OPTIONAL` sharing the key resolves as well (pinned), while the **stream-only route** remains
  projection-dependent — pinned as a carve-out, not claimed fixed (see **Known issues /
  follow-ups**). A second carve-out, the **composed-aggregate shape**'s fold ordering, is
  closed later in this same release, below.
- **Three aggregate-projection shapes, one per aggregate route**, under a broader
  finding than that issue's title. A sweep of `{6 aggregates + 2 multi} × 5 windows ×
  {global, GROUP BY}` through `registerCqelsQuery` found the class still open on three shapes.
  `[NOW]` + a **global** (no `GROUP BY`) aggregate leaked the raw argument binding and is now
  rejected at registration (see **Upgrade notes**); a `COUNT(*)`-only projection over `[TRIPLES]`,
  `[SLIDE]` or `[RANGE … STEP]` emitted **nothing**, because `COUNT(*)` names no variable so every
  record was empty and the `!record.isEmpty()` filter annihilated the window — records are now
  retained for the all-`COUNT(*)` projection, which by construction implies `hasAggregates` so they
  fold to `{alias: value}` and no empty row can reach a consumer; and the same drop on the composed
  stream+static route is fixed below the inner-join gate, so multiplicity is unchanged. That issue's
  own reproducer (global `AVG` over `[TRIPLES 4]`) already passed on `main` — a separate, earlier fix
  had landed it in July,
  and the `@Disabled` marker that prompted the reopening was a stale carry-over from a branch based
  before that fix. Three pre-existing tests were rewritten: they pinned that the flag-off path "binds no
  `Long ?c`", which measurement showed was never true, and now pin the emission **grid**, which is
  what the flag actually governs.
- **Aggregates folded BEFORE `UNION` / `OPTIONAL` / `FILTER NOT EXISTS` instead of after**
 . SPARQL §18.2 evaluates the graph pattern before aggregation, so a solution
  a reshaper excludes must not contribute to an aggregate over its group. On the two routes that
  fold in a per-element batch — the stream-only windowed hash join and the composed stream+static
  aggregate topology — the fold ran FIRST, and the reshapers then probed a row already rebuilt from
  `GROUP BY` keys and aggregate aliases alone: `FILTER NOT EXISTS` excluded rows from the OUTPUT but
  not from the COUNT (a windowed count beside an exclusion of half the arrivals read `1,2,3,4` where
  `1,1,2,2` is correct, identically for `COUNT(?v)`/`SUM`/`AVG`); `UNION`, which is not a left outer
  join, matched neither arm on the folded row and dropped it, so the query emitted NOTHING;
  `OPTIONAL` null-filled the folded row, leaking block-introduced variables into a projection that
  never selected them. Fixed by reordering: the stream-only route now composes
  `rows → reshapers → fold → solution modifiers`; the composed route runs the reshapers between
  static enrichment and the in-batch `FILTER`/`BIND`, matching the composed `Istream` and
  non-aggregate topologies. Routes left alone, each verified rather than assumed: the incremental
  windowed-aggregate route is unreachable for these shapes (its gate rejects all three blocks and
  falls back to the hash join, inheriting the fix); the outer `collectList` fold already ran
  reshapers first; the temporal-join route rejects aggregates and all three blocks; `Istream` rejects
  aggregates at registration. **This also closes the composed-aggregate carve-out left open
  above** for `FILTER NOT EXISTS`: the fetched join key is now in the row when the anti-join probes
  it, so exclusion is effective there even for a `COUNT(*)`-only projection. **Residual, pinned
  rather than hidden:** on the stream-only route the row is the `SELECT` projection plus aggregate
  arguments with no fetch-and-strip pair, so a block key that is neither projected nor an aggregate
  argument leaves that block inert on either side of the fold — see **Known issues / follow-ups**.
- **Three engine fixes extracted from the six abandoned use-case PRs so they land on their own
  merits**. CEP `whereContext` guards in `SimplePatternProcessor` were never evaluated, and a
  negated state could match — both fixed. `ReasoningService`'s `isRDFStatement()` guard *reads* as
  "is this the kind of element I handle?" but *means* "is this a SINGLE statement?", so a
  multi-statement graph element was skipped entirely rather than reasoned over. And
  `ShaclContinuousQuery` now folds a `GraphStreamElement` into the snapshot atomically instead of
  dropping it. Recorded because the first attempt was worse than the bug: turning `statementsOf`'s
  silent drop into a pass-through collapsed two semantically **opposite** cases onto one empty list
  (main *forwarded* a non-RDF element but *dropped* an `isRDFStatement()==true` element with a null
  statement), and both callers read emptiness as "pass through" — producing an NPE raised before
  `Mono.fromCallable`, where `failClosedOnSolverError` cannot catch it. The guard now disambiguates
  at the call sites and drops. An intermediate commit message on that branch claimed the refactor
  "introduces no new failure mode"; that claim was false and was not carried forward.

**Multi-stream interval join hygiene** — the multi-stream epic's stages S1-S4
- **Interval-join runtime state was shared across every subscription to the returned `Flux`**,
  stage F5 — `IntervalJoinRuntime` was constructed once at assembly time inside `join(...)`, so
  buffers, watermarks and timers leaked between subscriptions. Its construction is now wrapped in
  `Flux.defer`, mirroring the existing per-subscription fix in
  `StreamElementToStreamEventAdapter`.
- **The left/right buffers had no cardinality bound**, stage F6(2). A cap
  (`cqels.join.intervalBufferCap`, default 100 000) now fails loudly instead of growing without
  bound. **The flag bounds one buffering STAGE, not the sum:** `SideBgpMatcher`'s raw-statement
  buffer and the interval join's own per-side buffer are capped separately by the same flag,
  so a *multi-triple* side can hold up to 2× the configured number across both stages. Size memory
  accordingly. **`F6(3)` — bounding the operator's internal event queue — is NOT implemented; it is
  an explicit documented waiver, not a resolution**, and the cap has no visibility into that queue:
  it is checked at admission, after an event has already been dequeued from `eventSink`, so a slow
  or backpressuring consumer can still accumulate unboundedly in front of it.
- **Out-of-order arrivals on the multi-stream route vanished silently**, stage F4(i):
  `TemporalJoinDispatch` always called `eventTimeJoin` with null late-data handlers. A 5-arg
  dispatch overload now carries a late-data listener, wired from `CqelsQueryCompiler`'s existing
  `lateDataDropListener`. Late drops are still **silent unless a listener is registered** —
  `setLateDataDropListener` defaults to `null`.
- **One defect class recurred across successive review rounds on the side-BGP stage, and the
  invariant it produced is now permanent**. Anything deciding whether a record or a buffered
  item is still relevant must read the *join*
  frontier — `min(left, right)`, and specifically the frontier of watermarks the operator has
  **enqueued**, not the one it has processed — because a side-local horizon discards partners the
  join still admits, and a processed-frontier read makes admission depend on downstream demand.
  Late filtering, side-BGP eviction and eviction on the watermark-forwarding path now all use that
  one anchor; only durations and spans may be per-side. The conjunction-extent check is
  overflow-safe (`nextMax > saturatingAdd(nextMin, extentMillis)` — the previous subtraction wrapped
  for timestamps more than `Long.MAX_VALUE` apart, admitting two statements that cannot co-reside
  under any window). **Documented, not fixed:** retention is governed by the *slower* side, so a
  stalled partner grows the side buffer until the cap fires; eviction is arrival-driven, so a side
  receiving nothing while the other advances retains until its next arrival or the cap; and state
  that the *other* side's later progress makes unreachable cannot be released without a cross-thread
  call into the single-threaded matcher — bounded by the cap, freed when the subscription ends.
- **The docs described the multi-stream route as RStream-cumulative; it is an interval join**,
  stage S4 track A. The cheatsheet claimed a windowed join "re-evaluates the whole window on every
  arriving element and re-emits all current rows", and told users to *omit* `Istream(...)` for that
  behaviour — omitting it gives neither. `CQELS-QL_SPEC.md` had no multi-stream section at all and
  listed "Multi-Stream Join Operators" under *Future Extensions*, while the route has compiled and
  executed since an early PR. New spec §10 states the route's real contract with every claim citing
  production code or a characterization test: the interval predicate with **closed** endpoints,
  insert-only pair emission (with the honest caveat that no retraction contract exists, so
  eviction-then-re-formation could re-emit), bag/multiset semantics, and the rejection surface by
  category. Two pre-existing doc errors fixed with it: `LATENESS` on this route is accepted at
  registration and rejected at `execute()` (two such sites), not rejected at registration; and late
  drops are silent unless a listener is registered. `CqelsQueryDefinition.isIstream()` and
  `CqelsQLASTBuilder` no longer claim "Default is RStream-cumulative" unconditionally.
- **The route had almost no result-asserting coverage before it was changed**, stage S2. Across the
  whole repository there was exactly **one** result-asserting test with a non-`[NOW]` window on a
  multi-stream join and **zero** covering the headline `RANGE ⋈ RANGE` shape.
  `MultiStreamWindowCharacterizationTest` (17 cases) pins what the route does today — expected
  values hand-derived from the traced interval predicate and cold-source event order, no oracle used
  — so S4 changed it against a recorded baseline rather than a blank one. Five behaviours are marked
  CHARACTERIZED rather than correct.

**Documentation accuracy, and the gates that keep it**
- **Every row-cardinality claim in the user-facing documentation was re-derived**, stage S5. The
  docs stated row counts that hold only for restricted query shapes: row count in this compiler is
  not a property of the window spec but is governed by `GROUP BY`, by `hasBatchSolutionModifiers()`
  (= `hasOrderBy() || hasLimit()`), by per-binding and per-window fan-out, by route, by the static
  side, by the EMIT policy and by the R2S operator. 158 claims enumerated across
  `CQELS-QL_SPEC.md`, `CQELSQL_SYNTAX_CHEATSHEET.md`, `ToolSchemas.java` and `README.md`; **89
  defects corrected over three passes** (47, then 36 from a re-audit of those corrections, then 6
  from review of the result). Notable semantic corrections: `OPTIONAL` is a left outer join and can
  never emit zero rows (it had been grouped with the inner-join shapes); a null `staticRepository`
  makes `NOT EXISTS` and `OPTIONAL` inert but makes `UNION` arms and inner/lookup/`GRAPH` joins
  **drop every row**; `k` matching triples give up to `k²` bindings on the multi-pattern hash-join
  route, not `k`; the out-of-order tolerance bound on centered windows is `P + F`, not `F`; and
  RSP-QL named windows parse and compile but throw at registration.
- **The first sweep broke shipped Java, and nothing caught it**. Audit commentary — including
  an editorial instruction to the editor — was spliced into the `register_stream_query` description,
  and one JSON string was split across two raw lines. The file still **compiled**, because a Java
  text block containing malformed JSON is a valid text block, so 2178 green tests and a clean
  compile said nothing about it: `register_stream_query` could not register at runtime.
  `ToolSchemasJsonValidityTest` now reflects every schema constant and parses each, and was proven
  to fail on the exact break before being trusted.
- **69 line-number citations replaced with method names** — 48 of them were already stale.
  Zero line numbers remain, so the convention has nothing left to drift.

### Changed
- **`validate_stream_query`'s MCP description no longer implies it catches registration-time
  rejections**. It is parse-only, and now says so, naming the constructor-time guards it
  never runs — which matters more now that strict parsing makes registration throw and this same fix
  adds a new registration-time rejection.
- **The interval join subscribes its sources on first `request()`, not on `subscribe()`**.
  Moving the trigger from `doOnSubscribe` to a once-guarded `doOnRequest` was necessary to close a
  subscribe-order race structurally — a cancelled Reactive Streams subscriber never calls
  `request()` — but it is a real, accepted behaviour change: a downstream that never requests (a
  custom `Subscriber` withholding `request()`) now leaves **both** sources permanently unsubscribed
  and the join never completes, where previously two otherwise-empty sources attached and completed
  regardless of demand. Cancellation still cleans up correctly.
- **Each emission axis note in the docs is presented as a WORKING list, not a guarantee**. It
  went A–F, then A–P, then A–S, and each revision was found incomplete by the next audit. "All axes
  checked" is a claim about a revision.
- **CI dependency bumps:** `anthropics/claude-code-action` 1.0.180 → 1.0.193,
  `dorny/paths-filter` 3.0.3 → 4.0.3, `actions/setup-java` 5.6.0 → 5.7.0,
  `docker/login-action` 3.7.0 → 4.6.0.
- **The nightly JDK compatibility probe's rationale no longer cites a JDK 26 deadline**. The
  comment justified collecting JEP 472/498 warnings "because JDK 26 turns the Unsafe ones into
  errors". The JDK never said that — its own wording is "a future release", undated — and the claim
  was stale on its face: 26 has shipped and these are still warnings. The argument for not silencing
  the flags never depended on a date and is unchanged.

### Upgrade notes

**Several merged changes alter results for existing queries.** The three below have the widest blast
radius and are spelled out in full; narrower ones follow, and none of the three lists is a total —
read all three before upgrading. Each replaces a silently wrong, silently empty or silently salvaged
result with a correct one, so none is a regression — but every one is observable.

1. **Every CQELS-QL registration surface now parses strictly**.
   `CQELSEngine.registerCqelsQuery` and `CQELSEngine.registerCepQuery`, the `cqels-cdsp`
   `QueryRegistry` `.cqels` file
   loader, and (inheriting through the engine methods, pinned by a regression test rather than
   assumed) MCP `register_stream_query` all route through `CqelsQueryParser.parseCqelsQLStrict`.
   **Before:** a genuine syntax error — a property path, a `[SLIDE 60s 10s]` missing its `STEP`
   keyword — was salvaged by ANTLR's single-token error recovery, printed to stderr, and registered
   as whatever recovery produced, which then emitted nothing. **After:** it throws
   `CqelsQLParseException` naming the offending construct. Queries that "worked" by silently doing
   nothing now fail loudly at registration; validate your registered query text before upgrading.
   `registerCypherQuery` and `registerAspQuery` are different grammars and are **out of scope** —
   they are unchanged.
2. **A static BGP outside `STREAM{}` on the composed route is now a CONJUNCTION**.
   **Before:** each needed variable was filled independently from the enriched view, so a
   partially-satisfiable static pattern still produced a filled row (`OPTIONAL`-like), and a value
   could come from a triple that did not satisfy the whole pattern. **After:** one row per complete
   satisfying assignment (so a multi-solution match fans out, bag semantics), and the row is
   **dropped** when the conjunction has no solution. Two second-order effects on the same change:
   the matcher's seed now carries pre-projection `Value`s, so a typed literal such as `xsd:int 42`
   matches statements it previously missed; and `StaticBgpMatcher` deduplicates candidate **triples**
   by s/p/o, so a repository holding one fact in both the default graph and a named graph now yields
   one solution where it previously yielded two — a SHACL fixture doing exactly that had been
   counting one violation as two. Distinct facts still fan out.
3. **`[NOW]` with a GLOBAL (no `GROUP BY`) aggregate is now rejected at registration**.
   **Before:** `SELECT (AVG(?temp) AS ?avgTemp) … [NOW]` delivered `{temp=25.0}` and never bound
   `?avgTemp` — every in-batch fold gate is guarded by `!isNow` and the outer fold requires
   `hasGroupBy()`, so it folded nowhere and the raw argument binding shipped. **After:** a loud
   rejection at registration naming the windows that accumulate. It is rejected rather than routed
   to a fold because `evictExpiredElements` has no `NOW` case: folding would never evict, so the
   aggregate would silently span the whole stream under an instantaneous-window declaration — a
   worse wrong answer with unbounded growth behind it. **Grouped `[NOW]` aggregates are unaffected**
   (they reach the outer fold and compute correctly on a finite source), and the multi-source
   temporal-join route keeps its own separate aggregate rejection.

**Further result changes, scoped narrowly:**

- **`FILTER NOT EXISTS` now excludes rows whose join key is unprojected — on the static-lookup
  route**. Rows that previously survived the anti-join because the key was absent from the
  `SELECT` list are now correctly excluded, and an `OPTIONAL` sharing that key resolves too. This is
  **route-scoped, not general**: the stream-only route remains projection-dependent and is pinned as
  a carve-out. The composed-aggregate shape was a second carve-out at the time but is closed
  later in this entry, below.
- **`COUNT(*)`-only projections that previously emitted NOTHING now aggregate**, over
  `[TRIPLES]`, `[SLIDE]` and `[RANGE … STEP]` windows and on the composed stream+static route. A
  consumer that treated the empty result as "no data" will now receive folded `{alias: value}` rows.
  `COUNT(*)` with an *unprojected* `GROUP BY` key still emits nothing — see below.
- **Aggregate results change for a query that also uses `UNION` / `OPTIONAL` / `FILTER NOT EXISTS`**
 , on the stream-only windowed hash join and the composed
  stream+static aggregate topology. A windowed `COUNT`/`SUM`/`AVG` beside a `FILTER NOT EXISTS`
  that previously excluded rows from the output but not from the count (`1,2,3,4` where `1,1,2,2`
  is correct) is now correct; a `UNION`+aggregate query whose folded row matched neither arm and
  emitted NOTHING now emits; an `OPTIONAL`+aggregate query that leaked block-introduced variables
  into the projection no longer does. The stream-only route keeps one carve-out — a block key that
  is neither projected nor an aggregate argument still leaves that block inert — see **Known
  issues**.

**Also worth checking before upgrading:**

- **A multi-stream join that previously grew without bound now fails loudly at 100 000 buffered
  elements per buffering stage**, behind the `cqels.join.intervalBufferCap` flag. Like alpha.18's three
  runtime caps, this fires at *runtime* on a data-dependent threshold, not at registration: a query
  registers clean and can throw later under load. Raise it with
  `-Dcqels.join.intervalBufferCap=<n>`, and supply a `QueryResultListener` with an `onError` if you
  register with a lambda — a tripped query otherwise stops emitting permanently with only a log line
  to show for it. **Sizing note:** the flag bounds one stage, not the sum, so a multi-triple side —
  which has both `SideBgpMatcher`'s statement buffer and the interval join's own — can hold up to
  2× that number. Multi-triple sides also make a trip likelier, because a solution is expired on its
  newest constituent but stops being usable at its oldest.
- If you subscribe to an interval join with a custom `Subscriber` that withholds `request()`, both
  sources are now left unsubscribed and the join never completes — see **Changed**.
- Rules now fire for observations pushed via `DataStream.push(Collection)`. If you worked
  around the old silence by pushing statement-at-a-time as well, drop the workaround — the atomic
  push now reasons on its own, and the statements share one window slot and one eviction watermark.
- A prefixed name inside `FILTER`/`BIND`/`HAVING` that previously matched nothing now resolves
 . Queries whose results were silently empty for this reason will start emitting rows.

### Known issues / follow-ups

- **Multi-stream / multi-window joins — revised from alpha.19.** That entry called them
  "effectively unimplemented" and listed four defects; S1/S2/S4 closed some and not others, so here
  is the corrected picture.
  - **Now fixed:** interval-join state is per-subscription rather than shared across every
    subscription to the returned `Flux`; the buffers are bounded by
    `cqels.join.intervalBufferCap` and fail loudly rather than growing without bound, so the
    "no state bound" clause no longer holds; the route is documented as the **interval join** it is
    rather than the RStream-cumulative relation join the docs promised; a `STREAM` block may
    now carry several triple patterns, conjoined by a BGP matcher; and a query sharing two or
    more variables is joined naturally on all of them instead of being rejected.
  - **Still true:** the tested, correct multi-way join machinery — `MultiWayJoinState` /
    `JoinGraph` — remains **unreachable from the query language** (verified: outside its own
    package it is referenced only by `cqels-benchmarks` and tests, never by `cqels-core`'s or
    `cqels-engine`'s production sources, and by no compiler). There is still **no retraction**:
    emission is insert-only pair emission, so eviction followed by re-formation could re-emit, and
    no delta or
    retraction contract exists. And the route remains an **interval join derived from the two window
    durations**, not general multi-way windowed-relation machinery — that is the epic's recorded
    decision D1 = (a'), not an oversight, and a windowed-relation join would arrive later on a
    separate named-window surface. Per-stream watermark control is still absent: `LATENESS` on this
    route is accepted at registration and rejected at `execute()`, and idle-source watermark advance,
    stage F6(1), is still open. `TemporalJoinSpec.Kind.WINDOWED` remains a declared-but-unimplemented
    second kind that throws on dispatch. The epic stays open.
  - **No longer true, and it was already stale when alpha.19 shipped:** alpha.19's claim that "a
    multi-stream declaration without a matching number of `STREAM` blocks silently falls back to a
    nondeterministically chosen single stream" describes behaviour that an earlier PR had
    *already* replaced with a loud `UnsupportedOperationException` naming both supported shapes —
    that PR is
    an ancestor of the `v2.0.0-alpha.19` tag. Nothing in this cycle changed it; the alpha.19 note
    was simply describing a state of the tree that no longer existed.
- **Residual from the aggregate-fold reordering — on the stream-only route, a reshaper's
  block key that is neither projected nor
  an aggregate argument still leaves that block inert, on either side of the fold.** Unlike the
  composed route, whose static-enrichment stage fetches a `FILTER NOT EXISTS`/`OPTIONAL`/`UNION`
  block's join key into the row even when it is unprojected, the stream-only route's row is
  only the `SELECT` projection plus the aggregate arguments — there is no equivalent fetch-and-strip
  mechanism there. Reordering the fold relative to the reshapers fixed the ordering
  everywhere the key IS present in the row, but does not change this: `SELECT (COUNT(*) AS ?n) … FILTER
  NOT EXISTS { ?s ex:excluded ?e }` on the stream-only route still reads `1,2,3,4` instead of
  `1,1,2,2` unless `?s` is projected or is itself an aggregate argument.
- **`COUNT(*)` with an unprojected `GROUP BY` key still emits nothing.**
  `mapBindingsToRecord` carries only `SELECT` variables and aggregate-argument variables, never an
  unprojected group key; with `COUNT(*)` naming no variable either, every record is empty and the
  `!record.isEmpty()` filter annihilates the batch. One shape past the earlier aggregate-projection
  fix above, and deliberately
  excluded there: retention alone would trade an empty result for a **wrong** one, since
  every-record-empty collapses `buildGroupKey` to a single null-keyed group counting everything.
- **The row representation still string-lifts non-core typed and language-tagged
  literals**, so `datatype()`/`lang()` lose fidelity and a bare `FILTER(?dt)` over an `xsd:dateTime`
  binding is still wrongly admitted on `Map`-row routes. This is what **bounds `sameTerm()`
  accuracy** for row-bound literal arguments: two numerics differing only in lexical form or
  hierarchy datatype can false-positive, and tagged/typed literals can narrow to bare strings. IRI
  and blank-node arguments are exact. Carried over from alpha.19, unchanged.
- **Private tracker issue numbers ship inside user-facing exception messages.** They appear
  in `throw` messages across `cqels-core` and `cqels-engine` — `CqelsQueryCompiler` carries most of
  them, with `CQELSEngine`, `CqelsQLASTBuilder`, `StaticBgpMatcher`, `TemporalJoinDispatch`,
  `WindowSpec`, `CepChannelEmitter` and `CepPatternCompiler` supplying the rest — in messages
  downstream users hit through the published artifacts and cannot look up. **The issue's own tally
  is wrong and needs recomputing before the sweep is scoped from it:** it claims 46 distinct issue
  numbers, but a fresh scan of `src/main` string literals with comments and Javadoc stripped finds
  17 distinct tokens (16 numbers plus one two-variant token counted once) across roughly two dozen
  message sites
  — and the population is a moving target, since `CepChannelEmitter` is new in this release and
  arrived carrying one. It propagates: a downstream docs PR
  quoted one verbatim into public prose and was caught only in review. Not a security issue — the
  numbers alone, no credentials, URLs or private project names — and not release-blocking; the
  messages are correct and useful apart from the reference. The durable half of the fix is extending
  the existing leak gate, itself a prior fix in this same family, to string literals in
  `src/main`, which it currently does not cover.
- **Use-case tutorials, slices 2-4 outstanding.** The tutorials module landed slices 01-04
 ; 05-16 remain.
  The six original use-case PRs are closed, not merged: they were never built or tested (their only
  workflow died at `detect-changes` on an Actions billing block, so every downstream job showed
  SKIPPED and the red X was misread as a workflow bug), and running them locally gave 84/88 with all
  four failures asserting behaviour the engine does not have.
- **Two S5 test-coverage residuals closed, two documentation residuals not.** The row-cardinality
  sweep recorded
  that the CypherQL, CEP and `SPARQLExecutor` routes were reached by code reading with no tests
  pinning them, and that processing-time mode rested on a single `eventTime` gate with no test.
  This closes both, as four pinned claims — CypherQL `SlidingWindow` and `SPARQLExecutor`'s
  `collectList` paths are completion-only (pinned at operator *and* registered-query level, with
  exact step-grid counts); directional CEP honours `LATENESS` (a control/treatment pair over one
  event sequence with opposite outcomes); processing-time interval joins consult no watermark for
  lateness. All four hold exactly as worded — no doc/engine contradictions found. **Not closed:** the
  status-and-provenance claims ("not yet merged", "not yet implemented") that the same sweep flagged
  as their
  own unswept population after three instances turned up incidentally; and no convergence is claimed
  for the docs sweep — each of the last several rounds found defects introduced by the previous
  round's fixes. What improved is that the *structural* classes (JSON validity, citations,
  duplication, paren damage, punctuation residue, table shape) are gated rather than merely fixed.
  The same PR also records a finding it did not act on: `IntervalJoinOperator.processingTimeJoin` is
  unreachable from any production API (`TemporalJoinDispatch.assemble` only ever builds event-time),
  awaiting a remove/wire/document decision.
- **The interval join expires side-BGP solutions by their newest constituent.** A solution
  stops being usable at its *oldest* constituent but is expired by the operator on its *newest*, so
  it occupies the buffer for `newest − oldest` longer than it can ever produce a row; worst case, a
  solution spanning its whole side window is dead on arrival and still held for a full window, and
  at sustained rates those entries count against `cqels.join.intervalBufferCap`. Documented rather
  than fixed, deliberately: `cleanupBuffer` is a `headMap` range cut over a timestamp-keyed
  `NavigableMap` that removes whole buckets, and two solutions sharing a `newest` but differing in
  `oldest` land in the same bucket, so the rule cannot be expressed as a cut on that key. **Liveness
  and memory, not correctness:** no wrong row results, but the buffer cap fails loudly, so at
  sustained rates the dead entries can trip it and terminate an otherwise valid join — raise
  `-Dcqels.join.intervalBufferCap=<n>` if a legitimate query hits it. Single-triple sides are
  unaffected (`newest == oldest`).
- **Fail-loud on unknown function names was assessed and deliberately NOT shipped** with `sameTerm`
 . `CqelsQueryCompilerFunctionRegistryTest` pins silent-`null` as permanent behaviour, and
  the grammar's `idToken` path would need a synchronized keyword list across every expression entry
  point. The gap is known, not overlooked — a misspelled function name still evaluates to `null`,
  which `FILTER` reads as a type error and drops the row.
- **The interval join's internal event queue is still unbounded**, epic stage F6(3), by
  explicit
  documented waiver rather than resolution. The per-side buffer cap bounds a different
  structure. Also still open on this route: `SideBgpMatcher` may drop a
  `GraphStreamElement` — the same `isRDFStatement()` guard family as the `ReasoningService` and
  SHACL fixes above — and `StreamElementToStreamEventAdapter.lift` drops the
  subscriber's Reactor context and drains its source unbounded during subscription — both
  pre-existing, both shared with the temporal-join route.
- **Carried over from alpha.19, unchanged:** modifier-free per-arrival routes still ignore their
  declared window entirely.


## Using this release

Artifacts resolve anonymously — no token, no `settings.xml`:

```xml
<repositories>
  <repository>
    <id>cqels</id>
    <name>CQELS Releases</name>
    <url>https://maven.cqels.org/releases</url>
  </repository>
</repositories>

<dependency>
  <groupId>org.cqels</groupId>
  <artifactId>cqels-engine</artifactId>
  <version>2.0.0-alpha.20</version>
</dependency>
```

## Verifying this release

The signed manifest and its signature are served from the artifact repository;
the public key is served from a different origin, so verifying with it is not
circular. Take the key from the commit pinned below.

```bash
set -euo pipefail
BASE=https://maven.cqels.org/releases/supply-chain/2.0.0-alpha.20

curl -fsSLO https://raw.githubusercontent.com/cqels/CQELS4J/5bc3d2d00a0c7a5db8e496ad22e73fc34f2a399b/cosign.pub
echo "36dd8daa9988f23eb40c4f3550fa7bdfa3796e5e58cce8d23b9cc6a99f47f30b  cosign.pub" | sha256sum --check --strict - || exit 1

curl -fsSLO "$BASE/SHA256SUMS" -O "$BASE/SHA256SUMS.bundle"
cosign verify-blob --key cosign.pub --bundle SHA256SUMS.bundle \
  --new-bundle-format=false --insecure-ignore-tlog SHA256SUMS
```

The URL is pinned to a commit rather than to `master`, so it keeps working
after a key rotation. `--insecure-ignore-tlog` is expected: these signatures
carry no transparency-log entry, which is precisely why the key's origin
matters. Full walkthrough: https://github.com/cqels/CQELS4J/blob/master/SUPPLY_CHAIN.md