Skip to content

Fix discarding identification and audit - #6841

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

Fix discarding identification and audit#6841
delchev merged 20 commits into
eclipse-dirigible:masterfrom
TIVMOF:fix/discarding

Conversation

@TIVMOF

@TIVMOF TIVMOF commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

This PR:

resolves: throws away the identification when the status move is rejected

resolves: batched three semantically independent values — the resolved relation, the outcome:
trace, and the routing status — into ONE updateProperties call. The generated DAO runs the
lifecycle: and checks: gates against the post-write row before persisting anything
(Repository.java.template: values applied in memory → enforceLifecycleMoveenforceChecks
super.updateProperties), so a rejected status move discarded the relation and the trace with it.

The system did the work, got the right answer, and threw all of it away — including the audit field
whose entire purpose is recording what happened. And because the listener dispatcher gave no
explanation, a reviewer could not tell this apart from "the lookup never ran".

The fix

stamp() now writes twice, in this order:

  1. The result — the resolved relation + the outcome trace. This is the work: the register was
    read, exactly one row covered the date, this is the answer.
  2. The routing status, separately.

A rejected transition can no longer destroy the identification, and the audit trace is the last
thing a failure can take.

The routing write catches ValidationException specifically. Retrying cannot help — nothing about
the record changes by re-reading the register — so rethrowing would only burn the redelivery budget
(now real, since #6830) and dead-letter. Instead it logs and amends the trace to
<outcome>-notRouted, so a routed-but-rejected record stays distinguishable from a fully processed
one. A genuine DB failure is not caught and still propagates and retries.

Both writes stay targeted, so no -updated re-fires and no concurrent write to another column is
reverted.

Also here

A parser guard that the outcome: field is long enough for the values written — 9 normally, 19 once
any outcome routes by setStatus. Truncation happens at the DB where nothing reports it, which
would have reintroduced the same silent loss through the back door. Only bites an author who
explicitly narrows the field (the default VARCHAR length is 100).

Verification

  • IntentEngineIT.resolve_writes_the_result_before_the_routing_status — asserts the write order, that
    the status is not in the batched map, the ValidationException catch, the amended trace, and
    the log line.
  • ResolveIntentTest.rejectsAnOutcomeFieldTooShortForTheValuesWritten — red-first verified; also
    asserts the non-routing case still accepts a shorter field.
  • engine-intent 692/692, formatter:validate clean.
  • Template rendered through real Velocity in all four #if shapes (writesStatus × outcomeProperty).

Not run: the new IT. engine-camel's Maven plugin is unresolvable in my offline environment,
which blocks the whole failsafe suite. CI will be its first execution.

TIVMOF and others added 19 commits August 10, 2026 08:20
…ification

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openBuilder() returns as soon as navigation does, and the shell's scripts are
deferred, so the probe for Alpine and the Builder's stores ran against a page
that had not finished booting. On a slow runner that reports a healthy shell as
broken - which is what it did on this branch's smoke run, in a test that has
nothing to do with the change under review.

Poll the same condition to a 30s deadline. A mis-ordered or missing script never
registers the stores, so a real breakage still fails; it just fails at the
timeout rather than instantly. Polling happens in the calling thread because
Selenide binds its WebDriver per-thread.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tches

Three PRs in a row failed their smoke run on assertions that were right about the
generator and wrong about how they looked at it. All three were the same two
hazards, so fix the hazards rather than the sites.

Comments. The events templates carry long explanations that necessarily name the
very calls and topics being asserted on, so a contains() could be satisfied by
prose the code never contained - and an assertFalse() tripped by prose the code
never earned. Read generated Java through codeOf(), which strips comments with a
scanner rather than a regex, because a "//" inside a literal (an endpoint URL, a
JSON template) would otherwise cut real code away. Three assertions turned out to
be about prose on purpose - a scaffold naming its field in the javadoc, a
developer's preserved marker, a guard matched by its trailing comment. The first
two stay on contentOf() and say why; the third now matches the guard itself.

First matches. indexOf() answers with the first hit and says nothing about a
second, so an anchor that becomes ambiguous silently relocates the assertion. That
is how the number stamp's reload assertion came to be about its guard read
instead. Ordering assertions now go through onlyIndexOf(), which fails at the
anchor when it is not unique.

Coverage. ModelGenerationIT exists to catch unresolved ${...} in rendered output,
but its fixture declared no resolves, writers or numbering - three of the four
templates these PRs touched were never rendered by it. Adding them is fixture-only
and verified red-first: dropping setProperty, keyAccessor and series each surfaces
as an unresolved reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous push updated this PR's head but GitHub created no workflow run for
it, leaving the PR displaying the previous head's conclusion. Empty commit to
fire pull_request.synchronize so the checks run against the current code.
…arding

# Conflicts:
#	components/engine/engine-intent/CLAUDE.md
One file conflicted, Resolve.java.template, three times - and the two changes
are complementary rather than competing:

- this branch splits stamp() into two targeted writes so a rejected status
  move can no longer discard the resolved relation and the audit trace;
- master's eclipse-dirigible#6836 publishes "-transitioned" after a routing so an AUTOMATIC
  resolution reaches the constructs a manual transition does.

Both kept. The publish is placed INSIDE the try, immediately after the
successful updateProperty: a status the record could not take is not a
transition, so a rejected move must announce nothing - master's version sat
after a single combined write, where that distinction did not yet exist. The
two javadoc blocks are merged the same way.

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/discarding; the PR is mergeable again).

