diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 6bb1eb83e34..c69c8009937 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -410,7 +410,7 @@ Semantics worth knowing: - **The notify fan-out addresses TWO records, and says which one it means (`attach: recordPrint` + the `record.` scope, #6717).** `notify.forEach` sends one message per row of a related entity, and until now everything in the block resolved against the ROW - right for the payslip case (one document per row), impossible for its mirror: **one** document to **many** recipients, where the rows are only the recipient list (a request for quotation mailed to each invited supplier, an agenda to each participant) and the document belongs to the record they hang off. **`attach: recordPrint`** renders the fan-out's **anchor record** instead of the row - fan-out-only (without one, `attach: print` already renders that record), the ANCHOR is what must be a printable document, and `language:`/`languageFrom:` then read off it, because the render happens **ONCE**: `NotifySupport.printAttachment` pre-renders every expression against the `source` local (`RECORD_LOCAL`) instead of the per-row `entity`, and both fan-out templates (`Send`, `Transition`) call a `renderDocument(source)` before the loop - and not at all when there are no rows, so an empty recipient list costs no render and cannot fail a step for nobody. **Placeholders are explicitly scoped:** a bare path keeps resolving against the ROW and the reserved prefix **`record.`** reaches the anchor (`{record.number}`, ONE field of it - a walk on would need a second load per message and belongs in a field of the record). The recipient may never be record-scoped (the rows ARE the recipients, so it would mail one address once per row), and `record.` outside a fan-out is an error (there the bare placeholder already IS the record's) - implicit mixing is how a message quotes the wrong party with nothing in the output to show it. The generated per-row send method takes the anchor only when a message actually quotes it (the `notifyRecordScoped` glue key). **Alongside it, a `forEach` at a call site that cannot generate one is now REJECTED** - a `schedules[].notify` already runs per matched row and a `notifications[]` entry is about the event record, so both parsed it and silently sent the single per-record message instead (the authored-but-unconsumed class). Covered by `GlueSendDocumentTest` (scoping + the six rejections) and `IntentEmissionCoverageIT` (the `BillFlow` `shareBill` step: one render call, `renderDocument(source)` fed with the anchor's key, the row-scoped recipient, the record-scoped subject). - **A notify body carries LINKS, and the intent never spells a route (`{recordUrl}` / `{inboxUrl}`, #6553).** `{appUrl}` (#6642) supplies only the origin, so "you have an approval waiting" still had to hand-type `{appUrl}/services/web//gen//index.html#/Order/{id}/edit` into the body - the generated app's URL layout, typed into the one artifact that is forbidden to know it, and silently stale the day a template changes it. **`{recordUrl}`** is the deep link to the record the message is about and **`{inboxUrl}`** the link to the recipient's process Inbox; both are reserved tokens resolving to a **bare Java identifier** - `NotificationSupport.Resolver` emits the name and records the use, `NotifySupport.deepLinkFields` carries the only facts the intent owns (`recordUrlEntity` / `recordUrlKeyProperty` - model facts, not paths) plus a `uses*` flag per link, and the **events template DECLARES the local**, composing `/services/web//gen//index.html#///edit` from the parameters it already has. That split is the whole design: it is the template layer that knows the routes (the same reason `Trigger.java.template`, not the generator, assembles `__entityUrl`), so the path-agnostic rule holds and a route change stays a template change. A link is declared only where the message names it (no dead local), and one **fan-out** links the ROW like every other bare path - `{record.}` reaches the anchor for VALUES, but there is deliberately no anchor LINK. All four notify call sites got it from the two shared seams (`buildNotifications`, `buildSchedules`, `notifyFields` -> transitions + sends), so no call site can be forgotten. Covered by `NotificationSupportTest`, `IntentEngineIT` (both links, and the unused one NOT declared) and `IntentEmissionCoverageIT` - where it is also the compile proof: an undeclared local fails the whole client-Java batch and every REST assertion in that gate. - **`transitions:` (top-level) = guarded on-demand status flip (void / cancel / close / reopen).** The missing affordance for a document whose create-time process has ENDED: process triggers fire only on create/update/delete, and `actions:` only opens a custom page - nothing declarative could transition a finished document again. `TransitionIntent` + parser `validateTransitions` (forEntity must declare a `function: EntityStatus` relation; `from:` = non-empty list of allowed source seed ids; `setStatus:` = target seed id not in `from`; optional `when: " ==|!= "` guard over an own field, resolved case-insensitively - the identifier follows the Calc PascalCase convention). Two halves, the `generates` pattern: `TransitionsIntentGenerator` (`@Order(470)`) contributes the per-record button (`-transition-action.extension`/`.js` on `-custom-action`, descriptor carries `endpoint`); `GlueIntentGenerator.buildTransitions` pre-renders EVERYTHING (the `allowedExpr` over an `int currentStatus` local, the `when` guard as a full `Calc.eval(...).compareTo(...)` expression - null field reads as 0) into the `transitions` glue collection -> the pipeline's collection case -> `Transition.java.template`: a `@Controller` at `gen/events//Transition/run` that re-loads the record, returns **409** (via `sdk.http.Response.setStatus`) with the reason when a guard fails, flips ONLY the status column via the targeted `updateProperty` (no `-updated` re-fire - no onUpdate reactions), re-loads, and publishes `-transitioned` - the SAME channel the workflow setters and `generates.sourceStatus` publish, so `postings:` glue observes a manual void exactly like a workflow transition. This realizes the "guarded transition" half of the Tier-2 `lifecycle:` sketch below for the post-process case. Covered by `TransitionsIntentTest` + `GlueTransitionsTest` + the `IntentEmissionCoverageIT` transitions assertions. -- **`generates` + `event:` = the create-from runs itself (#6711).** A create-from was strictly a **user action** - a button on the source view - so "when the source reaches this state, mint the follow-up document" had no expression: a `generates` button plus a process `wait` degraded the automation to a person remembering to click (and an unclicked record parks its instance forever), `posts` is event-driven but emits **flat mapped rows** and cannot reference the freshly created header, and the remaining option was a hand-written `delegate`. A `generates` entry now accepts `event: { onTransition: , when: " == " }` (guard mandatory, status by seeded NAME or id) or `{ onCreate: }` (guard optional - a source with no lifecycle), mirroring `postings`' event axis. **The event says WHEN, never what**: the entity it names must be the one `from:` declares and `model:` is rejected (`fromUses:` owns that), both parser-checked - two ways to name the source could only drift. **At-most-once is derived, not declared twice**: the `map` entry copying the source's PK IS the back-reference, so `GlueIntentGenerator.putGeneratesEvent` derives `backRefProperty` from it and fails loudly when it is missing (the parser catches the local case earlier with the fix in the message; the cross-model source's key field is only known once the owner `.model` resolves). Emission: the existing `Generate.java.template` was refactored so its body is a `create(Integer sourceId)` method carrying the guard (`findAll(eq(backRef, sourceId))` -> return the existing document), and a new **`GenerateOnEvent.java.template`** renders a `MessageHandler` on the source's `-transitioned` (or bare create) topic that re-loads the source, applies the status guard and calls `new Generate().create(id)` - **it carries no mapping of its own**, which is what keeps the two triggers from diverging. The listener is a collection of its own (`generateEvents`, the filtered `generates` list - one file per entry is the collection contract, and a create-from with no event must contribute no listener) but shares `bindGenerate`, so both templates see the same descriptor. `button:` decides the click half: default **true** without an event and **false** with one (declaring an event is how an author says nobody has to click), `button: true` keeps both (they share the one guard), `button: false` with no event is rejected - the action would have no trigger at all. Without a button the class gets no `@Controller`/`@Post` and no custom-action descriptor or i18n label - no endpoint nothing links to. **The template gates the controller half on the NEGATIVE (`#if(!$eventOnly)`)** so a `.glue` written before this key existed keeps rendering the endpoint it always did. `sourceStatus:` composes (the flip cannot re-trigger the create-from - the guard has already claimed the source), and **the flip runs BEFORE the target is saved** while the `-transitioned` publish stays after it: the flip is a lifecycle move the source's repository enforces, so a move the graph does not declare must throw with nothing yet created. Flipping afterwards was the worst possible order - a committed document whose source never transitioned, so every posting and integration keyed on the new status silently never ran, and the back-reference guard then made a redelivery return that document instead of repairing the flip. Now a redelivery re-runs the flip as a no-op (`previous == next`) and goes on to create what is missing. The parser closes the authoring half in the same pass: `validateStatusWritesAgainstLifecycle` covers `generates[].sourceStatus` and every `resolves:` outcome `setStatus` alongside the workflow setters and checks it already covered. (`sourceStatus` takes a seed **id** only - it is not one of the sites `StatusSymbolResolver` rewrites, so a seeded name would not reach the graph.) Covered by `GeneratesIntentTest` + `GlueGeneratesTest` + `ModelGenerationIT`'s glue fixture (the listener renders with no unresolved reference) + `IntentEmissionCoverageIT` at both layers: posting a Slip mints the Voucher **with its computed line** while nobody calls the create-from, and a click afterwards returns that same voucher. **The guard asks state, not existence (#6814).** It first shipped as `findAll(eq(backRef, sourceId))` — pure existence — and a voided target answers that forever: it keeps existing and keeps back-referencing the source, so the source's one-shot slot was consumed at the first creation and nothing that later happened to the target released it. "Void and reissue", an ordinary business flow, was inexpressible. The state half reuses the **`stage:` classification** the report `scope:` already resolves through (`LifecycleStages`), never a second key on the create-from — two vocabularies for "this row no longer counts" could only drift: `putSupersededTarget` reads the LOCAL target's `function: EntityStatus` nomenclature, collects the `cancelled` + `void` seed ids and pre-renders `hasRetiredStatus` / `retiredStatusProperty` / `retiredStatusCondition`, and the template turns the `if (!existing.isEmpty())` into a loop that steps over a retired candidate. Draft and live targets still block, so redelivery idempotence is untouched; the voided document is KEPT (both stay on the audit trail) rather than replaced in place. A target with no lifecycle keeps the existence-only guard silently (nothing can retire it); one that HAS a lifecycle whose nomenclature nobody classified keeps it with a **generation warning** — that is the silent combination, where the guard looks state-aware and is not. A cross-model target's seeds live in its owner model, so no classification is resolvable here (the report scope has the same limit). `mode: append` (#6800) is NOT this: it is the ABSENCE of a guard, so every qualifying event mints another document. +- **`generates` + `event:` = the create-from runs itself (#6711).** A create-from was strictly a **user action** - a button on the source view - so "when the source reaches this state, mint the follow-up document" had no expression: a `generates` button plus a process `wait` degraded the automation to a person remembering to click (and an unclicked record parks its instance forever), `posts` is event-driven but emits **flat mapped rows** and cannot reference the freshly created header, and the remaining option was a hand-written `delegate`. A `generates` entry now accepts `event: { onTransition: , when: " == " }` (guard mandatory, status by seeded NAME or id) or `{ onCreate: }` (guard optional - a source with no lifecycle), mirroring `postings`' event axis. **The event says WHEN, never what**: the entity it names must be the one `from:` declares and `model:` is rejected (`fromUses:` owns that), both parser-checked - two ways to name the source could only drift. **At-most-once is derived, not declared twice**: the `map` entry copying the source's PK IS the back-reference, so `GlueIntentGenerator.putGeneratesEvent` derives `backRefProperty` from it and fails loudly when it is missing (the parser catches the local case earlier with the fix in the message; the cross-model source's key field is only known once the owner `.model` resolves). Emission: the existing `Generate.java.template` was refactored so its body is a `create(Integer sourceId)` method carrying the guard (`findAll(eq(backRef, sourceId))` -> return the existing document), and a new **`GenerateOnEvent.java.template`** renders a `MessageHandler` on the source's `-transitioned` (or bare create) topic that re-loads the source, applies the status guard and calls `new Generate().create(id)` - **it carries no mapping of its own**, which is what keeps the two triggers from diverging. The listener is a collection of its own (`generateEvents`, the filtered `generates` list - one file per entry is the collection contract, and a create-from with no event must contribute no listener) but shares `bindGenerate`, so both templates see the same descriptor. `button:` decides the click half: default **true** without an event and **false** with one (declaring an event is how an author says nobody has to click), `button: true` keeps both (they share the one guard), `button: false` with no event is rejected - the action would have no trigger at all. Without a button the class gets no `@Controller`/`@Post` and no custom-action descriptor or i18n label - no endpoint nothing links to. **The template gates the controller half on the NEGATIVE (`#if(!$eventOnly)`)** so a `.glue` written before this key existed keeps rendering the endpoint it always did. `sourceStatus:` composes (the flip cannot re-trigger the create-from - the guard has already claimed the source), and **the flip runs BEFORE the target is saved** while the `-transitioned` publish stays after it: the flip is a lifecycle move the source's repository enforces, so a move the graph does not declare must throw with nothing yet created. Flipping afterwards was the worst possible order - a committed document whose source never transitioned, so every posting and integration keyed on the new status silently never ran, and the back-reference guard then made a redelivery return that document instead of repairing the flip. Now a redelivery re-runs the flip as a no-op (`previous == next`) and goes on to create what is missing. The parser closes the authoring half in the same pass: `validateStatusWritesAgainstLifecycle` covers `generates[].sourceStatus` and every `resolves:` outcome `setStatus` alongside the workflow setters and checks it already covered. (`sourceStatus` takes a seed **id** only - it is not one of the sites `StatusSymbolResolver` rewrites, so a seeded name would not reach the graph.) Covered by `GeneratesIntentTest` + `GlueGeneratesTest` + `ModelGenerationIT`'s glue fixture (the listener renders with no unresolved reference) + `IntentEmissionCoverageIT` at both layers: posting a Slip mints the Voucher **with its computed line** while nobody calls the create-from, and a click afterwards returns that same voucher. **The guard asks state, not existence (#6814).** It first shipped as `findAll(eq(backRef, sourceId))` — pure existence — and a voided target answers that forever: it keeps existing and keeps back-referencing the source, so the source's one-shot slot was consumed at the first creation and nothing that later happened to the target released it. "Void and reissue", an ordinary business flow, was inexpressible. The state half reuses the **`stage:` classification** the report `scope:` already resolves through (`LifecycleStages`), never a second key on the create-from — two vocabularies for "this row no longer counts" could only drift: `putSupersededTarget` reads the LOCAL target's `function: EntityStatus` nomenclature, collects the `cancelled` + `void` seed ids and pre-renders `hasRetiredStatus` / `retiredStatusProperty` / `retiredStatusCondition`, and the template turns the `if (!existing.isEmpty())` into a loop that steps over a retired candidate. Draft and live targets still block, so redelivery idempotence is untouched; the voided document is KEPT (both stay on the audit trail) rather than replaced in place. A target with no lifecycle keeps the existence-only guard silently (nothing can retire it); one that HAS a lifecycle whose nomenclature nobody classified keeps it with a **generation warning** — that is the silent combination, where the guard looks state-aware and is not. A cross-model target's seeds live in its owner model, so no classification is resolvable here (the report scope has the same limit). `mode: append` (#6800) is NOT this: it is the ABSENCE of a guard, so every qualifying event mints another document. **And the guard belongs to ONE rule (`validateIdempotencyGuardOwnership`).** It asks whether the source already has a row through the back-reference and cannot tell which rule wrote it, so two event-driven `generates:`/`posts:` rules sharing a target AND a back-reference silently divide into a winner and a loser - the first to fire claims the source forever, the other returns that row instead of writing, for that source and every future one. It parsed, generated and compiled, and the loser read as a rule whose condition never matched; disjoint `when:` guards do not help, because existence decides it, not the condition. Both halves of the key are static in the model, so it is a parse error now. Two `mode: append` rules are exempt (neither reads the other's rows - the exemption #6800 created), but append PLUS guarded is not: the appended rows carry the back-reference, which is all the guarded rule's lookup needs to be satisfied forever. The check spans both constructs, since a `posts:` row satisfies a `generates:` guard just as well. Covered by `CollidingGuardIntentTest`. - **`generates.event:` on the process-step axis + an opt-in `mode: append` (#6800).** Two narrow extensions that together close "on event E, append a derived row" - a `LogEntry` per process step, a protocol line per transition - which **no** event-driven construct could express: every candidate either writes into an existing row (`postings`/`rollups`/`aggregates`), or was at-most-once by construction (`generates` + `event:`), so the shape needed a hand-written listener under `custom/` or an `outbound` -> `inbound` loopback. (1) The `event:` map now also takes the **step axis** `onStepReached`/`onStepCompleted: { process, step }` that `notifications`/`integrations`/`outbound` already bind to (#6537) - so a create-from can hang off a moment in a flow rather than a status write, which is also the one route around a state write that publishes nothing. Its extra narrowing over the other consumers: the process's `trigger:` entity must EQUAL `from:` (the step event is delivered as a message about the process's trigger record, and that record is what the create-from reads by id), and the source must be local - a process and its steps belong to the model that declares them, so a `fromUses:` source is rejected. `when:` stays optional on this axis: the step IS the moment. (2) `mode: once` (**default** - unchanged behaviour, byte-identical output) vs `mode: append`, which drops the existing-target lookup in `Generate.java.template` (`#if($hasEvent && !$appendMode)`, the single guard site, inside the shared `create()`), so every delivery creates a row. **The back-reference stays REQUIRED in both modes** - the dedup key under `once`, the row's provenance under `append` (a log row nothing points back at cannot be read); the parser message names both roles. Emission: `putGeneratesEvent` gained `isStep`/`stepProcess`/`stepName`/`topicSuffix`/`appendMode`, and the listener's `destination()` now renders `${topicSuffix}` instead of branching on `isCreate` (`""` for a create, `-transitioned` for a transition, `-step---reached|completed` for a step - same strings as before). **`StepEventSupport.boundEvents` had to learn about `generates`**, not just `GlueIntentGenerator`: `emitters()` reads that list, so without it a moment whose ONLY consumer is a create-from got no `JavaDelegate` emitter and the listener bound a topic nothing published to. **What `append` is NOT:** a state-aware guard. It is the ABSENCE of one - a redelivery appends a duplicate (the step topic is published after commit, not transactionally with the step, the same at-least-once contract `outbound` states), and it is the wrong answer to "I voided the target and cannot regenerate it" (that is #6814's stage-aware predicate on `mode: once`). Two `append` rules sharing a target AND a back-reference are **legal by design** (each records a different moment) - which is why #6813's parse-time collision diagnostic must be scoped to `once` pairs only. Covered by `GeneratesIntentTest` (step binding accepted; unknown process/step, non-eventable kind, trigger-entity mismatch, cross-model source, a mode with no trigger, an unknown mode, a missing back-reference under append, a prompt on an appending create-from all rejected) + `GlueGeneratesTest` (the step topic, `appendMode`, the emitter for a generates-only moment, and both lifecycle axes unchanged) + `IntentEmissionCoverageIT.assertGeneratesStepAxisRuntime` - one shipment whose all-serviceTask flow appends TWO log rows from two moments sharing the same back-reference, a click appending a THIRD, and an at-most-once sibling on the same moment minting exactly one summary that a later click hands back. - **`prompt:` on a `generates` action = a declared input form before the create (#6685).** The gap it closes: `transitions:` writes but takes no input and `generates:` creates but declares every value up front, so an action that collects the two answers the source cannot derive (which payment, how much) had to be a hand-written page. It reaches a post-issue child on an IMMUTABLE document too, because per-record action buttons are deliberately NOT gated on mutability (that is why Void works) - the **action-shaped sibling of `locksWithMaster: false`** (#6700), which reopens the child's own panel: the panel is the affordance for ordinary data entry, a prompted action for a guided create over mostly-derived values. `prompt:` entries name fields / to-one relations of the TARGET; parser (`validateGeneratesPrompt`): local target only, target must declare a composition to-one relation to `forEntity` (that guarantees the generated detail registration the dialog renders from), scope `entity`, no `timestamp` fields, no overlap with `map`/`defaults` (one writer), no duplicates, and **no `event:`** (an event-driven create-from runs with nobody there to answer the form - which is also why the prompted values ride the ENDPOINT path only: `run()` checks the required ones and passes the map into `create(sourceId, values)`, while the event listener's `create(sourceId)` signature is untouched). Server half: `promptFields` in the glue (PascalCase prop + required + a pre-rendered `Object raw` -> field-type conversion), `Generate.java.template` takes `values` in the Request, 400s on a missing required input BEFORE anything is written, and sets prompted values after map/defaults - the save still goes through the target's repository so numbering/checks/events fire. Client half: the descriptor carries `prompt` + `promptEntity` (authored names ONLY - control types, lookup URLs and `dependsOn` metadata are resolved AT RUNTIME from `App.detailsFor(view)`'s edit-columns registration, so the intent layer never references template routes); the shared `customActions` store opens an input dialog instead of the plain confirm (`openPrompt`/`promptRun` + a mini dependsOn cascade seeded from the clicked master id - the invoice's Customer chain narrows the payment list, `valueFrom` defaults the amount), degrading to the confirm when the registration is absent (the shared shell). Dialog markup rides in all five shells wrapped in the `customActionPrompt` Alpine component so the Velocity shell stays `$store`-free. Covered by the `GeneratesIntentTest` prompt tests + `GlueGeneratesTest.promptFieldsRenderTypedConversions` + the `IntentEmissionCoverageIT` prompted-generates assertions (emission + 400 + value-reaches-the-row). - **`history: true` on an entity = the shadow change trail (#6715).** `audit: true` keeps only the LAST writer and time, in four columns of the row itself; a regulated domain has to answer *what changed, from what to what, by whom, when* for every write, and that was hand-written or skipped. `history: true` gives the entity a sibling **`_HISTORY`** shadow table (the `_LANG` pattern: emitted by `application.schema.template` off the EDM `history="true"` attribute `EdmIntentGenerator` writes) shaped `GUID, Id, Operation, Property, OldValue, NewValue, ChangedAt, ChangedBy, Source`, and the generated repository appends **one row per property whose value actually changed** on every write path it owns — create (`null -> value`), update, `updateWithoutEvent`, the targeted `updateProperty`/`updateProperties` (whose override is now gated on `history` too — the base ones write the column directly and would leave no trace), `recalculate` (it deliberately calls the BASE targeted write, so it records for itself) and delete (`value -> null`). The writer is the SDK `org.eclipse.dirigible.sdk.db.History` (api-modules-java, `Translator`'s sibling: plain JDBC, quoted exact-case identifiers, values stringified and truncated at 4000). Four decisions worth keeping: **(1) `Source` is `USER` vs `SYSTEM`** — the user-facing paths record USER, every targeted/system write records SYSTEM, because once a roll-up total and a person's edit land in the same column nothing downstream can tell them apart. **(2) The before-image is read through `super.findById`, never the class's own override** — on a multilingual entity the override overlays the caller's language, and a translated value diffed against the stored one reports an edit nobody made. **(3) Decimals are compared by `compareTo`, not `equals`** — a recomputed `2.0` against a stored `2.00` is the same amount, and treating it as a change fills the trail with noise. **(4) The tracked set excludes the primary key and the audit columns** (they say exactly what the row itself says). Read-only end to end: `GET /{id}/history` on the entity's own controller (404 on an unknown row — never an empty trail a caller could read as "nothing happened"), rendered as a **History** card in the manage form's and the document's right sidebar; there is no create/update/delete verb on the shadow table anywhere, which is what makes it append-only *by construction* rather than by policy. Two interactions are deliberately specified: the **scoped surfaces get no history endpoint at all** (a `my`/`partner` controller strips `sensitive:` fields from its responses, so handing it a trail carrying those fields' old and new values would leak exactly what the scoping hides — when a scoped panel is wanted it arrives WITH its per-property filter, in one PR), and **CSVIM seeds bypass the repository**, so seeded rows have no history (correct: nobody wrote them). The append happens after the entity write, on its own connection — `JavaEntityStore` commits every operation in its own transaction, so there is no enclosing transaction to join; a failure to append is logged at ERROR and does not fail the already-committed business write. `IntentEmissionCoverageIT` covers all of it (`Entry` for the USER/SYSTEM runtime split, `Claim` for the audit-exclusion and the absent personal endpoint). diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 6f3784e4207..4188ae32160 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -39,6 +39,7 @@ import org.eclipse.dirigible.components.intent.model.NumberIntent; import org.eclipse.dirigible.components.intent.model.CalendarIntent; import org.eclipse.dirigible.components.intent.model.CheckIntent; +import org.eclipse.dirigible.components.intent.model.PostIntent; import org.eclipse.dirigible.components.intent.model.PostingIntent; import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; @@ -313,6 +314,7 @@ private static void validate(IntentModel model, List issues) { validateExpansions(model, issues); validateSettlements(model, issues); validateResolves(model, entityNames, issues); + validateIdempotencyGuardOwnership(model, issues); if (!issues.isEmpty()) { throw new IntentValidationException(issues); } @@ -5358,6 +5360,113 @@ private static void validatePostings(IntentModel model, Set usesAliases, } } + /** + * One rule per at-most-once guard: no two event-driven {@code generates:} / {@code posts:} rules + * may share a target entity AND the back-reference relation their guard queries. + * + *

+ * The generated guard is {@code findAll(eq(, sourceId))} - it asks whether the + * source already has a row through that relation, and is indifferent to WHICH rule wrote it. So two + * rules sharing both silently divide into a winner and a loser: whichever fires first claims the + * source forever, and the other returns that row instead of writing anything - for this source and + * for every future one. Nothing shows up at runtime; the loser looks like a rule whose condition + * never matched. And disjoint {@code when} guards do not save it, because the collision is decided + * by the target's EXISTENCE, not by the condition that led to it. + * + *

+ * Both are static in the model, so this is an authoring-time message instead. A + * {@code mode: append} rule has no guard of its own, so two of them cannot collide - but the rows + * it appends still carry the back-reference, which is enough to permanently satisfy a guarded + * sibling's lookup, so that pairing is reported too. + * + * @param model the model + * @param issues the collected issues + */ + private static void validateIdempotencyGuardOwnership(IntentModel model, List issues) { + Map byName = new HashMap<>(); + for (EntityIntent entity : model.getEntities()) { + if (entity.getName() != null) { + byName.put(entity.getName(), entity); + } + } + Map> claims = new LinkedHashMap<>(); + for (GeneratesIntent g : model.getGenerates()) { + // A cross-model target or source is resolved from the owner's .model at generation time, so + // neither its key nor its back-reference is knowable here. + if (!g.isEventDriven() || g.getTo() == null || g.getUses() != null || g.isCrossModelSource()) { + continue; + } + EntityIntent source = g.getFrom() == null ? null : byName.get(g.getFrom()); + String sourceKey = source == null ? null : IntentEntities.keyFieldName(source); + String backReference = sourceKey == null ? null : backReferenceOf(g.getMap(), sourceKey); + if (backReference == null) { + // An event-driven rule with no back-reference in its map is already refused, with a + // message about the missing guard rather than about sharing one. + continue; + } + claim(claims, g.getTo(), backReference, "generates [" + g.getName() + "]", !g.isAppendMode()); + } + for (PostIntent p : model.getPosts()) { + if (p.getInto() == null || p.getIdempotentBy() == null || p.getIdempotentBy() + .isBlank()) { + continue; + } + claim(claims, p.getInto(), p.getIdempotentBy(), "posts [" + p.getName() + "]", true); + } + for (List sharing : claims.values()) { + reportGuardCollision(sharing, issues); + } + } + + /** The target's to-one back to the source: the {@code map:} key whose value is the source's key. */ + private static String backReferenceOf(Map map, String sourceKey) { + for (Map.Entry mapping : map.entrySet()) { + if (mapping.getValue() != null && mapping.getValue() + .equalsIgnoreCase(sourceKey)) { + return mapping.getKey(); + } + } + return null; + } + + private static void claim(Map> claims, String target, String backReference, String subject, boolean guarded) { + String key = target.toLowerCase(Locale.ROOT) + "#" + backReference.toLowerCase(Locale.ROOT); + claims.computeIfAbsent(key, k -> new ArrayList<>()) + .add(new GuardClaim(subject, target, backReference, guarded)); + } + + /** + * Reports one shared guard. Two rules that both append are left alone - neither reads the other's + * rows - so a collision needs at least one guarded claimant, and the message names it as the loser + * because it is the one that stops writing. + */ + private static void reportGuardCollision(List sharing, List issues) { + if (sharing.size() < 2) { + return; + } + List guarded = sharing.stream() + .filter(GuardClaim::guarded) + .toList(); + if (guarded.isEmpty()) { + return; + } + GuardClaim first = guarded.get(0); + String others = sharing.stream() + .filter(claim -> claim != first) + .map(claim -> claim.subject() + (claim.guarded() ? "" : " (mode: append)")) + .collect(java.util.stream.Collectors.joining(", ")); + issues.add(first.subject() + " shares its at-most-once guard on [" + first.target() + "] through back-reference [" + + first.backReference() + "] with " + others + + " - that guard asks whether the source already has a row through that relation and cannot tell which rule wrote it," + + " so whichever fires first claims the source permanently and the rest silently never write again." + + " Give them separate back-references or separate targets, or declare `mode: append` on every one of them if each" + + " event should add a row."); + } + + /** One rule's claim on a (target, back-reference) guard. */ + private record GuardClaim(String subject, String target, String backReference, boolean guarded) { + } + private static void validateGenerates(IntentModel model, Set entityNames, Set usesAliases, List issues) { Map byName = new HashMap<>(); for (EntityIntent entity : model.getEntities()) { diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index d0ba5aa503d..220f50e67fc 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -1540,6 +1540,17 @@ generates: create-from looks for a target that already back-references this source and returns it instead, so an event redelivery - or a click afterwards - is a no-op rather than a duplicate document. Under `mode: append` it is the appended row's provenance. Authoring an event without it is rejected. +- **That guard belongs to ONE rule.** It asks whether the source already has a row through the + back-reference, and cannot tell which rule wrote it - so two event-driven rules sharing a target AND a + back-reference divide into a winner and a loser: whichever fires first claims the source forever, and + the other hands back that row instead of writing, for that source and every future one. **Disjoint + `when:` guards do not save it** (the collision is decided by the target's EXISTENCE, not the condition + that led to it), and nothing shows at runtime - the loser looks like a rule whose condition never + matched. This is now refused at parse time. To write two kinds of row about one source, give them + separate back-references (two to-one relations to the source) or separate targets; declare + `mode: append` on **every** one of them if each event should add a row. The same applies to `posts:` + through its `idempotentBy:`, and across the two constructs - a `posts:` row satisfies a `generates:` + guard just as well. - **`mode:` - the cardinality.** `once` (default, today's behaviour) creates at most one target per source. `append` creates one **per delivered event**: the "a row per step, a row per transition" shape - a log entry, a protocol line, an activity record. diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/CollidingGuardIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/CollidingGuardIntentTest.java new file mode 100644 index 00000000000..8ba50d98d37 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/CollidingGuardIntentTest.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.parser; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Two event-driven rules may not share one at-most-once guard. + * + *

+ * The guard the templates emit is {@code findAll(eq(, sourceId))}: it asks whether + * the source already has a row through that relation and cannot tell which rule wrote it. Two rules + * sharing a target AND a back-reference therefore divide silently into a winner and a loser - the + * first to fire claims the source forever, the other hands back that row instead of writing, for + * this source and every future one. It parses, generates and compiles; the loser simply looks like + * a rule whose condition never matched. + */ +class CollidingGuardIntentTest { + + /** + * The reported shape: two transition-driven log rules onto one target through one back-reference. + */ + private static final String YAML = """ + name: fines + entities: + - name: FineStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Fine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal, precision: 12, scale: 2 } + relations: + - { name: status, kind: manyToOne, to: FineStatus, function: EntityStatus, init: 1 } + - name: FineLog + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 200 } + relations: + - { name: fine, kind: manyToOne, to: Fine } + seeds: + - name: fineStatuses + entity: FineStatus + rows: + - { id: 1, name: NEW } + - { id: 2, name: UNRESOLVED } + - { id: 3, name: DECLARED } + 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 } + """; + + @Test + void rejectsTwoGeneratesSharingATargetAndABackReference() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(YAML)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(issue -> issue.contains("shares its at-most-once guard on [FineLog] through back-reference [fine]") + && issue.contains("log-")), + "got: " + ex.getIssues()); + } + + /** + * Disjoint {@code when} guards do not make it safe, and that is the trap: the author reads two + * mutually exclusive conditions and expects two independent rules. The collision is decided by the + * target's EXISTENCE, so the second rule no-ops on a Fine whose condition it matched perfectly. + */ + @Test + void rejectsEvenWhenTheConditionsAreDisjoint() { + assertThrows(IntentValidationException.class, () -> IntentParser.parse(YAML)); + } + + /** + * The same model with a second entity, a second back-reference and a button variant, assembled from + * one place: the fixtures differ only in the block under test, and string surgery on a text block + * is how these went wrong the first time. + */ + private static String model(String extraEntities, String logRelations, String generates) { + return """ + name: fines + entities: + - name: FineStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Fine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal, precision: 12, scale: 2 } + relations: + - { name: status, kind: manyToOne, to: FineStatus, function: EntityStatus, init: 1 } + %s - name: FineLog + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 200 } + relations: + %s + seeds: + - name: fineStatuses + entity: FineStatus + rows: + - { id: 1, name: NEW } + - { id: 2, name: UNRESOLVED } + - { id: 3, name: DECLARED } + generates: + %s + """.formatted(extraEntities, logRelations, generates); + } + + private static final String ONE_BACK_REF = " - { name: fine, kind: manyToOne, to: Fine }"; + + /** Separate back-references are separate guards, so two rules onto one target are fine. */ + @Test + void acceptsTwoRulesWithSeparateBackReferences() { + String yaml = model("", ONE_BACK_REF + "\n - { name: declared, kind: manyToOne, to: Fine }", """ + - 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: { declared: id }"""); + assertDoesNotThrow(() -> IntentParser.parse(yaml)); + } + + /** A different target is a different guard. */ + @Test + void acceptsTwoRulesOntoDifferentTargets() { + String yaml = model(""" + - name: FineNote + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 200 } + relations: + - { name: fine, kind: manyToOne, to: Fine } + """, ONE_BACK_REF, """ + - name: log-identification-failed + from: Fine + to: FineLog + event: { onTransition: Fine, when: "Status == UNRESOLVED" } + map: { fine: id } + - name: log-declaration-created + from: Fine + to: FineNote + event: { onTransition: Fine, when: "Status == DECLARED" } + map: { fine: id }"""); + assertDoesNotThrow(() -> IntentParser.parse(yaml)); + } + + /** + * Two appending rules cannot collide - neither reads the other's rows, because {@code mode: append} + * is the ABSENCE of the guard. This is the escape hatch the error message points at. + */ + @Test + void acceptsTwoAppendingRulesSharingEverything() { + String yaml = model("", ONE_BACK_REF, """ + - name: log-identification-failed + from: Fine + to: FineLog + event: { onTransition: Fine, when: "Status == UNRESOLVED", mode: append } + map: { fine: id } + - name: log-declaration-created + from: Fine + to: FineLog + event: { onTransition: Fine, when: "Status == DECLARED", mode: append } + map: { fine: id }"""); + assertDoesNotThrow(() -> IntentParser.parse(yaml)); + } + + /** + * One appending and one guarded rule DO collide, which #6800 does not cover: the rows the appender + * writes carry the back-reference, and that is all the guarded rule's lookup needs to be satisfied + * forever. + */ + @Test + void rejectsAnAppendingRuleSharingWithAGuardedOne() { + String yaml = model("", ONE_BACK_REF, """ + - name: log-identification-failed + from: Fine + to: FineLog + event: { onTransition: Fine, when: "Status == UNRESOLVED", mode: append } + map: { fine: id } + - name: log-declaration-created + from: Fine + to: FineLog + event: { onTransition: Fine, when: "Status == DECLARED" } + map: { fine: id }"""); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(issue -> issue.contains("(mode: append)")), + "the message should name the appending rule as such, got: " + ex.getIssues()); + } + + /** + * A non-event-driven create-from is a BUTTON: a person decides when it runs, and the template emits + * no guard at all, so two of them share nothing. + */ + @Test + void acceptsTwoButtonRulesSharingEverything() { + String yaml = model("", ONE_BACK_REF, """ + - name: log-identification-failed + from: Fine + to: FineLog + map: { fine: id } + - name: log-declaration-created + from: Fine + to: FineLog + map: { fine: id }"""); + assertDoesNotThrow(() -> IntentParser.parse(yaml)); + } +} diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentBuilderShellIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentBuilderShellIT.java index 6dd6723658a..fb00be0bb4d 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentBuilderShellIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentBuilderShellIT.java @@ -136,7 +136,7 @@ void builderShell_loads_and_bootstraps() { // In the calling thread: Selenide binds its WebDriver per-thread, so a probe on // Awaitility's own poll thread finds no driver at all. .pollInSameThread() - .atMost(Duration.ofSeconds(30)) + .atMost(Duration.ofSeconds(60)) .pollInterval(Duration.ofMillis(250)) .untilAsserted(() -> { Object bootstrapped = Selenide.executeJavaScript("return !!(window.Alpine && Alpine.store('intent')" @@ -145,11 +145,13 @@ void builderShell_loads_and_bootstraps() { "The Builder shell failed to bootstrap its stores and renderer."); }); - // The first-run surface is there: the invitation and the composer. + // The first-run surface is there: the invitation and the composer. Explicit deadlines, like every + // other wait in this class - Selenide's default is 4s, and Alpine renders these AFTER the stores + // the poll above waits for, so the default races the boot rather than testing it. Selenide.$(By.xpath("//*[contains(text(), 'Describe the application you need')]")) - .shouldBe(Condition.visible); + .shouldBe(Condition.visible, Duration.ofSeconds(30)); Selenide.$(By.id("builder-input")) - .shouldBe(Condition.visible); + .shouldBe(Condition.visible, Duration.ofSeconds(30)); // The assistant IS configured here (the stub), so the shell must not claim otherwise. Selenide.$(By.xpath("//*[contains(text(), 'is not configured on this instance')]")) @@ -173,8 +175,9 @@ void an_unconfigured_assistant_is_announced_before_the_user_types() { void a_conversation_becomes_a_published_application() { openBuilder(); + // The composer appears once the shell has booted, which openBuilder() does not wait for. Selenide.$(By.id("builder-input")) - .shouldBe(Condition.visible) + .shouldBe(Condition.visible, Duration.ofSeconds(60)) .setValue("I need an expense tracker."); Selenide.$(By.cssSelector("button[aria-label='Send']")) .shouldBe(Condition.enabled)