feat(intent): phases - an enrichment gets a channel a consumer can bind (#6929) - #6933
Merged
Conversation
…nd (#6929) A value a listener computes AFTER the insert - a moving-average cost, a snapshot column, an external lookup - must be written back event-silently, or it re-fires every onUpdate consumer of a change the user never made. So it published nothing at all, and a declarative consumer of that value had no moment to bind. Bound to onCreate it ran as a SIBLING of the enrichment listener, and two listeners on one topic have no order between them - each is its own durable subscriber, there is no priority anywhere - so a posting could read the row before the cost was written and record a perfectly balanced journal entry for a null amount, with the parse, the generation, the compile and the publish all green. The fix is a channel, not an ordering contract the broker cannot keep. An entity declares the moments it announces: entities: - name: StockMovement phases: [costed] and the Java DAO template emits one announce<Phase>(id, values) per declared phase: updateProperties with the phase's own topic, so the enrichment and its notice ride ONE write into the tenant's outbox and commit together. The generated method is the point - a hand-typed topic string reproduces exactly the silence being removed, while 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. The read half is one kind on the shared axis, event: { onPhase: <Entity>, phase: <name> } - entity in the kind plus 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 removes. Accepted by postings (the driver), notifications, integrations, outbound and an event-driven generates, with the when: guard optional there since a phase already names one moment. Deliberately not widened to a process trigger, a wait or resolves - the same line onTransition drew; their closed key sets reject it loudly. Posting.java.template was the last template hardcoding its channel and now renders ${topicSuffix}/${moment}, with GlueGenerator supplying the legacy two-state defaults so a .glue written before the axis keeps binding exactly what it did. Refused at parse, each because it is otherwise silent: a phase that is not a lower-camel identifier (it becomes a method name and a topic), one named after a platform channel (updated/deleted/transitioned/rekeyed - announcing it would re-fire that channel's consumers), a duplicate, a phase: key on another axis, and a binding naming a phase the entity does not declare. A cross-model source declares its phases in its own model, so the name is not checkable from the consumer's side - the limit a cross-model status nomenclature has. Covered by EntityPhaseIntentTest, GluePhaseAxisTest, EdmIntentGeneratorTest, ModelGenerationIT (the DAO template renders the block) and IntentEngineIT.a_declared_phase_gives_an_enrichment_its_own_channel_a_posting_can_bind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 #6929.
The problem
A value a listener computes after the insert - a moving-average cost, a snapshot column, an external lookup - must be written back event-silently, or it re-fires every
onUpdateconsumer of a change the user never made. So it published nothing at all, and a declarative consumer of that value had no moment to bind.Bound to
onCreateit ran as a sibling of the enrichment listener, and two listeners on one topic have no order between them (each is its own durable subscriber; there is no priority anywhere inListenerClassConsumer). Apostings:block could therefore read the row before the cost was written and record a perfectly balanced journal entry for a null amount - with the parse, the generation, the compile and the publish all green. The silently-wrong class.Neither of the issue's other two candidates was the answer: a global ordering tier is a contract concurrent delivery cannot keep, and documenting the race leaves the automation inexpressible while the failure stays silent.
The shape
An entity declares the moments it announces:
The generated repository gains one
announce<Phase>(id, values)per declared phase -updatePropertieswith the phase's own topic, so the enrichment and its notice ride one write into the tenant's event outbox and commit together:Any glue consumer then binds the announcement instead of the insert:
Notes on the design
announceCostedis a compile error the Problems view shows. An emptyvaluesmap throws rather than no-opping - the write is the announcement.model:, soEventBinding.entity()and the cross-model resolution are untouched.EventBindinggainsON_PHASE/phase()/topicSuffix(Map); the suffix is data here, not a constant of the kind, so the kind-onlytopicSuffix(String)throws foronPhaserather than answering""- silently binding the un-enriched moment is the failure this exists to remove.postings:(the driver),notifications:,integrations:,outbound:and an event-drivengenerates:, with thewhen:guard optional there since a phase already names one moment. Deliberately not widened to a processtrigger:, awaitorresolves:- the same lineonTransitiondrew; their closed key sets reject it loudly.Posting.java.templatewas the last template hardcoding its channel (#if(!$isCreate)-transitioned#end) and now renders${topicSuffix}/${moment}, withGlueGeneratorsupplying the legacy two-state defaults so a.gluewritten before the axis keeps binding exactly what it did.Refused at parse
Each because it is otherwise silent: a phase that is not a lower-camel identifier (it becomes a method name and a topic); one named after a platform channel (
updated/deleted/transitioned/rekeyed- announcing it would re-fire that channel's consumers); a duplicate; aphase:key on another axis; and a binding naming a phase the entity does not declare. A cross-model source declares its phases in its own model, so the name is not checkable from the consumer's side - the limit a cross-model status nomenclature has.Verification
EntityPhaseIntentTest(10),GluePhaseAxisTest(4),EdmIntentGeneratorTest(+1) - engine-intent unit suite green at 898.ModelGenerationIT- the DAO template renders the block with no unresolved reference (simple.modelfixture gained a phase).IntentEngineIT.a_declared_phase_gives_an_enrichment_its_own_channel_a_posting_can_bind- Generate carries the phase onto the.model, the repository exposesannounceCostedon the phase's topic, an entity with no phase generates what it always did, and the posting handler binds-costed. Both ITs green (60 tests).mvn formatter:validateandmvn -P releasejavadoc clean onengine-intentandide-template.Documentation: dirigible.io PR (glue + DSL reference) and the vendor-neutral intentfile.org proposal + site pages ship alongside; the in-repo assistant guides, module README and both
CLAUDE.mdfiles are in this PR.🤖 Generated with Claude Code