One file conflicted — Resolve.java.template, three times — because #6836 landed on the same method. The two changes are complementary, so both are kept:

The one judgement call: #6836's publish sat after a single combined write, where "the status was written" and "the method succeeded" were the same thing. With the write split they are not, so the publish now sits inside the try, immediately after the successful updateProperty — a status the record could not take is not a transition, so a rejected move announces nothing. Master's now-duplicate trailing write block is dropped, and the two javadoc blocks are merged the same way.

That placement also keeps this branch's own emission assertions valid: onlyIndexOf requires repository.updateProperties(id, values) and repository.updateProperty(id, "Status", status) to appear exactly once each, which the leftover block would have broken.

Verified on the merged tree before pushing: IntentEngineIT (55), IntentEmissionCoverageIT (including #6836's assertResolveTransitionRuntime, which needs the publish to still fire) and ModelGenerationIT all green — 57 tests; plus the engine-intent unit suite (754) and formatter:validate. CI is re-running the rest.

TIVMOF added a commit to TIVMOF/dirigible that referenced this pull request Aug 20, 2026
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.
TIVMOF added a commit to TIVMOF/dirigible that referenced this pull request Aug 20, 2026
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.
# Conflicts:
#	tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java
@delchev
delchev merged commit 420878e into eclipse-dirigible:master Aug 20, 2026
9 checks passed
delchev added a commit that referenced this pull request Aug 20, 2026
…6867)

With #6841 merged, the lookup's routing status was a targeted
updateProperty followed by a post-commit re-read and a bare publish of
"-transitioned" - the same write-beside-publish pair the rest of this
branch removes. The topic now rides the routing write into the outbox:
the flip and its announcement commit together, the payload is the row
exactly as the statement left it, and a rejected move (the
ValidationException the routing write catches) records no event at all -
a status the record could not take is not a transition.

