Skip to content

feat(intent): a create-from may be triggered by a source event, not only a button (#6711) - #6731

Merged
delchev merged 1 commit into
masterfrom
feat/generates-event-trigger
Aug 14, 2026
Merged

feat(intent): a create-from may be triggered by a source event, not only a button (#6711)#6731
delchev merged 1 commit into
masterfrom
feat/generates-event-trigger

Conversation

@delchev

@delchev delchev commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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 an inbound: 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:

  • a generates button plus a process wait step — 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);
  • a hand-written delegate in custom/.

What this adds

generates:
  - name: declaration-from-fine
    from: Fine
    to: Declaration
    event: { onTransition: Fine, when: "Status == IDENTIFIED" }   # or { onCreate: Fine }
    map:
      Fine: id                       # REQUIRED with an event — the back-reference, i.e. the guard
      Vehicle: Vehicle
    defaults: { declaredAt: now }
    items:                           # a whole document — header AND items
      - { name: "Fine {number}", amount: Amount }

onTransition binds the source's -transitioned topic with a mandatory status guard (named or numbered); onCreate binds 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, and model: is rejected because fromUses: 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 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 (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 a create(Integer sourceId) method that carries the guard, and the new GenerateOnEvent.java.template renders a MessageHandler which 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 filtered generates list — one file per entry is the collection contract, and an event-less create-from must contribute no listener) sharing one binder with generates.

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: true keeps both, and button: false with 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 .glue written before this key existed still renders the endpoint it always did.

Verification

  • GeneratesIntentTest — the six validation rules (wrong source entity, repeated model, unguarded onTransition, missing back-reference, button: false with 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 the generateEvents collection, 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 to new 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.
  • IntentEngineIT 46/46 (two assertions updated for the renamed create(sourceId) parameter), engine-intent + ide-template unit tests 439/439, formatter:validate and the release-profile javadoc clean.

Documentation

🤖 Generated with Claude Code

…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.
@delchev
delchev merged commit 9176395 into master Aug 14, 2026
9 checks passed
@delchev
delchev deleted the feat/generates-event-trigger branch August 14, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

intent: event-driven generates - create a document from a source document on an entity event, not only a button

1 participant