Skip to content

feat(intent): phases - an enrichment gets a channel a consumer can bind (#6929) - #6933

Merged
delchev merged 2 commits into
masterfrom
intent-derived-event-6929
Aug 24, 2026
Merged

feat(intent): phases - an enrichment gets a channel a consumer can bind (#6929)#6933
delchev merged 2 commits into
masterfrom
intent-derived-event-6929

Conversation

@delchev

@delchev delchev commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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 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 in ListenerClassConsumer). A postings: 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:

entities:
  - name: StockMovement
    phases: [costed]

The generated repository gains 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 event outbox and commit together:

new StockMovementRepository().announceCosted(movement.Id, java.util.Map.of("CostValue", cost));

Any glue consumer then binds the announcement instead of the insert:

postings:
  - name: cogsPosting
    event: { onPhase: StockMovement, phase: costed }
    creates: JournalEntry
    backReference: StockMovement
    rule: { entity: PostingRule, match: { documentType: "Goods Issue" } }
    items:
      - { Account: rule(costOfSalesAccount), debit: "CostValue" }
      - { Account: rule(inventoryAccount),   credit: "CostValue" }

Notes on the design

  • The generated method is the point. A hand-typed topic string reproduces exactly the silence being removed; 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.
  • Entity in the kind, phase in 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:, 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.
  • Byte-identical output for a model that declares nothing. Posting.java.template was the last template hardcoding its channel (#if(!$isCreate)-transitioned#end) 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.

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.model fixture 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 exposes announceCosted on 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:validate and mvn -P release javadoc clean on engine-intent and ide-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.md files are in this PR.

🤖 Generated with Claude Code

…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>
@delchev
delchev merged commit 0cd1d4f into master Aug 24, 2026
3 checks passed
@delchev
delchev deleted the intent-derived-event-6929 branch August 24, 2026 19:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant