feat(intent): a create-from may be triggered by a source event, not only a button (#6711) - #6731
Merged
Merged
Conversation
…nly a button (#6711) `generates:` 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 fine arrives by webhook and, once its responsible person is identified (a status transition), a declaration document must be created from the fine and that person. The three shapes that could express that were all wrong: a button plus a process `wait` degrades the automation to a person remembering to click (and an unclicked record parks its instance forever), `posts` is event-driven and idempotent but emits FLAT MAPPED ROWS and cannot reference the header it would have to create first, and the rest was hand-written code. A `generates` entry now accepts `event: { onTransition: <from>, when: "<Status> == <status>" }` (guard mandatory, the status by seeded name or id) or `{ onCreate: <from> }` for a source with no lifecycle - `postings`' event axis, applied to a whole document. The event says WHEN, never what: the entity it names must be the one `from:` declares, and `model:` is rejected because `fromUses:` owns that. Both are parser-checked - two ways to name one source can only drift. At-most-once is DERIVED, not declared twice. The `map` entry copying the source's key IS the target's back-reference, so the glue derives the guard from it and a declaration without one fails loudly (locally at parse with the fix in the message; for a cross-model source at generation, where the owner's key field first resolves). Without that guard an event redelivery mints a duplicate document. A create-from with NO event keeps no guard at all: producing several targets from one source by clicking twice is a legitimate manual act. Emission keeps one creation path. `Generate.java.template`'s body became a `create(Integer sourceId)` method carrying the guard, and the new `GenerateOnEvent.java.template` renders a `MessageHandler` on the source's `-transitioned` (or bare create) topic that re-reads the source - the payload is as-of the event - applies the status guard and calls that method. It carries no mapping of its own, which is what keeps the two triggers from diverging. The listener is its own glue collection (`generateEvents`, the filtered `generates` list, since one file per entry is the collection contract and an event-less create-from must contribute no listener) sharing one binder with `generates`. `button:` decides the click half: true by default without an event, false with one (declaring an event is how an author says nobody has to click), `button: true` keeps both, and `button: false` with no event is rejected - the action would have no trigger at all. With no button the class gets no `@Controller`, no custom-action descriptor and no 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. Tests: `GeneratesIntentTest` (the six validation rules), `GlueGeneratesTest` (trigger, guard, derived back-reference, the loud cross-model failure), `ModelGenerationIT`'s glue fixture (the listener renders with no unresolved reference), and `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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #6711.
The gap
generates:(create-from) was strictly a user action — a button on the source view. "When the source reaches this state, mint the follow-up document" had no expression. The motivating case: a fine arrives through aninbound:webhook, and once its responsible person is identified (a status transition), a declaration document must be created from the fine and that person. Everything that could express it was wrong in a specific way:generatesbutton plus a processwaitstep — the automation degrades to a person remembering to click, and an unclicked record parks its process instance forever;posts— event-driven and idempotent, but it emits flat mapped rows and cannot mint a header-and-items document (the rows need the id of the header it would have to create first);delegateincustom/.What this adds
onTransitionbinds the source's-transitionedtopic with a mandatory status guard (named or numbered);onCreatebinds its create topic for a source with no lifecycle, guard optional —postings' event axis, applied to a whole document.The event says WHEN, never what. The entity it names must be the one
from:declares, andmodel:is rejected becausefromUses:owns that. Both are parser-checked: two ways to name one source can only drift apart.At-most-once is derived, not declared twice. The
mapentry copying the source's key is the target's back-reference, so the glue derives the guard from it — and a declaration without one fails loudly (at parse locally, with the fix in the message; at generation for a cross-model source, where the owner's key field first resolves). Without it an event redelivery mints a duplicate document. A create-from with no event keeps no guard at all: producing several targets from one source by clicking twice is a legitimate manual act, and that behaviour is unchanged.One creation path for both triggers.
Generate.java.template's body became acreate(Integer sourceId)method that carries the guard, and the newGenerateOnEvent.java.templaterenders aMessageHandlerwhich re-reads the source (the payload is as-of the event and lacks anything a later step wrote), applies the status guard, and calls that method. It carries no mapping of its own — that is what keeps a button and an event from diverging. The listener is its own glue collection (generateEvents, the filteredgenerateslist — one file per entry is the collection contract, and an event-less create-from must contribute no listener) sharing one binder withgenerates.button:is true by default without an event and false with one (declaring an event is how an author says nobody has to click);button: truekeeps both, andbutton: falsewith no event is rejected — the action would have no trigger at all. With no button the generated class gets no@Controller/@Post, no custom-action descriptor and no i18n label: no endpoint nothing links to.Backward compatibility. The template gates the controller half on the negative (
#if(!$eventOnly)), so regenerating code from a.gluewritten before this key existed still renders the endpoint it always did.Verification
GeneratesIntentTest— the six validation rules (wrong source entity, repeated model, unguardedonTransition, missing back-reference,button: falsewith no event, and the happy shape dropping its button).GlueGeneratesTest— trigger kind, guard property/value from a named status, the derived back-reference, and the loud cross-model failure.ModelGenerationIT— its glue fixture gained an event-driven entry plus thegenerateEventscollection, so the new listener is rendered and checked for surviving${…}references (the failure mode fix(generation): the glue templates get every reference they render #6723 paid for; the fixture's other two entries deliberately keep the old key set).IntentEmissionCoverageIT— both layers. Emission: the listener binds-Slip-transitioned, guards on the status the seeded name resolved to, delegates tonew VoucherFromSlipGenerate().create(, and carries no mapping; the create-from keeps its endpoint (button: true) and gained the back-reference guard. Runtime: nobody calls the create-from — a slip is posted through its transition and the voucher has to appear by itself, with its computed line (so the whole create-from ran, not an empty header) — and a click afterwards returns that same voucher.IntentEngineIT46/46 (two assertions updated for the renamedcreate(sourceId)parameter),engine-intent+ide-templateunit tests 439/439,formatter:validateand the release-profile javadoc clean.Documentation
🤖 Generated with Claude Code