Skip to content

intent: a row outside every key-tuple is not guarded, and a rule match needs a literal (#7180) - #7253

Merged
delchev merged 1 commit into
masterfrom
issue-7180-guard-null-key
Sep 9, 2026
Merged

intent: a row outside every key-tuple is not guarded, and a rule match needs a literal (#7180)#7253
delchev merged 1 commit into
masterfrom
issue-7180-guard-null-key

Conversation

@delchev

@delchev delchev commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

#7166 made Criteria.eq / ne null-safe, which is right for the idempotency guard it was written for and changed two generated call sites that pass a possibly-null value without saying what they mean by it.

The aggregate guard

aggregates: is explicit that a source row with any grouping key unset belongs to no tuple and is ignored - the generated aggregate handler says so in its own contract and materialises no target row for such a row. The guard that recomputes the same total said nothing about it and relied on SQL three-valued logic for the answer:

Both are wrong in the same way. A record that contributes to no total can breach no minimum, so the guard does not apply to it at all: no throw, no marker flip, no forced setStatus. The generated code now says that in a null test of its own rather than leaving it to how a comparison with no value happens to render, which also keeps the guard and its aggregate two computations of the same total instead of two different ones. The sibling roll-up capacity guard has always skipped a null FK this way. The skip is logged at debug naming the aggregate, so a record that is never guarded can be traced to the key it lacks.

boolean guardKeyed = entity.Person != null && entity.Unit != null;
boolean guardWithin = true;
if (guardKeyed) { /* recompute over the tuple, as before */ }
else { LOG.debug("Aggregate guard [remaining] skipped ..."); }

A grouping key that must always be there is declared required - a separate and better statement where it is true.

The posting's rule lookup

Not exposed to the change at all, contrary to the issue's reading: the value bound there is the authored literal of rule.match, never a value read off the source, so it is never null and the null-safe eq cannot reach it. The template now says so, and why no runtime guard belongs there.

What it was exposed to is an authoring accident the parser let through - a blank literal. It emits a lookup for the empty string, matches no rule row, and sends every source document to the unposted worklist with the intent, the generation and the publish all green. Refused now where it is authored. A value omitted outright (documentType: with nothing after it) was already refused as an empty selector, the typed mapping dropping the null entry.

Verification

  • IntentEmissionCoverageIT green - it renders both templates, asserts the new guard shape, and compiles the generated client Java (Compiled batch: [327] units, [340] class file(s), no failures).
  • engine-intent unit suite green (1187 tests), with the emitted aggregate name and both rule-match refusals asserted.
  • formatter:validate and the release-profile javadoc build clean.

Docs

Fixes #7180

🤖 Generated with Claude Code

…h needs a literal (#7180)

#7166 made `Criteria.eq` / `ne` null-safe, which is right for the idempotency
guard it was written for and changed two generated call sites that pass a
possibly-null value without saying what they mean by it.

The aggregate guard is the one that matters. `aggregates:` is explicit that a
source row with any grouping key unset belongs to no tuple and is ignored - the
generated aggregate handler says so and materialises no target row for such a
row - but the guard that recomputes the same total said nothing about it, and
relied on SQL three-valued logic for the answer. So a movement authored without
its warehouse used to be weighed against zero and rejected for breaching a pool
that does not exist; since #7166 it is weighed against the sum of every other
tuple-less row, a pool no aggregate row materialises. Both readings are wrong in
the same way: a record that contributes to no total can breach no minimum, so
the guard does not apply to it at all - no throw, no marker flip, no forced
status - and the generated code now says that in a null test of its own instead
of leaving it to how a comparison with no value happens to render. The sibling
roll-up capacity guard has always skipped a null FK this way. The skip is logged
at debug, naming the aggregate, so a record that is never guarded can be traced
to the key it lacks.

The posting's rule lookup turns out not to be exposed to the change at all: the
value bound there is the authored literal of `rule.match`, never a value read off
the source, so it is never null and the null-safe `eq` cannot reach it. What it
was exposed to is an authoring accident the parser let through - a blank literal,
which emits a lookup for the empty string, matches no rule row, and sends every
source document to the unposted worklist with the intent, the generation and the
publish all green. That is now refused where it is authored, and the template
says why no runtime guard belongs there (a value omitted outright was already
refused as an empty selector: the typed mapping drops the null entry).

Verified through IntentEmissionCoverageIT, which renders both templates and
compiles the generated client Java (327 units, no failures).

Fixes #7180

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

data-store-java: the null-safe Criteria.eq changed two unguarded call sites - a null key now groups, a null rule match now posts

1 participant