intent: a refused generation pass leaves nothing behind (#7227) - #7318
Merged
Conversation
#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>
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.
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), afterEdmIntentGenerator(200) andBpmnIntentGenerator(300) have written the.edm/.modeland the.bpmn— and the BPMN generator reads the lookup-freeresolvers(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.modelandsales-invoices.settingsbehind, and on a regeneration it rewrites the last good.bpmn(3246 bytes against 3233 — the extra bytes being theResolveCustomerMobileservice task).It was never specific to that check either: any
IntentValidationExceptionraised 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.
IntentGenerationContextjournals the state each write replaced — the previous content, ornullfor 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, andIntentGenerationServicecalls it before the exception leaves as the 422.Consequences worth knowing:
.settingsis rolled back with it, since that too goes throughwriteModelFile— a first Generate that is refused no longer leaves a settings file for a project that has never generated.BootstrapRequiredExceptionis anIntentValidationException, so a refused bootstrap pass is rolled back as well. That is the right reading: its recipe is to re-run withbootstrap=true, andmutual_cross_model_generates_bootstrapsstill passes end to end.The two smaller leftovers from #7118
targetProjectdropped from the resolver descriptor. The generated resolver imports the owner's Entity/Repository out of its generation folder (derived fromtargetModel) and builds no URL, so neitherGlueGenerator.bindResolvernorResolver.java.templateever read it;ProcessResolverSupport.CrossModelTargetno longer carries a project either. A cross-model hop link does need one (GlueGeneratorbuilds a controller URL from it) — a resolver does not.GlueFormCrossModelHopTestnow asserts the key is absent rather than asserting its unused value.engine-intent/CLAUDE.mdcorrected. TheFormIntentGeneratorbullet claimed the cross-model relation is "resolved at GENERATION against that owner's.model(the sameCrossModelSupportread...)". That generator reads no owner model at all:entitiesByNameholds the local entities, so a cross-model target resolves to nothing and the control falls to thepickControl(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
.intentsurface 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 wheremutual_cross_model_generates_bootstrapsneeds a model to be absent, and one test's owner model is the other's precondition.engine-intentunit suite: 1210 tests green.IntentEngineIT: 78/78.IntentCrossModelFieldRetirementIT+IntentCrossModelScheduleSourceIT+IntentCrossModuleCollisionIT: 7/7.🤖 Generated with Claude Code