Skip to content

fix(intent): two event-driven rules can no longer share one at-most-once guard - #6859

Merged
delchev merged 17 commits into
eclipse-dirigible:masterfrom
TIVMOF:fix/colliding-rules
Aug 20, 2026
Merged

fix(intent): two event-driven rules can no longer share one at-most-once guard#6859
delchev merged 17 commits into
eclipse-dirigible:masterfrom
TIVMOF:fix/colliding-rules

Conversation

@TIVMOF

@TIVMOF TIVMOF commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Refuses, at parse time, two event-driven generates: / posts: rules that share a target entity and a back-reference relation.

The guard the templates emit is findAll(eq(<backReference>, sourceId)) — it asks whether the source already has a row through that relation, and cannot tell which rule wrote it. So two rules sharing both divided silently into a winner and a loser: whichever fired first claimed the source forever, and the other handed back that row instead of writing, for that source and every future one.

generates:
  - name: log-identification-failed
    from: Fine
    to: FineLog
    event: { onTransition: Fine, when: "Status == UNRESOLVED" }
    map: { fine: id }
  - name: log-declaration-created
    from: Fine
    to: FineLog
    event: { onTransition: Fine, when: "Status == DECLARED" }
    map: { fine: id }              # SAME back-reference

Nothing surfaced it: it parsed, generated and compiled, and the loser read as a rule whose condition never matched. Disjoint when: guards did not save it — which is the trap. The author writes two mutually exclusive conditions and expects two independent rules, but the collision is decided by the target's existence, not by the condition that led to it, so the second rule no-ops on a Fine whose condition it matched perfectly.

Both halves of the key are static in the model, so this is now an authoring-time error naming the ways out: separate back-references (two to-one relations to the source), separate targets, or mode: append on every one of them.

Two decisions worth recording

  • One check spans both constructs rather than one per collection, because a posts: row satisfies a generates: guard just as well.
  • mode: append is exempt only in pairs. Two appending rules genuinely cannot collide — neither reads the other's rows, since append is the absence of the guard. But append plus guarded does collide: the appended rows still carry the back-reference, and that is all the guarded rule's lookup needs to be satisfied forever. That pairing is the case generates.event: should support the process-step axis and an opt-in append cardinality (mode: once|append) #6800 leaves open, and it has its own test.

A non-event-driven create-from is a button — a person decides when it runs and no guard is emitted — so two of those share nothing and are left alone.

Not addressed here: a posts: entry whose glue yields an empty backRef gets no idempotency guard at all. It is real, but it is a different defect from the collision, and turning a missing idempotentBy: into an error risks rejecting models that already ship. Worth its own issue.

Verification

  • engine-intent 760/760; IntentEngineIT 54/54; IntentEmissionCoverageIT 1/1 — the last two matter most here, since a new parse error could have rejected models the repo already ships.
  • Red-first: all three rejection cases in CollidingGuardIntentTest parse silently without the validator, which is the reported defect exactly.

What issues does this PR fix or reference?

Release Notes

Two event-driven generates: or posts: rules that wrote to the same target through the same back-reference used to collide silently: the first one to fire claimed the source permanently and the others never wrote again, with no error anywhere. This is now reported when the intent is parsed, with the available fixes named.

Documentation

intent-assistant-guide.md and components/engine/engine-intent/CLAUDE.md are updated in this PR. No matching docs-repo PR — the intent DSL reference lives in-tree.

TIVMOF and others added 16 commits August 10, 2026 08:20
The guard the templates emit is findAll(eq(<backReference>, sourceId)): it asks
whether the source already has a row through that relation, and cannot tell which
rule wrote it. So two event-driven `generates:` / `posts:` rules sharing a target
AND a back-reference divided silently into a winner and a loser - whichever fired
first claimed the source forever, and the other handed back that row instead of
writing, for that source and every future one.

Nothing surfaced it. It parsed, generated and compiled, and the loser read as a
rule whose condition never matched. Disjoint `when:` guards did not save it
either, which is the trap: the author writes two mutually exclusive conditions and
gets two independent rules, but the collision is decided by the target's EXISTENCE,
so the second no-ops on a source whose condition it matched perfectly.

Both halves of the key are static in the model, so refuse it at parse time and
name the ways out - separate back-references, separate targets, or `mode: append`
on every one of them.

Two decisions worth recording. The check spans both constructs rather than being
one per collection, because a `posts:` row satisfies a `generates:` guard just as
well. And `mode: append` is not simply exempt: two appending rules genuinely
cannot collide, since neither reads the other's rows, but append PLUS guarded does
- the appended rows carry the back-reference, and that is all the guarded rule's
lookup needs to be satisfied forever. That pairing is the case eclipse-dirigible#6800 leaves open,
and it has its own test.
Two separate races against one cold page load, both of which report a healthy
Builder as broken.

openBuilder() returns as soon as navigation does and the shell's scripts are
deferred, so the probe for Alpine and the Builder's stores sampled a page that had
not finished booting. That is the failure seen on this PR's smoke run and on
eclipse-dirigible#6841 - a test with nothing to do with either change. It now polls the same
condition instead; a mis-ordered or missing script still never registers the
stores, so a real breakage still fails, just at the deadline rather than instantly.

The second race had not been drawn yet. This class configures no Selenide timeout,
so every shouldBe without an explicit Duration gets the library default of four
seconds - and three of them wait on elements Alpine renders after those stores
register, one straight after a page load. Four seconds there is a race against the
boot, not a check on it. They get explicit deadlines, in line with every other
wait in the class.

Left alone: the waits that follow an already-satisfied wait on the same rendered
region. The DOM is present by then, and inflating every timeout only makes a
genuine breakage take minutes to surface instead of seconds.
One conflict, in the engine-intent guide's `generates` + `event:` bullet:
master extended it with the sourceStatus flip ordering (the flip now runs
BEFORE the target is saved) while this branch appended the guard-ownership
paragraph. Both kept, master's text first. The parser halves auto-merged and
both validators are wired: validateStatusWritesAgainstLifecycle from master,
validateIdempotencyGuardOwnership from this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev

delchev commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Merged current master into the branch to clear the conflict (pushed to TIVMOF:fix/colliding-rules on top of your 66f1e9b; the PR is mergeable again).

One conflict, in the engine-intent guide's generates + event: bullet: master extended it with the sourceStatus flip ordering (the flip now runs BEFORE the target is saved) while this branch appended the guard-ownership paragraph. Both kept, master's text first. The parser halves auto-merged and both validators are wired — validateStatusWritesAgainstLifecycle from master, validateIdempotencyGuardOwnership from here.

The thing worth checking for a PR that adds a new parse-time rejection is whether master's newer fixtures still parse: #6824 added a pair of appending create-froms to IntentEmissionCoverageIT that deliberately share a target AND a back-reference. Your two-append exemption covers it — verified rather than assumed:

  • IntentEmissionCoverageIT, IntentEngineIT (55) and ModelGenerationIT all green — 57 tests
  • engine-intent unit suite 764, including your CollidingGuardIntentTest (7)
  • formatter:validate clean

# Conflicts:
#	tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentBuilderShellIT.java
@delchev
delchev merged commit 882eb43 into eclipse-dirigible:master Aug 20, 2026
10 checks passed
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.

Two generates:/posts: rules sharing a target back-reference silently collide - detect it at parse time

2 participants