The emission oracles pin the new contract: IntentEngineIT's two-writes
ordering assertion anchors on the topic-carrying updateProperties, and
IntentEmissionCoverageIT's resolve block asserts the topic on the write
and refuses any bare Producer.sendToTopic in the generated lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
delchev added a commit that referenced this pull request Aug 20, 2026
…nce guard (#6859)

* feat: Implementing appUrl inside the notify subject/body interpolation

* feat: Implementing tenant config overrides into client-Java listener dispatch

* Refuse two event-driven rules that share one at-most-once guard

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 #6800 leaves open,
and it has its own test.

* Stop IntentBuilderShellIT racing the shell it is testing

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
#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.

---------

Co-authored-by: Nedelcho Delchev <delchevn@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
delchev added a commit that referenced this pull request Aug 20, 2026
…h their writes (#6869)

* fix(templates): the rekey pair and the transition announce commit with their writes (#6867)

#6854 established the contract - announce an entity change by handing the
topic to the WRITE, never by publishing beside it - and converted the
generated repository's main paths. Two write+publish pairs survived it,
each a live instance of #6816's failure class (row committed, event lost
for good when the broker is briefly down, downstream reactions silently
never run):

- The DAO's targeted "-rekeyed" pair (#6845): one mutation had to record
  TWO bodies - the row as it stood, naming the group it left, and the row
  as written, naming the one it joined - and the targeted write could
  carry only one topic. The store and JavaRepository gain the
  event-carrying overloads (save and updateProperties with
  additionalEvents, mirroring update's trio), recorded only when the row
  actually existed to be written; the template hands both notices to the
  base write. A model without grouping keys regenerates byte-identically.

- The transition controller (Transition.java.template): updateProperty +
  post-commit re-read + bare publish becomes one targeted write carrying
  the "-transitioned" topic, so the flip and its announcement commit
  together and the event payload is the row exactly as the statement left
  it - never a re-read a concurrent write could have moved on. The reload
  stays only for the HTTP response and the notify block.

Deliberately NOT converted here, with the reasoning on #6867: the five
deferred/ordered announces (SetField, Writer, Numbering, StepEvent via
Process.executeAfterCommit - deferred so consumers observe the whole
synchronous Flowable chain's writes, which write-attachment would regress
- and Generate's completion announce, ordered after the target and its
items across several transactions). Those need a durable-enqueue
primitive, a follow-up of its own. Resolve.java.template follows once
#6841, which rewrites that exact region, lands.

JavaEventOutboxIT gains the retarget phase - one targeted mutation, two
notices, both delivered and both cleared - and IntentEmissionCoverageIT
pins the new contract, including that no bare Producer.sendToTopic
remains in a generated repository or transition.

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

* fix(templates): the resolve routing announce commits with its write (#6867)

With #6841 merged, the lookup's routing status was a targeted
updateProperty followed by a post-commit re-read and a bare publish of
"-transitioned" - the same write-beside-publish pair the rest of this
branch removes. The topic now rides the routing write into the outbox:
the flip and its announcement commit together, the payload is the row
exactly as the statement left it, and a rejected move (the
ValidationException the routing write catches) records no event at all -
a status the record could not take is not a transition.

The emission oracles pin the new contract: IntentEngineIT's two-writes
ordering assertion anchors on the topic-carrying updateProperties, and
IntentEmissionCoverageIT's resolve block asserts the topic on the write
and refuses any bare Producer.sendToTopic in the generated lookup.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
delchev added a commit that referenced this pull request Aug 20, 2026
…nt predicate (#6858)

* feat: Implementing appUrl inside the notify subject/body interpolation

* feat: Implementing tenant config overrides into client-Java listener dispatch

* Let a register lookup filter the register with a constant predicate

A `resolves:` lookup could not narrow its register by a literal. Every `match:`
pair binds a register column to a column of the RECORD, so "and only the rows
that are still valid" had no form at all - and a register is exactly the kind of
table that keeps its corrections. The cancelled row stays beside the active one
over the same dates, so both cover the violation date, and a lookup with one
right answer reports `ambiguous`, routes to manual handling and logs "multiple
matches". Every correction ever made earns that, quietly, and it gets worse the
longer the register lives.

Add an optional `where: { <register property>: <literal> }`, ANDed onto the same
Criteria as the match keys so it narrows the query rather than being applied
after the period comparison.

Three things are deliberately not a mirror of the relation-level `where:`.
Multiple pairs are allowed: that one is capped at a single pair because it lands
in two EDM attributes, whereas these are chained `eq` calls where a second
condition costs nothing. A pair naming the register's `function: EntityStatus`
relation may use the seeded NAME, resolved on the REGISTER's own nomenclature -
the record's would hand back a plausible id from the wrong lifecycle - and only
that pair is offered to the resolver, so an ordinary `kind: PRIMARY` is not
reported as an unknown status. And a pair repeating a `match` key is refused
rather than ANDed: on a column already bound to the record a literal either
repeats the match or contradicts it into matching nothing, and which one depends
on data the parser cannot see.

The generated handler names the filter in its javadoc and in the notFound log, so
a filter that is too narrow reads as a filter rather than as missing data.

Also two defects in the javaLiteral helper this reuses: a Boolean was quoted into
the string "true", which matches no boolean column, and a backslash was not
escaped.

* Stop IntentBuilderShellIT racing the shell it is testing

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
#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.

* fix(ide-template): a .glue written before the where: filter keeps rendering (#6813)

GlueGenerator.copy REMOVES an absent key, and Velocity renders the removed
reference as its own literal - so a resolves entry written before the
where: keys existed emitted ${filterSummary} into the generated javadoc
and read as filtered while filtering nothing. The binder now defaults
filters to the empty list and filterSummary to "" - the no-filter shape,
the same migration pattern the create-from's topicSuffix uses. Caught by
ModelGenerationIT's unresolved-reference guard on the pre-existing
fixture entry.

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

---------

Co-authored-by: Nedelcho Delchev <delchevn@gmail.com>
Co-authored-by: Claude Fable 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.

resolves: batches relation + outcome + status into one write - a rejected status move discards the identification and the audit trace

2 participants