Skip to content

intent: a refused generation pass leaves nothing behind (#7227) - #7318

Merged
delchev merged 1 commit into
masterfrom
fix/7227-intent-validation-prepass
Sep 11, 2026
Merged

intent: a refused generation pass leaves nothing behind (#7227)#7318
delchev merged 1 commit into
masterfrom
fix/7227-intent-validation-prepass

Conversation

@ThuF

@ThuF ThuF commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #7227.

The claim was the wrong way round

#7118 (#7093) moved the "form field references a field of a cross-model to-one" check from the parser to generation, and justified it by saying that skipping the resolver "would leave the BPMN with a service task pointing at a handler nothing generated". But the check sits at @Order(350), after EdmIntentGenerator (200) and BpmnIntentGenerator (300) have written the .edm/.model and the .bpmn — and the BPMN generator reads the lookup-free resolvers(model) overload, whose convention fallback yields the resolver task whether or not the owner declares the field. So the 422 left in the workspace exactly the artefact the PR said it avoided, where the same authoring mistake used to be refused at parse with nothing written.

Reproduced, red-first: without the fix, a refused pass over the reported document leaves Send.bpmn, sales-invoices.edm, sales-invoices.model and sales-invoices.settings behind, and on a regeneration it rewrites the last good .bpmn (3246 bytes against 3233 — the extra bytes being the ResolveCustomerMobile service task).

It was never specific to that check either: any IntentValidationException raised at generation left the earlier generators' output behind, past the stale-output scrub the loop never reaches.

A refused pass now writes nothing

Of the issue's three options this takes the second (scrub the pass's outputs) rather than a pre-pass for the one check: it covers the whole class instead of one member of it, and needs no second run of the generators.

IntentGenerationContext journals the state each write replaced — the previous content, or null for a file it created — recorded on the first change of each file only (the rollback restores the state the pass started from, not what an earlier generator of the same pass left) and never for a byte-identical write, which has nothing to undo. rollbackWrittenFiles() puts that back, and IntentGenerationService calls it before the exception leaves as the 422.

Consequences worth knowing:

  • The scaffolded .settings is rolled back with it, since that too goes through writeModelFile — a first Generate that is refused no longer leaves a settings file for a project that has never generated.
  • BootstrapRequiredException is an IntentValidationException, so a refused bootstrap pass is rolled back as well. That is the right reading: its recipe is to re-run with bootstrap=true, and mutual_cross_model_generates_bootstraps still passes end to end.
  • A restore that itself fails is logged and the rest still run — the caller is on its way to reporting the authoring error, and one unrestorable file must not hide it.
  • Deliberately not rolled back: the model-to-code recipes. They run after the generators, the model files are on disk by then, and each entry already reports its own outcome — a partial result there is the caller's to see, which is the existing contract.

The two smaller leftovers from #7118

  • targetProject dropped from the resolver descriptor. The generated resolver imports the owner's Entity/Repository out of its generation folder (derived from targetModel) and builds no URL, so neither GlueGenerator.bindResolver nor Resolver.java.template ever read it; ProcessResolverSupport.CrossModelTarget no longer carries a project either. A cross-model hop link does need one (GlueGenerator builds a controller URL from it) — a resolver does not. GlueFormCrossModelHopTest now asserts the key is absent rather than asserting its unused value.
  • engine-intent/CLAUDE.md corrected. The FormIntentGenerator bullet claimed the cross-model relation is "resolved at GENERATION against that owner's .model (the same CrossModelSupport read...)". That generator reads no owner model at all: entitiesByName holds the local entities, so a cross-model target resolves to nothing and the control falls to the pickControl(null) read-only text field. The owner read happens only in the Glue resolver, which is also where the unknown field is refused. The refusal atomicity is documented in the Services-flow list, and the resolvers bullet now says why its dangling-task justification only holds with the rollback in place.

Not a DSL change

The .intent surface is untouched — no new construct, no changed semantics of any keyword — so this needs none of the three-repository specification sync. What changed is the generation pass's transaction behaviour.

Verification

  • IntentGenerationServiceRefusalTest (new, 2 cases): a refused first pass leaves no generated file and nothing carrying the dangling resolver; a refused regeneration restores byte-for-byte what the last good pass wrote. Both fail without the rollback, with the file set above.
  • IntentEngineIT.a_generation_time_refusal_leaves_no_model_files_behind (new): the same at the outermost layer, over a real cross-model owner model — in a project of its own, because the shared dependency project is where mutual_cross_model_generates_bootstraps needs a model to be absent, and one test's owner model is the other's precondition.
  • engine-intent unit suite: 1210 tests green. IntentEngineIT: 78/78. IntentCrossModelFieldRetirementIT + IntentCrossModelScheduleSourceIT + IntentCrossModuleCollisionIT: 7/7.

🤖 Generated with Claude Code

#7118 moved the "form field references a field of a cross-model to-one"
check from the parser to generation, and justified it by saying that
skipping the resolver "would leave the BPMN with a service task pointing
at a handler nothing generated". But the check sits at @order(350), after
EdmIntentGenerator (200) and BpmnIntentGenerator (300) have written the
.edm/.model and the .bpmn into the workspace - and the BPMN generator
reads the lookup-free resolvers(model) overload, whose convention
fallback yields the resolver task whether or not the owner declares the
field. So the 422 left in the workspace exactly the artefact the PR said
it avoided, where the same authoring mistake used to be refused at parse
with nothing written. It was never specific to that check either: any
IntentValidationException raised at generation left the earlier
generators' output behind, past the stale-output scrub the loop never
reaches.

A pass that is refused now writes nothing. IntentGenerationContext
journals the state each write replaced - the previous content, or null
for a file it created, recorded on the first change only and never for a
byte-identical write - and rollbackWrittenFiles() puts it back before the
exception leaves as the 422. The scaffolded .settings is rolled back with
it, since that too goes through writeModelFile. A restore that itself
fails is logged and the rest still run: the caller is on its way to
reporting the authoring error, and one unrestorable file must not hide
it. Deliberately not rolled back: the model-to-code recipes, which run
after the generators and already report their own per-entry outcome.

Also dropped `targetProject` from the resolver descriptor. The generated
resolver imports the owner's Entity/Repository out of its generation
folder (derived from targetModel) and builds no URL, so neither
GlueGenerator.bindResolver nor Resolver.java.template ever read it -
ProcessResolverSupport.CrossModelTarget no longer carries a project
either. A cross-model HOP link does need one; a resolver does not.

And corrected the CLAUDE.md FormIntentGenerator bullet: that generator
reads no owner model at all - entitiesByName holds the local entities, so
a cross-model target resolves to nothing and the control falls to the
pickControl(null) read-only text field. The owner read happens only in
the Glue resolver, which is also where the unknown field is refused.

Red-first: the unit test fails without the rollback with the reported
file set (Send.bpmn, .edm, .model, .settings), and on a regeneration the
refused pass rewrote the last good .bpmn. The IT asserts the same at the
outermost layer, over a real cross-model owner model - in a project of
its own, because the shared dependency project is where the bootstrap
test needs a model to be absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev
delchev merged commit e232325 into master Sep 11, 2026
10 checks passed
@delchev
delchev deleted the fix/7227-intent-validation-prepass branch September 11, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants