From 4e79eb258c8c5d883698695c0055323d988ef922 Mon Sep 17 00:00:00 2001 From: Nedelcho Delchev Date: Mon, 5 Oct 2026 11:36:31 +0300 Subject: [PATCH] A button create-from takes sourceStatusOnRetire too The reopen was refused on a create-from with no `event:`, on the reasoning that a button carries no at-most-once guard, so nothing blocks a replacement and the button already reissues. That holds only without a completion hook. With `sourceStatus:` declared, the hook flips the source off the status the button is offered from, and the implied `fromStatus` deny of exactly that status (#7068) answers the second click with a 409 - so the proforma stays INVOICED, `immutableWhen` locks it, and nothing in the model can move it back. The hook blocks the button exactly as the at-most-once guard blocks the trigger, and the declared inverse is the same move back in both shapes. The parser refusal is gone. `putSupersededTarget` no longer returns early for a button shape that declares the reopen: it emits the reopen half and leaves `hasRetiredStatus` false, the stage-aware step-over belonging to a guard this shape does not have. Its unclassified-nomenclature warning now says which of the two is at stake. What stays refused is a reopen with no `sourceStatus` at all - there is nothing to invert - and every other combination that could never fire. Fixes #7647 Co-Authored-By: Claude Opus 5 (1M context) --- components/engine/engine-intent/CLAUDE.md | 2 +- .../intent/generator/GlueIntentGenerator.java | 25 ++++++++++++---- .../intent/parser/IntentParser.java | 18 +++++------- .../main/resources/intent-assistant-guide.md | 16 ++++++---- .../intent/generator/GlueGeneratesTest.java | 26 +++++++++++++++++ .../intent/parser/GeneratesIntentTest.java | 29 +++++++++++++++---- 6 files changed, 88 insertions(+), 28 deletions(-) diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 055e696ff02..2b22a44f44f 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -474,7 +474,7 @@ Semantics worth knowing: - **`notify: { outcome: }` + the `onNotifyFailed` axis = a delivery is observable (#7023).** A notify block is fail-soft by construction - the status flip is the transition endpoint's contract, a schedule tick is a batch, and neither may break because a mailbox is unreachable - and it was therefore also **silent**: the only trace of a mail that never left was `MailClient`'s own log line. The invoice sat in SENT with a send method recorded, the person who pressed the button got a green toast, nothing was stamped on the record, and no construct in the DSL could react; the user learned about it from the customer, weeks later, while the dunning schedule kept mailing reminders through the same broken sender just as quietly. The **silently-incomplete** class, and the fix is not to stop being fail-soft (that turns every mail misconfiguration into a failed business transaction) but to make the outcome a first-class fact. **One key, three surfaces.** `outcome: ` names a string field of the record the message is ABOUT - the ROW inside a fan-out, since that is the record carrying the recipient - and every send stamps it `sent` or `failed: ` through the **targeted** `updateProperties` primitive (no `-updated` re-fire, no clobbering a concurrent edit), truncated in the GENERATED CODE to the length the parser holds the field to (>= 64) because a column that truncates does it in the database where nothing reports what was cut. (1) The record carries it, so "which of last night's 400 reminders did not go out" is a list filter rather than a log search. (2) A `transitions[]` endpoint that mails answers **`{ record, notify: { status, message } }`** instead of the bare record - the shape is decided at generation time, and `TransitionsIntentGenerator` puts `notifies` on the action descriptor so the shared `customActions` store reads the right one rather than sniffing the payload; a `failed` outcome becomes a **warning** toast naming the reason, and `skipped` (nobody to mail) is its own status because reporting it as a failure trains people to ignore the warning that matters. (3) A FAILED stamp carries **`-notifyFailed`** into the outbox with its own write, so the trace and its announcement commit together, and `onNotifyFailed` joins the glue event axis (`notifications:`, `integrations:`, `outbound:`) **and a process `trigger:`** - which is what makes "open a task when the invoice mail bounces" expressible with no Java. Only the FAILURE has a channel: a delivery that worked is the normal path, and announcing it would hand every reaction a second copy of an event it already has. **Resend is deliberately NOT a new key**: route the failure to a status of its own and declare the way back as an ordinary `transitions[]` button carrying the same notify block - the retry then inherits the same guards, the same audit trail and the same stamp, where a bespoke resend affordance would inherit none of them. **The stamp never throws** (a record OF an outcome must not become a second, louder failure - and on the per-row paths it would abort the rows still to send); a `checks:` gate refusing it is exactly such a case, and note it goes through the REPOSITORY, so `immutableWhen` - enforced in the REST controller's `requireMutable` - cannot stop a document from recording why its own send failed after the send froze it. **The schedule fan-out was worse than the issue described** and is fixed in the same PR: its send `throw`ew from inside the row loop, so the FIRST unreachable mailbox aborted the tick and every row after it was silently never mailed, every night. It is now per-row fail-soft, with a logger the job template did not have at all, a per-row error naming the row's key, and a `mailed [m] of [n], no recipient [k], failed [f]` summary. Five kind enumerations had to agree (the `onTransition` precedent): `EventBinding.KINDS` + `topicSuffix`, `IntentParser.EVENT_KINDS`, `UnknownKeyValidator.GLUE_EVENT_KEYS` + `ProcessIntent#trigger`, and `StatusSymbolResolver.triggerEntityOf` - that last one runs on the RAW YAML tree before the typed mapping and therefore cannot read `EventBinding`, and missing it fails in the worst way (the trigger entity stops resolving, so a status NAME anywhere in that process is refused with a message about the nomenclature). Deliberately NOT widened to `ProcessWaitSupport.EVENT_KINDS` (a parked instance resumed by a bounce is not a moment anyone asked for; its closed set rejects it loudly). The new glue keys (`notifyOutcome*`) are defaulted in `GlueGenerator.bindNotifyOutcome` for all four call sites, the `filters` migration pattern. Covered by `GlueNotifyOutcomeTest` + `GlueNotifyFailedAxisTest`, a transition entry added to `ModelGenerationIT`'s glue fixture (the notification and generate-schedule entries stay in the legacy shape so the defaults are exercised), and `IntentEmissionCoverageIT` at both layers - the emitted stamp/topic/response tokens, and then the published app answering `{record.Status, notify.status: failed}` with `failed: ...` on the record. - **`phases:` (entity) + `onPhase` (the glue event axis) = the enrichment channel (#6929).** An enrichment a listener computes on create - a moving-average cost pool, a snapshot column, an external lookup - must be written back WITHOUT an event or it re-fires every onUpdate consumer of a change the user never made; so it publishes nothing at all, and a declarative consumer of the enriched value had **no moment to bind**. Bound to `onCreate` it raced the listener - the order of two listeners on one topic is undefined by construction (`ListenerClassConsumer` gives each subscription its own durable subscriber; there is no priority anywhere) - and read the un-enriched row: a balanced-looking journal entry posted for a null amount, with the parse, the generation, the compile and the publish all green. The **silently-wrong** class, which is why neither "document the race" nor a global ordering tier was the answer: the first leaves the automation unexpressible and the second is a contract the messaging layer cannot keep. What was missing was a CHANNEL, and the platform already had the primitive for one - `updateProperties(id, values, topic)`, whose event is recorded in the tenant's outbox inside the write's own transaction (the same mechanism `transitions:` and `sourceStatusOnRetire:` ride). So: an entity declares the moments it announces (`phases: [costed]`), `EdmIntentGenerator` carries them onto the `.model` entity map as a comma-joined `phases` (the `lifecycleEdges` convention), and the Java DAO template emits one **`announce(id, values)`** per declared phase - `updateProperties` with the phase's own topic, so the values and the notice commit together and no consumer can observe one without the other. The method is the point: a hand-typed topic string would reproduce exactly the silence the axis removes, whereas a mistyped `announceCosted` is a compile error the Problems view shows. An **empty values map throws** rather than no-opping - the write IS the announcement, and a phase nothing wrote is a moment that did not happen. The read half is one new kind on the shared axis: `event: { onPhase: , phase: }`, entity-in-the-kind + a sibling key exactly like `model:`, so `EventBinding.entity()` and the cross-model resolution are untouched. `EventBinding` gains `ON_PHASE`/`phase()`/**`topicSuffix(Map)`** - the suffix is DATA here, not a constant of the kind, so the kind-only `topicSuffix(String)` **throws** for `onPhase` rather than answering `""` (silently binding the un-enriched moment is the failure this exists to remove). Accepted by `postings:` (the driver), `notifications:`, `integrations:`, `outbound:` and an event-driven `generates:`; the `when:` guard is optional on it, the phase already being one moment, which is also why `validatePostings` had to stop treating "not a create" as "requires a status guard". Deliberately NOT widened to a process `trigger:` or a `wait` (their own closed key sets reject it loudly) nor to `resolves:`, whose vocabulary was already narrower - the same line `onTransition` drew. `Posting.java.template` was the one template still hardcoding its channel (`#if(!$isCreate)-transitioned#end`); it now renders `${topicSuffix}` with an `isCreate` **fallback**, so a `.glue` written before the axis existed keeps binding exactly what it did (the `$eventOnly` precedent). Parse-time refusals, each because it is otherwise silent: a phase that is not a lower-camel identifier (it becomes a method name and a topic), a phase named after a platform channel (`updated`/`deleted`/`transitioned`/`rekeyed` - announcing it would re-fire that channel's consumers), a duplicate, a `phase:` key on any other axis (dropped, and the consumer keeps racing), and a binding naming a phase the entity does not declare (a topic nothing publishes to). A cross-model source's phases live in its own model and cannot be checked from here - the cross-model status nomenclature has the same limit. Covered by `EntityPhaseIntentTest` + `GluePhaseAxisTest` + `EdmIntentGeneratorTest` + `IntentEngineIT.a_declared_phase_gives_an_enrichment_its_own_channel_a_posting_can_bind`. - **`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` - and its inverse `sourceStatusOnRetire` - are now among the sites `StatusSymbolResolver` rewrites, so both take a seeded NAME or an id; they were id-only until #6868, and leaving the pair asymmetric would have been a wart of its own.) 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`. **And a freed slot needs something able to refill it: `sourceStatusOnRetire:` (#6868).** #6850 unlocked the door and, for the `sourceStatus:` combination, left nobody able to knock: the completion hook flips the source OFF the status its own `event.when` qualifies on - deliberately, so the guard-claimed source stops matching - and the usual lifecycle graph declares no edge back, so the retired target frees the slot and no qualifying `-transitioned` is ever published again. An event-only rule (`button: false`, the shape #6711 introduced the axis for) had NO reissue path at all. The fix is the hook's **declared inverse** on the same rule, so the reissue is the ORDINARY path rather than a special one: `GenerateReopen.java.template` (collection `generateReopens`, the same filtered descriptors and the same `bindGenerate`) renders a `MessageHandler` on the TARGET's `-transitioned` topic that re-loads the target, tests the SAME retiring-`stage:` set the guard uses (`putSupersededTarget` pre-renders the disjunction twice, once per local - one resolution, so guard and reopen cannot disagree about what retired means), reads the source through the same `backRefProperty`, and flips it with `updateProperties(id, {status: reopen}, "-transitioned")` - the notice riding the write into the outbox, as `transitions:` does, so flip and announcement commit together and `GenerateOnEvent` cannot miss the moment that frees it. Then the trigger re-fires, the guard steps over the retired document, and the replacement is minted by machinery that already existed. **Idempotence is by STATE, not a marker column**: it acts only while the source still stands at this rule's own `sourceStatus` AND no target of that source still counts - the create-from's guard asked from this end, over the same classification, which is the half that actually closes redelivery. Delivery is at-least-once, so a redelivered void arrives AFTER the replacement exists; the standing-status test alone passes there (the reissue put the source back at `sourceStatus`) and would re-open a source with a live target against it - `create()` then returns at its own guard, so nothing would ever put the status back. The free-slot scan makes the reopen run exactly when a creation would be allowed through. The two directions the issue also weighed were rejected: re-delivering the source's qualifying event from the target's retirement fabricates a transition that did not happen (and, since the source stands at the POST status, would not even match the guard without bypassing it) and re-fires every other consumer of that topic; documenting-and-warning leaves the automation unexpressible. `validateGeneratesReopen` refuses every combination that could never fire - no `sourceStatus:`, the same status, `mode: append` (no guard, no slot), **no `event:`** (the emission is gated on event-driven, so a button-only reopen would be authored and silently dropped - and there the button IS the reissue), a cross-model target (its stages are classified in the owner model), a target with no lifecycle, an unclassified nomenclature - and `validateStatusWritesAgainstLifecycle` pins the source's graph to the ONE edge `sourceStatus` -> reopen, the exact place the source stands when the retirement arrives (it now takes `edges` as well as `reachable` for that). Covered by `GeneratesIntentTest` (nine cases, all of which parse silently without the validator), `GlueGeneratesTest` (the emitted inverse, and byte-identical output without the key) and `IntentEngineIT.generates_reopen_returns_the_source_when_its_target_is_retired`. Deliberately NOT added to `IntentEmissionCoverageIT`'s `voucher-from-slip`: its #6814 test reissues by POSTing the endpoint, and an automatic reissue racing that POST could mint a third voucher and make a green test flaky. +- **`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` - and its inverse `sourceStatusOnRetire` - are now among the sites `StatusSymbolResolver` rewrites, so both take a seeded NAME or an id; they were id-only until #6868, and leaving the pair asymmetric would have been a wart of its own.) 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`. **And a freed slot needs something able to refill it: `sourceStatusOnRetire:` (#6868).** #6850 unlocked the door and, for the `sourceStatus:` combination, left nobody able to knock: the completion hook flips the source OFF the status its own `event.when` qualifies on - deliberately, so the guard-claimed source stops matching - and the usual lifecycle graph declares no edge back, so the retired target frees the slot and no qualifying `-transitioned` is ever published again. An event-only rule (`button: false`, the shape #6711 introduced the axis for) had NO reissue path at all. The fix is the hook's **declared inverse** on the same rule, so the reissue is the ORDINARY path rather than a special one: `GenerateReopen.java.template` (collection `generateReopens`, the same filtered descriptors and the same `bindGenerate`) renders a `MessageHandler` on the TARGET's `-transitioned` topic that re-loads the target, tests the SAME retiring-`stage:` set the guard uses (`putSupersededTarget` pre-renders the disjunction twice, once per local - one resolution, so guard and reopen cannot disagree about what retired means), reads the source through the same `backRefProperty`, and flips it with `updateProperties(id, {status: reopen}, "-transitioned")` - the notice riding the write into the outbox, as `transitions:` does, so flip and announcement commit together and `GenerateOnEvent` cannot miss the moment that frees it. Then the trigger re-fires, the guard steps over the retired document, and the replacement is minted by machinery that already existed. **Idempotence is by STATE, not a marker column**: it acts only while the source still stands at this rule's own `sourceStatus` AND no target of that source still counts - the create-from's guard asked from this end, over the same classification, which is the half that actually closes redelivery. Delivery is at-least-once, so a redelivered void arrives AFTER the replacement exists; the standing-status test alone passes there (the reissue put the source back at `sourceStatus`) and would re-open a source with a live target against it - `create()` then returns at its own guard, so nothing would ever put the status back. The free-slot scan makes the reopen run exactly when a creation would be allowed through. The two directions the issue also weighed were rejected: re-delivering the source's qualifying event from the target's retirement fabricates a transition that did not happen (and, since the source stands at the POST status, would not even match the guard without bypassing it) and re-fires every other consumer of that topic; documenting-and-warning leaves the automation unexpressible. `validateGeneratesReopen` refuses every combination that could never fire - no `sourceStatus:`, the same status, `mode: append` (no guard, no slot), a cross-model target (its stages are classified in the owner model), a target with no lifecycle, an unclassified nomenclature - and `validateStatusWritesAgainstLifecycle` pins the source's graph to the ONE edge `sourceStatus` -> reopen, the exact place the source stands when the retirement arrives (it now takes `edges` as well as `reachable` for that). Covered by `GeneratesIntentTest` (nine cases, all of which parse silently without the validator), `GlueGeneratesTest` (the emitted inverse, and byte-identical output without the key) **The `no event:` refusal was wrong and is gone (#7647).** It read "a button carries no guard, so nothing blocks a replacement - the button already reissues", which holds only without a completion hook: a declared `sourceStatus` flips the source off the status the button is offered from, and the implied `fromStatus` deny (#7068) answers the second click with a 409, so a button create-from's source is as stuck as a guarded one and the declared inverse is the same move back. `putSupersededTarget` no longer returns early for the shape; it emits the reopen half and leaves `hasRetiredStatus` false, the stage-aware step-over belonging to an at-most-once guard that does not exist here. What stays refused is a reopen with no `sourceStatus` at all, there being nothing to invert. and `IntentEngineIT.generates_reopen_returns_the_source_when_its_target_is_retired`. Deliberately NOT added to `IntentEmissionCoverageIT`'s `voucher-from-slip`: its #6814 test reissues by POSTing the endpoint, and an automatic reissue racing that POST could mint a third voucher and make a green test flaky. - **`fromStatus:` (and the guard `sourceStatus:` implies) = a create-from is not offered twice (#7068).** A `generates:` was unconditional. With a `sourceStatus:` completion hook it flipped the source once the target existed - and then went on offering the same button on the flipped record and answering the same endpoint 200, so a second click minted a **second document**: a ProformaInvoice already INVOICED produced SalesInvoice 10 and then SalesInvoice 11, both in the customer's hands. The hook DECLARED what "already done" looks like; nothing consulted it - the authored-but-unconsumed class, and the reason the gap was invisible (both halves of the model read correctly). The fix is one rule resolved once, `GeneratesGuardSupport`, feeding **both** halves of the action: the generated `run()` refuses with **409** before anything is created (the pre-check load is on the guarded path only), and the contributed action descriptor carries the same `guard: { property, allowed | blocked }` so the shared `customActions` store stops OFFERING the click on a record it would refuse - the store's `getActions(view, type, record)` gained an optional record and the four entity-action sites pass the one they already have (`selected`, and the document form). Two shapes, one guard: `fromStatus: [...]` is the explicit allow-list - the `from:` of a `transitions:` entry, spelled differently ONLY because `from:` on a create-from already names the source ENTITY, which is why the issue's suggested `from:` could not be taken literally; absent it, a declared `sourceStatus` IMPLIES the deny-list of exactly that status, which is the minimal refusal and needs no authoring change for the models that already carry the defect. The guard is on the **click**, deliberately: an event-driven create-from already carries the at-most-once back-reference guard (or asked for a row per event with `mode: append`) and qualifies its moment with `event.when`, so `fromStatus` on an event-only rule is **refused at parse** rather than silently ignored. Also refused: a `page` scope (no record to read a status from), a source with no `function: EntityStatus` relation (nothing to read), and an allow-list containing the `sourceStatus` the action itself writes (it re-opens exactly the duplicate the guard removes). The statuses are symbolic like every other status site (`StatusSymbolResolver.rewriteGenerates` now resolves `fromStatus` too). Emission is gated on a new `hasStatusGuard` boolean, so a `.glue` written before the key existed renders the unguarded `run()` it always did. Covered by `GeneratesIntentTest` + `GlueGeneratesTest` + `IntentEngineIT.generates_completion_hook_flips_the_source_via_targeted_update` (the 409 branch, its ordering before the create, and the descriptor's guard). - **A mutual cross-model `generates` cycle bootstraps through a declared pass, not a hand-strip ([#6539](https://github.com/eclipse-dirigible/dirigible/issues/6539)).** A cross-model create-from is resolved against the target's real `.model` (`CrossModelSupport.resolve`, workspace-or-registry, loud on absence), which has no first project when the pair is MUTUAL - the canonical opportunity -> quotation funnel, where A mints a document into B while B holds a foreign key back to A: A cannot generate because B's `.model` does not exist, and B cannot because A's does not. The workaround was to strip A's `generates` block, generate A, generate B, restore the block, regenerate A - five steps, four of them editing the intent to say something it does not mean. Now the pass itself takes `bootstrap=true` (`POST /services/ide/intent/generate?...&bootstrap=true` -> `IntentGenerationService.generate(..., bootstrap)` -> `IntentGenerationContext.isBootstrap()`), which skips exactly the create-from whose owner model is not there yet and names it in `warnings`: bootstrap here, generate the dependency, regenerate here normally. **Absence is the whole trigger, and it is asked as a separate question** - `CrossModelSupport.ownerModelExists` tests whether the owner's `.model` FILE is readable from either source, deliberately narrower than `resolve` succeeding, so an owner that IS there but declares no such entity keeps failing loudly in a bootstrap pass too ("the dependency is not generated yet" and "the reference is wrong" want opposite answers, and the second is the one a bootstrap flag could hide forever). **Nothing else is relaxed**: a cross-model RELATION never degrades to a guess - its table, key column and FK type would have to be invented and the emitted schema would be wrong rather than incomplete - and lazy resolution was rejected for the same reason (a `generates` glue entry needs the target's perspective + PK at glue-generation time, and the convention fallback is exactly the dead-dropdown guess `CrossModelSupport` exists to refuse). **The default pass teaches the escape**: `buildGenerates` asks the absence question in both modes and, outside a bootstrap, throws its own `BootstrapRequiredException extends IntentValidationException` naming the cycle and the three-step recipe - so the endpoint can answer the ordinary 422 plus `bootstrap: true`, the one fact a caller cannot read out of the text, and the Intent Editor offers "Generate anyway" as a retry instead of leaving the developer to edit the document. Covered by `GlueGeneratesBootstrapTest` (skip + warning, the loud default with the recipe, and the present-but-wrong reference staying fatal under bootstrap) and `IntentEngineIT.mutual_cross_model_generates_bootstraps`. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index d00e65d0e3d..271e3dd4dbd 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -1482,9 +1482,18 @@ private static void putSupersededTarget(GeneratesIntent g, Map e // retired target to release - and warning about an unclassified nomenclature there would be // noise about a guard that does not exist. A reopen is refused on that shape by the parser, so // there is nothing to emit for it here either. - if (!g.isEventDriven() || g.isAppendMode()) { + if (g.isAppendMode()) { return; } + // The at-most-once guard and its stage-aware step-over belong to the EVENT trigger. A button + // create-from has neither - but it may still declare the reopen (#7647): its completion hook + // flips the source off the status the button is offered from, and the implied fromStatus deny + // (#7068) then refuses the second click, so the source is as stuck as a guarded one and the + // declared inverse is the same move back. Only the reopen half is emitted for that shape. + boolean guarded = g.isEventDriven(); + if (!guarded && !g.hasReopen()) { + return; // a plain button create-from: no guard to make stage-aware, no reopen to emit + } RelationIntent status = LifecycleStages.statusRelation(target); if (status == null || status.getTo() == null) { return; @@ -1493,10 +1502,14 @@ private static void putSupersededTarget(GeneratesIntent g, Map e List retired = new ArrayList<>(stages.getOrDefault(LifecycleStages.CANCELLED, List.of())); retired.addAll(stages.getOrDefault(LifecycleStages.VOID, List.of())); if (retired.isEmpty()) { - String warning = "generates [" + g.getName() + "] is event-driven and its target [" + g.getTo() - + "] carries a lifecycle status [" + status.getName() + "], but no seed row of [" + status.getTo() - + "] is classified with `stage:` - the at-most-once guard can only ask whether a [" + g.getTo() - + "] exists, so a cancelled or voided one blocks its replacement forever. Classify the seed rows of [" + status.getTo() + String warning = "generates [" + g.getName() + "] " + (guarded ? "is event-driven" : "declares sourceStatusOnRetire") + + " and its target [" + g.getTo() + "] carries a lifecycle status [" + status.getName() + "], but no seed row of [" + + status.getTo() + "] is classified with `stage:` - " + + (guarded + ? "the at-most-once guard can only ask whether a [" + g.getTo() + + "] exists, so a cancelled or voided one blocks its replacement forever." + : "nothing here can tell a retired [" + g.getTo() + "] from a live one, so the source is never returned.") + + " Classify the seed rows of [" + status.getTo() + "] with `stage:` (draft/live/cancelled/void) so a retired target can be superseded."; LOGGER.warn(LoggedValue.of(warning)); if (context != null) { @@ -1505,7 +1518,7 @@ private static void putSupersededTarget(GeneratesIntent g, Map e return; } String property = IntentNaming.pascalCase(status.getName()); - e.put("hasRetiredStatus", true); + e.put("hasRetiredStatus", guarded); e.put("retiredStatusProperty", property); // Rendered against the template's loop variable: a retired candidate is stepped over, the first // one that is not is this source's document. 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 b1619ff2295..fc77052c652 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 @@ -9311,16 +9311,14 @@ private static void validateGeneratesReopen(GeneratesIntent g, String name, Map< + " another " + g.getTo() + "; drop the reopen, or use mode: once"); return; } - if (!g.isEventDriven()) { - // A create-from with no event carries no guard at all, so nothing ever blocks a second - // creation: the button IS the reissue. There is no slot to free and no trigger to re-fire, - // which is why the glue emits no reopen listener for this shape - and an authored key that - // generates nothing is the silence this whole construct exists to refuse. - issues.add(subject + " declares sourceStatusOnRetire but has no event: - a create-from triggered only by a button carries" - + " no at-most-once guard, so nothing blocks a replacement and the button already reissues. The reopen exists to" - + " re-fire an EVENT trigger; declare event: or drop the key"); - return; - } + // A BUTTON create-from used to be refused here, on the reasoning that nothing blocks a second + // click so the button already reissues. That holds only without a completion hook: a declared + // `sourceStatus` flips the source off the status the button is offered from, and the implied + // `fromStatus` deny of exactly that status (#7068) then refuses the second click with a 409 - + // so the source is stuck, `immutableWhen` locks it, and nothing can move it back (#7647). The + // hook blocks the button exactly as the guard blocks the trigger, and the declared inverse is + // the move back in both shapes. What stays refused is a button create-from with no hook at all, + // which the sourceStatus check above has already returned on. if (crossModel) { issues.add(subject + " cannot reopen for a cross-model target (uses [" + g.getUses() + "]) - what RETIRES a [" + g.getTo() + "] is the `stage:` classification of its status nomenclature, seeded in the owner model and not resolvable here;" 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 da39c04adcf..369acb4c572 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 @@ -2073,7 +2073,9 @@ generates: sourceStatus: 3 # optional completion hook: the SOURCE's EntityStatus seed id # after the target is created (e.g. proforma -> INVOICED) sourceStatusOnRetire: 2 # optional INVERSE of that hook: where the SOURCE returns when the - # target is retired (cancelled/void) - see "void and reissue" + # target is retired (cancelled/void) - see "void and reissue". + # Takes a button create-from too, where the hook is what sticks + # the source (#7647) ``` **Which source rows become lines (`items: where:` / `refuse:`).** The mirror form clones every row @@ -2367,7 +2369,8 @@ generates: slot; but where `sourceStatus:` is declared nothing could refill it. The completion hook moved the source OFF the status its own trigger qualifies on - deliberately - and the ordinary lifecycle graph declares no edge back, so no qualifying event is ever published again: an event-only create-from had - no reissue path at all, and only a shared `button: true` could raise the replacement. + no reissue path at all, and only a shared `button: true` could raise the replacement - and a + BUTTON-only one is no better off, the implied `fromStatus` deny refusing the second click (#7647). `sourceStatusOnRetire:` declares the move back, so the reissue becomes the ORDINARY path: ```yaml @@ -2388,9 +2391,12 @@ generates: end, which is what makes it idempotent with no marker column: a redelivered retirement arriving after the replacement exists finds a live target and does nothing. - It requires an `event:` to re-fire (a button-only create-from carries no guard, so nothing blocks a - replacement - the button already reissues) and `sourceStatus:` to invert, and must name a DIFFERENT - status; the target must be local, with a nomenclature that classifies a retiring `stage:` (a + It requires `sourceStatus:` to invert and must name a DIFFERENT status. It does NOT require an + `event:` (#7647): a BUTTON create-from carries no at-most-once guard, but its completion hook flips + the source off the status the button is offered from and the implied `fromStatus` deny then refuses + the second click, so the source is just as stuck and the declared inverse is the only move back - the + reopen is emitted for that shape too, without the guard's stage-aware step-over, which belongs to a + guard it does not have. the target must be local, with a nomenclature that classifies a retiring `stage:` (a cross-model target is seeded in its owner model, so nothing here can recognise its retirement - keep `button: true` and reissue by hand); `mode: append` is refused (no guard, so no slot to free); and when the source declares a `lifecycle:`, the graph must declare the edge from `sourceStatus` back to diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesTest.java index 64a73e599bb..3e8bf64f6e9 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesTest.java @@ -241,6 +241,15 @@ class GlueGeneratesTest { sourceStatusOnRetire: 2 """); + /** + * The same reopen on a BUTTON create-from (#7647): no {@code event:}, so no at-most-once guard to + * make stage-aware - but the completion hook still flips the fine off the status the button is + * offered from, so the reopen is the only thing that can bring it back. + */ + private static final String BUTTON_REOPEN_YAML = REOPEN_YAML.replace(""" + event: { onTransition: Fine, when: "Status == POSTED" } + """, ""); + @SuppressWarnings("unchecked") @Test void rendersHeaderAssignmentsItemsAndKeys() { @@ -1226,6 +1235,23 @@ void aDeclaredReopenEmitsTheInverseOfTheCompletionHook() { * create-from written before the key existed regenerates byte-identical output and contributes no * listener. */ + /** + * A button create-from emits the reopen but not the stage-aware step-over (#7647): the guard the + * step-over belongs to is the event trigger's, and this shape has none. The source is stuck all the + * same - the hook flips it off the status the button is offered from, and the implied fromStatus + * deny refuses the second click - so the inverse is exactly what is missing. + */ + @Test + void aButtonCreateFromEmitsTheReopenWithoutTheStageAwareGuard() { + Map g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(BUTTON_REOPEN_YAML)) + .get(0); + + assertEquals(true, g.get("hasReopen")); + assertEquals("2", g.get("reopenStatusValue")); + assertEquals("target.State == 3 || target.State == 4", g.get("reopenRetiredCondition")); + assertEquals(false, g.get("hasRetiredStatus"), "there is no at-most-once guard here to step a retired target over"); + } + @Test void withoutTheKeyNoReopenIsEmitted() { Map g = GlueIntentGenerator.buildGeneratesForTest(IntentParser.parse(RETIRING_YAML)) diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/GeneratesIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/GeneratesIntentTest.java index f43e737b036..10e7a37c1f8 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/GeneratesIntentTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/GeneratesIntentTest.java @@ -1230,19 +1230,36 @@ void rejectsAReopenOnAnAppendingCreateFrom() { } /** - * A button-only create-from carries no guard at all, so nothing blocks a replacement - the button - * IS the reissue, and there is no trigger for a reopen to re-fire. The glue emits no listener for - * that shape, so accepting the key would authorise something that generates nothing. + * A BUTTON create-from takes the reopen too (#7647). It carries no at-most-once guard, which is why + * this was once refused as a key that generates nothing - but the completion hook makes the source + * just as stuck: {@code sourceStatus} flips it off the status the button is offered from, the + * implied {@code fromStatus} deny (#7068) refuses the second click, and nothing else can move it + * back. The hook blocks the button exactly as the guard blocks the trigger. */ @Test - void rejectsAReopenWithoutAnEventTrigger() { - IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """ + void acceptsAReopenOnAButtonCreateFromWithACompletionHook() { + IntentModel model = IntentParser.parse(GENERATES_REOPEN_HEAD + """ sourceStatus: DECLARED sourceStatusOnRetire: IDENTIFIED + """); + + GeneratesIntent generates = model.getGenerates() + .get(0); + // The seeded names are resolved to their ids before the typed model is built. + assertEquals(3, generates.getSourceStatus()); + assertEquals(2, generates.getSourceStatusOnRetire()); + assertFalse(generates.isEventDriven(), "the shape under test is the button one"); + } + + /** With no completion hook there is nothing to invert, button or not. */ + @Test + void rejectsAReopenOnAButtonCreateFromWithoutACompletionHook() { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(GENERATES_REOPEN_HEAD + """ + sourceStatusOnRetire: IDENTIFIED """)); assertTrue(ex.getIssues() .stream() - .anyMatch(i -> i.contains("no event:") && i.contains("the button already reissues")), + .anyMatch(i -> i.contains("declares sourceStatusOnRetire but no sourceStatus")), "got: " + ex.getIssues()); }