A single .intent YAML file at a project root becomes the source of truth for every other authoring artefact in that project. The intent layer is one altitude above the existing model files: where Dirigible used to have hand-authored .edm / .bpmn / .form / .report / .roles / .access / .csvim and code-gen them to TS / HTML / Java / SQL on demand, the intent layer authors the .edm / .bpmn / ... model files themselves from one YAML.
The whole feature lives in org.eclipse.dirigible.components.intent.*.
The intent is an authoring artifact, not a runtime artifact. The platform draws this line
sharply and it dictates the whole design: authoring artifacts (.edm, .model, .form,
.report) get editors in the workspace plus an explicit Generate step; only runtime artifacts
(.roles, .bpmn, .csvim, .table, jobs, listeners, ...) are reconciled from the registry by
synchronizers. The .edm is the precedent: it has NO synchronizer - and neither does the intent.
The developer workflow (the only flow):
1. create a project in your workspace
2. create app.intent (any *.intent) at the project root, authored by hand / Claude / structured panel
3. double-click → the Intent Editor opens: editable YAML text left, live read-only diagram right
(ER + one flowchart per process + forms/reports/roles/seeds summaries), validation issues inline
4. click Generate → the six generators write the derived model files NEXT TO app.intent,
IN YOUR WORKSPACE PROJECT (nothing is published):
<intent>.edm + <intent>.model ← entities + relations + UI metadata
<process>.bpmn ← processes
<form>.form ← forms
<report>.report ← reports
<intent>.roles ← permissions
<seed>.csvim + <seed>.csv ← seed data
5. (follow-up: Generate also chains the model-to-code templates via .gen descriptors)
6. publish → the registry receives intent + models + generated code together;
the per-artefact synchronizers bring the runtime live as for any other project
Why the project root and not gen/: the model-to-code templates ("Generate from EDM") treat
gen/ as their exclusive output folder and wipe it on every regeneration - intent output placed
there would be destroyed the first time the user generates the application code. The project root is
where every real-world Dirigible application keeps its model files. The folders layer as: app.intent
(authored) + root model files (intent-owned, scrubbed by this engine's Generate) → gen/
(template-owned, wiped by the template engine) → custom/ (hand-written escape hatch, touched by
nobody).
The published app.intent in the registry is an inert source file (exactly like a published
.edm). Consequence to document, not fix: an intent-only project deployed headlessly (git →
registry) does not self-materialize - the generated models are committed/published artifacts,
produced in the workspace.
Intent generators stop at the model file. They never emit Entity.ts, Controller.ts, Repository.ts, HTML, Java, or SQL directly - those come from the IDE's existing "Generate from EDM / Schema / BPMN" templates, fed by the model files this engine wrote. That contract is non-negotiable; see "Wrong turns we already made" below.
Distilled from the chat that produced the initial scaffold. Read this BEFORE designing additional pieces - half of these decisions are not obvious from the code.
Dirigible is already model-driven (the synchronizer model = "declarations on disk → running app"). Adding an intent layer above EDM/BPMN/form/DSM is the natural next abstraction. The second half of the pipeline (intent → standard models → generated app) reuses what already exists: project templates, decorator-driven scaffolding, the TS/Java SDKs. No new runtime concept - just a new authoring layer above the existing ones.
The dream is "no code, no modelling - just prompt": user describes what they want in natural language to Claude (or any LLM); Claude proposes a patch to .intent; the user accepts in the Intent Editor; Generate refreshes the model files at the project root, and the template engine turns them into the app under gen/; the editor's diagram pane renders the intent for a quick read-only visual at every step.
-
Expressiveness ceiling - the thing that kills MDE projects. Every EDM attribute, every BPMN gateway condition, every form validator, every report aggregation, every permission rule has to be representable in
.intent, because the developer can NOT escape togen/. Real apps always have one weird bit. We chose the escape-hatch directory approach over union-of-everything: a/custom/sibling to/gen/will hold hand-written code preserved across regenerations, intent declares hook points, custom files supply implementations. Pure MDE has been tried for thirty years and the escape hatch always wins. The/custom/folder is NOT yet wired (out of scope for this skeleton) but every generator must be written assuming it exists - never emit intogen/something that should have been overridable. -
LLM determinism - edit shape, not file shape. "Add a
countryfield toCustomer" must produce a one-line diff toapp.intent, not a re-emitted file with entities reordered. Claude's job is proposing a patch to the intent (structured operations / unified diff), not regenerating it. The UI should show patch + Mermaid preview + accept/reject before applying. The structured-edit panel in the IDE is the power-user fallback when Claude misunderstands. The intent JSON is therefore arranged so diffs are minimal and stable: entities/processes/forms/reports/permissions are arrays (preserved order), nested fields use object literals, and the parser does not normalize field order. Do not introduce auto-sorting or reformatting on save. -
Intent is structured, not free text. The LLM converts NL → structured YAML; transforms from intent to EDM/BPMN/form are pure deterministic functions; Mermaid renders from the same model. Three distinct stages. The LLM is replaceable / optional - the intent format must be authorable by a human in a structured editor too.
- Intent generators target the model layer ONLY. Output extensions are restricted to
.edm/.model/.bpmn/.form/.report/.roles/.access/.dsm/.schema/.table/.view/.csvim/.csv. Anything code-shaped (Entity.ts,Controller.ts,Repository.ts,*.java,*.html,*.sql) is the template engine's output and must not appear in any intent generator. If you find yourself emitting code, you are at the wrong altitude. - YAML, not JSON. Optimised for human authoring (comments, multi-line strings, no quote noise, friendlier LLM diffs). Parsed via SnakeYAML's
SafeConstructor(already on the classpath transitively via Spring Boot) then round-tripped through a plain Gson instance to land in the typed POJOs. NOT through the platform'sJsonHelper/GsonHelper: those are configured withexcludeFieldsWithoutExposeAnnotation(), which silently maps every un-annotated POJO field to null - the parse "succeeds" with an empty model and every generator quietly skips (this bug shipped once; the IT only caught it because it asserts file existence). The parser's Gson also setsToNumberPolicy.LONG_OR_DOUBLEso YAML integers in seed rows stay integral (id: 1-> CSV1, not1.0).JsonHelperremains fine for the generators' Map-shaped output (maps are not field-reflected). - Safe YAML loading is non-negotiable.
IntentParserconstructs SnakeYAML withSafeConstructor, which blocks!!type/!!newtags. Intents arrive from LLM output and human paste; YAML deserialisation must never become a code-execution surface. Do not swap toConstructorfor "ergonomics". - One
.intentfile per project, at the project root. There is no plan to support multiple intents per project - the whole model lives in one place so the LLM has the whole picture to diff against. (Re-evaluate if intents grow past ~2000 lines in practice; until then, one file.) - In an intent project, model-layer files at the project root are owned by the regeneration pass;
/gen/stays the template engine's. Developers must not hand-edit the generated.edm/.bpmn/.form/... - anything hand-edited is overwritten, and files no longer backed by the intent are scrubbed on the next regeneration (so addingapp.intentto a classic project hands ownership of its root-level model files to the intent engine - migrate them into the intent first).gen/keeps its existing platform meaning: the model-to-code templates' output folder, wiped by them on every regeneration. - Existing projects without an intent stay "classic" (hand-edit EDM/BPMN/form as before). An "intent project" is detected by the presence of
app.intentat project root. A futurereverse-engineer intentcommand can scan EDM/BPMN/form and propose an intent file to migrate; out of scope for now. - Mermaid renders the intent for visualisation, read-only. We do NOT build a Mermaid round-trip editor (it is a poor authoring surface). Editing is via the LLM prompt + structured panel; the existing modelers are NOT re-used for intent projects (they would let developers edit gen/ in disguise).
- Run-once-fix-it via Claude. When something can't be expressed, the answer is to extend
.intent(add a field to the schema, add a generator that consumes it), not to leak into gen/.
These mistakes have been made and reverted. They are documented here so they are not made again.
-
EntityIntentGenerator that wrote
gen/<Entity>Entity.tsdirectly. This was at the wrong altitude - it tried to emit the@Entity()/@Table()/@Column()decorator-driven TS file that the platform'sEntitySynchronizer(extensionEntity.ts) consumes. That artefact is itself the output of "Generate from EDM" in the IDE; intent should emit the.edminstead and let the existing pipeline produce the TS. The generator was committed once (commit9570405aa9) and later deleted - do not bring it back. The replacement isEdmIntentGeneratorat@Order(200). -
PermissionIntentGenerator that wrote
.accessconstraints with URLs targeting the (also-wrong) generatedController.tspaths. Same mistake one altitude up: the access URLs were/services/ts/<project>/gen/<Entity>Controller.ts/*, which assumed the missing TS controllers existed. The right output for permissions is the same.roles+.accessartefacts but with paths reflecting whatever the EDM template emits, OR (preferred) lean on the.edmentity's owngenerateDefaultRoles="true"flag and let the template produce roles + access in lockstep with the generated UI. Not yet implemented; see the follow-up list. -
Generators that wrote
IRepositorypaths without the/registry/publicprefix. Synchronizer artefact locations are registry-relative (/orders/app.intent- seeSynchronizationWalker.walk, which strips the registry folder), butIRepositorypaths are repository-absolute. The first regeneration implementation derived the project root straight from the location and wrotegen/output to/orders/gen/...- i.e. outside the registry, where no IT assertion, no Registry view, and crucially no downstream synchronizer would ever see it. The whole two-stage pipeline was dead and the IT could not pass (it was committed red).IntentRegenerationService.resolveProjectRootnow prependsIRepositoryStructure.PATH_REGISTRY_PUBLIC; the same convention is visible inSynchronizationProcessor's cleanup pass andCsvimProcessor.getCsvResource. When in doubt: locations are registry-relative, repository paths are not. -
An IntentSynchronizer + JPA artefact for the intent itself. The first incarnation treated the intent as a runtime artifact: a
BaseSynchronizerparsed published.intentfiles into aDIRIGIBLE_INTENTStable and regenerated the models in the registry during reconciliation. Two unfixable consequences: generation happened only after publish (invisible in the Projects view, unusable by the modelers and by "Generate from EDM", which all work on the workspace), and the UI could only be a registry-reading perspective instead of an editor. The platform's own line is: authoring artifacts (.edm,.form,.report,.intent) get editors + an explicit Generate in the workspace; only runtime artifacts get synchronizers - note the.edmhas no synchronizer either. The synchronizer, the JPA artefact, and the Mermaid perspective were all removed in favour ofeditor-intent+ the parse/generate endpoints. Do not reintroduce them; this separation is hard-won low-code-platform experience.
The general rule the first two violated: intent generators must never reference paths or routes that belong to the template engine's output, because the intent layer should be agnostic about which template is selected.
components/engine/engine-intent/ # backend: parser + generators + REST services
├── pom.xml # depends on core-base, core-database, core-repository, ide-workspace
├── about.html
├── CLAUDE.md # this file
└── src/main/java/org/eclipse/dirigible/components/intent/
├── model/ # POJOs for the intent document (plain-Gson-mapped after YAML → Map → JSON round-trip)
│ ├── IntentModel.java # root: name / entities / processes / forms / reports / permissions / seeds
│ ├── EntityIntent.java / FieldIntent.java / RelationIntent.java
│ ├── ProcessIntent.java / StepIntent.java
│ └── FormIntent.java / ReportIntent.java / PermissionIntent.java / SeedIntent.java
├── parser/
│ ├── IntentParser.java # YAML → Map (SnakeYAML SafeConstructor) → JSON → IntentModel (plain Gson) + structural validation
│ └── IntentValidationException.java # collects every structural issue in one shot
├── generator/
│ ├── IntentTargetGenerator.java # SPI - one per slice (entities, processes, forms, ...)
│ ├── IntentGenerationContext.java # parsed model + target project paths; writeModelFile() is the only write surface
│ ├── IntentNaming.java # shared naming: baseName, upperSnake, tableName (<INTENT>_<ENTITY>)
│ ├── IntentGenerationService.java # runs the SPI beans in @Order, scrubs stale output, returns written/scrubbed
│ ├── edm/EdmIntentGenerator.java # @Order(200); <intent>.edm (XML) + <intent>.model (JSON)
│ ├── bpmn/BpmnIntentGenerator.java # @Order(300); <process>.bpmn per process
│ ├── form/FormIntentGenerator.java # @Order(400); <form>.form per form
│ ├── report/ReportIntentGenerator.java # @Order(500); <report>.report per report
│ ├── permission/PermissionIntentGenerator.java # @Order(600); <intent>.roles
│ └── csvim/CsvimIntentGenerator.java # @Order(700); <seed>.csvim + <seed>.csv per seed
├── agent/ # AI assistant: Anthropic bridge + endpoint (key server-side via DirigibleConfig)
│ ├── IntentAgentService.java # builds the Messages request (schema prompt + propose_intent tool), calls Claude
│ ├── IntentAgentEndpoint.java # POST /services/ide/intent/agent → {reply, proposedYaml}
│ └── AgentRequest / AgentTurn / AgentReply / IntentAgent*Exception # package-private DTO records + errors
└── endpoint/IntentEndpoint.java # POST /services/ide/intent/parse + /generate (workspace-targeted)
The editor lives in one sibling UI module:
components/ui/editor-intent- registered for content typeapplication/yaml+intent(theintentextension is mapped inContentTypeHelper) via theplatform-editorsextension point, like every other specialized editor. Split layout: an embedded Monaco editor (YAML highlighting, theme-synced to the IDE viaThemingHub, loaded from the/webjars/monaco-editor/...webjar thateditor-monacoalready ships) left (Save, ctrl+s, dirty tracking viaLayoutHub;$scope.textstays the single source the parse/save/diagram code reads, kept in sync from Monaco's change event), live diagram right (an mxGraph ER-style diagram for the entities + one top-down mxGraph flowchart per process + forms/reports/roles/seeds summaries), validation issues as an inline strip fed byPOST /parseon a 600ms debounce. A togglable third pane (toolbar discussion icon) is the AI assistant - see the "AI assistant" section; it proposes a completeapp.intentrendered as a Monaco diff that Accept merges into the buffer. The Generate button callsPOST /generateand refreshes the project tree via theprojects.tree.refreshdialog-hub topic (the same mechanism the form-builder uses). The diagram uses mxGraph 4.2.2 (the same engine the EDM / schema / mapping modelers use), depended on asdirigible-components-resources-mxgraphand loaded from/services/web/resources/mxgraph/4.2.2/src/js/mxClient.jswithmxBasePathset in the editor HTML.
Intent Editor diagram = mxGraph, fixed-colour palette (read before touching editor-intent/js/editor.js)
The diagram pane was Mermaid through mid-2026 and had two unfixable-in-practice theming defects (invisible connector lines in dark mode; a "Syntax error in text" bomb on every light↔dark switch). It was rewritten on mxGraph - the rendering engine Dirigible already trusts in the EDM/schema/mapping modelers - which removed both defects by construction. Do not reintroduce Mermaid.
How it renders. render() tears down any live graphs, then builds one read-only mxGraph per section into a freshly created container appended to #intent-diagrams:
renderEntities()- one blue HTML-label card per entity (name over its field list, PK marked); a solid edge for a required relation, dashed for an optional one. Laid out left-to-right withmxHierarchicalLayout(graph, mxConstants.DIRECTION_WEST)- the same layout the processes use; the organic layout was tried first but collapsed every card onto the origin because they all start at (0,0).renderProcess()- slate start/end ellipses, blue user-task / green service-task rounded rects, amber decision rhombus; decision steps emit a conditioned edge (label =if) tothenand a default edge toelse(falling back to the next step), exactly likeBpmnIntentGenerator. Laid out top-down withmxHierarchicalLayout(graph, mxConstants.DIRECTION_NORTH).renderGlue()- a "Glue & Outputs" section: one card per form / report / notification / schedule / integration / inbound webhook / rollup, edged to the entity it binds to (form→forEntity, report→source, notification/schedule/integration→event entity, inbound→created entity, rollup→counted entity with→ Parent.fieldin its label). Each card is badged with a SAP-icon glyph (ICONmap:sap-icon--form/bar-chart/email/date-time/chain-link/inbox/sum) — the platform icon font (not emoji), so it's monochrome and theme-consistent. Cards use two extra fixed palette colours (outputpurple,gluerust). The glue collections are already on the parsed model (the/parseresponse is Jackson-serialized, so they survive — unlike the generators' Gson path);normalize()defaults them. Each graph issetEnabled(false)(read-only, no selection/editing), HTML labels on, and sized to its content (sizeToContent→ the pane scrolls). The forms/reports/roles/seeds summaries stay as Angular-bound HTML below the diagrams.
Why theming is now a non-issue (the whole point of the rewrite). The cells use fixed brand colours (COLOR map at the top of the controller: entity blue #3584e4, service green #26a269, decision amber #e9a319, terminal slate #708090, edge mid-gray #7a8896, white labels) chosen to read on both the light and dark IDE themes, painted on a transparent canvas whose background is the theme's. So the diagram looks identical in either theme and there is no recolour-on-theme-switch step to break - the controller deliberately has no ThemingHub listener for the diagram. This matches the schema/entity modelers' approach (solid-coloured shapes, white text) and is what the user asked for. If you ever need a colour to follow the theme foreground instead, resolve the CSS var to a concrete rgb() in JS first (mxGraph writes stroke/fill as raw SVG presentation attributes via mxSvgCanvas2D, and var(...) in an SVG attribute is not reliably resolved) - do not put var(--…) into an mxGraph style string for stroke/fill and assume it tracks the theme.
Stale-jar trap (still the most likely reason "nothing changed"). editor-intent is static web resources bundled into the build/application fat jar. mvn install -pl components/ui/editor-intent updates the .m2 artifact but NOT an already-built dirigible-application-*-executable.jar. After editing, mvn -P quick-build install -pl components/ui/editor-intent and mvn -P quick-build package -pl build/application, then restart. Verify the running jar is current: curl -s -u admin:admin http://localhost:8080/services/web/editor-intent/js/editor.js | grep -c mxGraph (expect a non-zero count; mermaid should be gone). IntentEditorLoadsIT does not have this problem (failsafe repackages independently), which is why a green IT never proves the user's jar is current.
IntentEditorLoadsIT clones dirigiblelabs/sample-intent-model (the library intent sample - same clone-a-real-repo pattern as the SampleProjectRepositoryIT subclasses; it replaced the old local IntentEditorIT fixture so the published sample stays the single source of truth), opens its app.intent, then asserts the mxGraph diagram renders (.intent-diagram svg is visible) and that the parsed Book entity's label appears inside .intent-diagram - so it fails on an empty or broken diagram, unlike the old "any <svg> exists" check that a Mermaid error bomb satisfied. It does not separately exercise a theme switch because the fixed-colour palette renders identically in both themes. Editing the sample's entities/process means the sample repo must change too (the IT clones its HEAD). IntentEngineIT stays self-contained (inline YAML) for fast, network-free coverage.
Web resources under components/ui/* and components/.../resources are bundled into the build/application fat jar. After editing them: mvn -P quick-build install -pl <module> then mvn -P quick-build package -pl build/application, and restart the running jar. A locally running instance does NOT hot-reload changed module resources. Integration tests repackage tests-integrations and are unaffected - so a passing IT never proves the user's running jar is current.
Six concrete generators currently live in-module:
EdmIntentGeneratorwrites<intent>.edm(XML) plus<intent>.model(JSON twin) from the entities + relations declared in the intent. Each entity is fleshed out with EDM editor defaults (icons, menu keys, layout type, perspective metadata, widget types) derived from the entity / field names so the produced model is a complete, openable EDM document. Conventions follow the canonical Dirigible model conventions: propertynames are PascalCase (id->Id,loanedOn->LoanedOn, FKmember->Member) viaIntentNaming.pascalCase, while the physical columndataNamestays UPPER_SNAKE and intent-prefixed (ORDERS_COUNTRY) - authoring stays lower camelCase, only the generated model names are PascalCased; every property carriesauditType="NONE", and a required field/FK also carriesisRequiredProperty="true"(the generated REST controller's required-value validation keys on it, not ondataNullable). Every to-one FK property carries the full relationship metadata the generation reads (the.modelhas no separate relations array, so it must live on the property):relationshipType(COMPOSITIONforcomposition: true, elseASSOCIATION),relationshipCardinality(1_ncomposition,n_1manyToOne association,1_1oneToOne association),relationshipName= the FK constraint name<owner>_<target>(e.g.Loan_Member- used as the DB FK constraint name in the schema template),relationshipEntityName= target entity,relationshipEntityPerspectiveName= target's resolved perspective,relationshipEntityPerspectiveLabel="Entities". The dropdown's data-service URL (api/<perspective>/<entity>Service.ts) and the create-detail dialog are built fromrelationshipEntityName+relationshipEntityPerspectiveName- omitting them generatedapi/undefined/undefinedController.tsand a dead dropdown. Acomposition: truerelation additionally makes the owner DEPENDENT/MANAGE_DETAILS, inheriting its transitively-resolved parent perspective; every other to-one stays a PRIMARY association DROPDOWN. Dropdown key/value andreferencedPropertycome from the target entity's actual PK andname-like fields (PascalCased); the.modelJSON carriesentities/perspectives/navigations(norelationskey - relations are XML-only, interleaved with their owning<entity>). The.edmalso carries anmxGraphModeldiagram with astyle="entity"vertex per entity (carrying an<Entity>value), a child vertex per property (carrying a<Property>value), and an edge per FK relation - the EDM editor renders the canvas exclusively by decodingmxGraphModel, so without it the editor opens empty. Entities are placed in a fixed grid for deterministic output.BpmnIntentGeneratorwrites one<process>.bpmnper process. Minimal Flowable-flavoured BPMN 2.0 - one start event, one end event, the declared steps, and the sequence flows that connect them. Decisions emit an exclusiveGateway with a conditioned outgoing flow toargs.thenand a default flow toargs.else(falling back to the next step in the chain when omitted). Thetriggerblock keeps the BPMN with a plain none-start event; the runtime auto-start is thetemplate-application-events-javalistener/handler undergen/events(driven off the EDM generator'striggerscollection), not a BPMN start event. Emits thebpmndi:BPMNDiagramblock (plus theomgdc/omgdinamespaces): the Flowable/Oryx modeler renders the canvas only from the diagram interchange - a process with noBPMNShapes opens empty. Nodes are laid out left-to-right along the linear chain at a fixed lane; edges connect source-right to target-left. The layout is deterministic (byte-stable across regenerations); the modeler re-routes on first manual edit. Naming: element ids are uniform lower camelCase — authored step names already are (librarianReview), and the injected decision-resolver task id is the lower-camel form of its handler (resolveBookPrice) while the delegate still resolves the PascalCase classgen.events.ResolveBookPrice. Task / gateway / processnames are humanized from the id viaIntentNaming.humanize(librarianReview → "Librarian Review",LoanApproval → "Loan Approval"); the process id stays the compactLoanApproval.FormIntentGeneratorwrites one<form>.formper form. Controls are typed by looking up each declared field against the bound entity (string/uuid -> input-textfield, text -> input-textarea, integer/decimal -> input-number, boolean -> input-checkbox, date -> input-date, timestamp -> input-datetime-local). Actions become buttons in a trailingcontainer-hbox; the button colour is inferred from the action name (approve -> positive, reject/decline/delete/cancel -> negative, save/submit -> emphasized). A stub controller code block declareson<Action>Clickedhandlers as TODOs - wiring to a backend is left to the downstream template engine or a hand-authored override undercustom/.ReportIntentGeneratorwrites one<report>.reportper report in the Dirigible.reportshape:name/alias(the source entity, the base-table alias) /table(intent-prefixed physical table viaIntentNaming.tableName) /columns/ a fully-materialised SQLquery/conditions/security. The report is rooted atsource; each dimension/measure resolves to a physical column - a plain field (dueOn) -> a source column; arelation.fieldpath (member.name) -> anINNER JOINto the related entity plus a column on it (this is how a report shows a parent's columns); a bare to-one relation (member) -> anINNER JOINshowing the target's label (name-like) field, not the raw FK id (so "group by member" displays the member's name; usemember.idfor the id); a measurecount(*)/sum(total)/avg/min/max-> an aggregate column (dimensions then become theGROUP BY).filterbecomes theWHERE, with intent field names rewritten to qualified physical columns (dueOn <= CURRENT_DATE->Loan."LOAN_DUE_ON" <= CURRENT_DATE); operators / literals /CURRENT_DATEpass through.securityis{generateDefaultRoles, roleRead: <project>.Report.<name>ReadOnly}. Column physical names + the base table mirrorEdmIntentGeneratorso the report never drifts from the model. (The earlier version leftqueryempty and used a non-standard shape - reports did not run.) All physical table and column identifiers in thequeryare double-quoted ("LIBRARY_LOAN",Loan."LOAN_DUE_ON"); PostgreSQL folds unquoted identifiers to lower case and would never match the quoted UPPER_SNAKE objects the platform creates, so an unquoted query runs on H2 but fails on Postgres. Table aliases stay unquoted (they fold consistently on both sides). Thequote(...)helper + the JSON-escaping of those quotes inside the.reportquerystring (assert\"in tests) are the two gotchas. Caveat: the base-table alias is the entity name; a reserved-word entity name (Order) yields an unquoted reserved alias - keep entity names non-reserved, as the standard Dirigible apps do.PermissionIntentGeneratorwrites<intent>.rolesfrom the intent'spermissionsblock (deduped by role name). It deliberately does NOT emit.accessconstraints - URL-shaped access rules belong to whichever downstream template materializes the UI for an entity / form / report, because only that template knows the paths it will publish. Thecan: [Resource:action, ...]tokens on each permission are an authoring hint to downstream UI generators about which actions each role may invoke; the actual<path, method, role>mapping is the downstream template's contract, not intent's.CsvimIntentGeneratorwrites<seed>.csvim+<seed>.csvper seed. ThetableisIntentNaming.tableName(same as the EDMdataName); thefilepath is project-qualified (/<project>/<seed>.csv) becauseCsvimProcessorresolves it against/registry/public. CSVIM defaults match the existing platform samples (header: true,useHeaderNames: true, field delim,, enclosing",version: 1.0, schemaPUBLIC). The CSV header carries<ENTITY>_<FIELD>upper-snake column names; row order matches the entity's declared field order so row authors can omit fields without misaligning columns. Cells containing the delimiter, the quote, or a newline are quoted and inner quotes doubled. Note the target table only exists after the downstream "Generate from EDM" output is published - until then the CSVIM import is retried by its own synchronizer.
Together they cover every intent block defined today.
| Intent block | Output | Spring @Order |
|---|---|---|
entities |
<intent>.edm + <intent>.model |
200 (done) |
processes[] |
<process>.bpmn (one per process) |
300 (done) |
forms[] |
<form>.form |
400 (done) |
reports[] |
<report>.report |
500 (done) |
permissions |
<intent>.roles |
600 (done) |
seeds[] |
<seed>.csvim + <seed>.csv |
700 (done) |
(future) low-level schemas[] |
<schema>.dsm + <schema>.schema |
250 |
(future) custom-action .access rules |
<intent>.access |
650 |
All implementations are Spring @Component beans implementing IntentTargetGenerator; ordering via @Order. Leave gaps of 100 so future generators can slot between.
- No artefact type, no JPA table, no synchronizer. The intent never touches the database; the
.intentfile in the workspace is the single source of truth. - Content type
application/yaml+intentmapped from theintentextension inContentTypeHelper(modules/commons/commons-helpers) - this is what routes a double-click to the Intent Editor. - Module registered in:
components/pom.xml(bothengine/engine-intentandui/editor-intent),components/group/group-engines/pom.xml(engine),components/group/group-ide/pom.xml(editor).
POST /services/ide/intent/parse(body: raw YAML) -IntentParser.parse→IntentModelJSON, or422 {"issues": [...]}with every structural problem at once. The editor calls this on a debounce to refresh the diagram and the validation strip; nothing is persisted.POST /services/ide/intent/generate?workspace=&project=&path=- resolves the current user's workspace project viaWorkspaceService(so it is inherently user-scoped), reads the intent file, runs every registeredIntentTargetGenerator(failures per generator are logged and isolated), then scrubs stale intent-owned files. Returns{"written": [...], "scrubbed": [...]}.- Stale-output scrub. Model-layer files at the project root that the pass did not re-emit are deleted. The extension filter keeps the scrub away from the
.intentfile itself, code files, and subfolders (gen/,custom/- only direct child resources are considered). Removing a process / form / seed from the intent removes its model file on the next Generate. - Generation is idempotent and diff-stable - identical input produces byte-identical output, and byte-identical content is not rewritten.
The third pane of the Intent Editor (toggled by the toolbar's discussion icon) is a natural-language assistant that edits app.intent at the developer's altitude - it proposes a patch to the intent, never a re-emitted model file, exactly the "edit shape, not file shape" contract from the design notes above.
- Server-side bridge, key never leaves the server.
agent/IntentAgentServicecalls the Anthropic Messages API with the JDKHttpClient;IntentAgentEndpointexposesPOST /services/ide/intent/agent(body{yaml, message, history}, returns{reply, proposedYaml}). The whole agent feature is one cohesive...intent.agentpackage (DTO records + exceptions package-private). The API key is read viaDirigibleConfig.INTENT_AI_API_KEYand is never sent to the browser. Config:DIRIGIBLE_INTENT_AI_API_KEY(blank → assistant disabled, endpoint returns412),_MODEL(defaultclaude-opus-4-8),_BASE_URL(defaulthttps://api.anthropic.com),_MAX_TOKENS(8192),_VERSION(2023-06-01). - The system prompt is an externalized, reviewable resource. It lives in
engine-intent/src/main/resources/intent-assistant-guide.md(loaded from the classpath byIntentAgentService.loadGuide()at class init — fail-fast if missing), not an inline string, so it can be edited as documentation and kept in lockstep with whatIntentParserenforces. It documents the full schema including the declarative-glue catalog (notifications/schedules/integrations/inbound/rollups) and the triggerbusinessKey/businessKeyStrategy, plus the recipient grammar (literal / direct field / one-hoprelation.field, no braces — braces are only for{…}interpolation insubject/body). - Full file via a forced-available tool, then we diff. The guide teaches Claude the intent YAML schema + the diff-stability rules (change minimally, preserve key order/comments, append don't reorder). Claude returns the complete updated YAML through a single
propose_intenttool ({explanation, yaml}); plain-text replies (no tool call) are clarifying questions/answers. The editor renders the proposal as a Monaco diff against the current buffer; Accept replaces the buffer (MonacosetValue→ the existing dirty-tracking + debounced re-parse fire), Reject discards. The developer still Saves + Generates as usual - the agent never writes to disk or runs the generators. This is why "full proposed YAML + we diff it" was chosen over LLM-authored unified diffs (fragile to apply) or structured edit-ops (lose comments/formatting). - Transcript discipline. The browser keeps two lists:
chat.messages(display, may hold errors + UI notes) andchat.turns(the clean alternating user/assistant transcript sent ashistory). The current YAML is embedded fresh in every latest user turn, so the model always diffs against ground truth even after an Accept; tool calls are not replayed (notool_resultplumbing). A failed turn pops its dangling user turn so the next request stays alternating. The service also defensively skips any non-user/assistantrole. - Spring Boot 4 strips
ResponseStatusException.getReason()from the error body, so the412"not configured" message is a frontend fallback string, andIntentEngineIT.agent_reports_when_not_configuredasserts only the status code (the test env has no key, so this path is network-free).
The shape the model POJOs serialize to / deserialize from. Keep field names stable - this is the schema the LLM is prompted against. Every collection defaults to empty so partial intents (e.g. entities only) parse cleanly. Field names are camelCase to match the POJOs after the SnakeYAML → Map → Gson → POJO round-trip (Gson does not do snake_case-to-camelCase rewriting by default).
name: orders
description: Order management with approval workflow
version: 1
entities:
- name: Customer
description: Buyer account
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 200 }
- { name: country, type: string, length: 2 }
relations:
- { name: orders, kind: oneToMany, to: Order }
processes:
- name: OrderApproval
trigger: { onCreate: Order }
steps:
- name: managerReview
kind: userTask
args: { assignee: manager, form: ApproveOrder }
- name: bigOrder
kind: decision
args: { if: "amount > 10000", then: cfoReview, else: end }
- name: cfoReview
kind: userTask
args: { assignee: cfo, form: ApproveOrder }
forms:
- name: ApproveOrder
forEntity: Order
fields: [items, total]
actions: [approve, reject]
reports:
- name: OrdersByCountry
source: Order
dimensions: [customer.country]
measures: ["count(*)", "sum(total)"]
permissions:
- { role: Sales, can: [Customer:read, Customer:write, Order:create] }
- { role: Manager, can: [Order:approve] }
seeds:
- name: countries
entity: Country
rows:
- { id: 1, name: Afghanistan, code2: AF, code3: AFG, numeric: "004" }
- { id: 2, name: Albania, code2: AL, code3: ALB, numeric: "008" }Logical field types (FieldIntent.type) are: string, text, integer, int, long, decimal, double, boolean, date, timestamp, uuid. Generators map them to JDBC + EDM types. Primary keys must be an integer type (integer/int/long) - the Dirigible model convention is integer auto-increment identifiers, and a non-integer auto-increment column is invalid SQL (a uuid/VARCHAR PK produced AUTO_INCREMENT on a VARCHAR(36) column, which H2 rejects); the parser enforces this and the EDM generator only emits dataAutoIncrement for integer columns. uuid remains valid for non-PK fields (maps to VARCHAR(36)). Relation kinds: oneToMany, manyToOne, oneToOne, manyToMany. Step kinds: userTask, serviceTask, decision, script, end.
Semantics worth knowing:
composition: trueon a to-one relation makes it a composition. The owning entity becomes DEPENDENT (managed as details under its parent's perspective) and the FK is NOT NULL.required: truealone only makes the FK NOT NULL - the entity stays a top-level PRIMARY association (plain dropdown, its own perspective). Composition is opt-in, matching the Dirigible convention (where it is an explicitrelationshipType="COMPOSITION"and most required FKs are plain associations);compositionalready implies NOT NULL, sorequiredneed not also be set. Only amanyToOne/oneToOnecan be a composition; an entity's firstcompositionto-one is its composition parent. Declare the inverseoneToManyon the master (Memberwithloans: oneToMany to Loan+Loan.membercomposition: true) soLoanis managed as a detail ofMember; theoneToManyitself is navigation-only (the EDM generator ignoresoneToMany/manyToManysince the FK lives on the child). (This replaced the earlier "first required to-one is automatically a composition" heuristic, which made entities like aLoanwith a requiredmemberFK silently nest underMemberinstead of staying top-level.) Every to-one FK property (composition or association) carriesrelationshipType/relationshipCardinality(1_n/n_1/1_1) /relationshipName(<owner>_<target>) /relationshipEntityName/relationshipEntityPerspectiveName- the last two drive the generated dropdown's data URL, so they are not optional.kind: settingon an entity marks it as nomenclature / configuration.EntityIntent.kind(default null = a regular managed entity);kind: settingmakesEdmIntentGeneratoremit the entity withtype="SETTING"(andentityType="SETTING"in the mxGraph cell) instead of PRIMARY. The template engine keys onentity.type === "SETTING"(service-generate/template/generateUtils.js) to route it under the dashboard's global Settings perspective (it nulls the layout and setsperspectiveName = "Settings"), so a setting entity does NOT get its own generated perspective. Crucially the EDM generator also resolves any relation targeting a setting entity to theSettingsperspective (perspectiveFor(...)), so an FK dropdown to a setting points atapi/Settings/<Entity>rather than a missing per-entity perspective. Settings are still real entities (own table, CSVIM seeds, FK columns) - only their UI placement differs.- Decision steps:
if+thenare mandatory;elseis optional and receives the gateway-default flow (so the conditioned branch can actually be skipped - withoutelsethe default falls through to the next step in the chain).then/elsemust name a declared step or the literalend; the parser validates this so a typo fails at parse time instead of producing BPMN Flowable rejects. trigger: { onCreate|onUpdate|onDelete: <Entity>, when: "<expr>" }starts the process on the named<Entity>lifecycle event - fully wired (Java). Any of the three events is supported:onCreatebinds the entity's base topic,onUpdate/onDeletethe-updated/-deletedtopics the Java DAO publishes (TriggerSupport+EventBinding); an optionalwhenguard (a singlefield ==|!= literal, viaNotificationSupport.guard) gatesProcess.start. Three parts: (1) the parser validates at most one event kind and that the target is a declared entity; (2) the EDM generator adds aProcessIdback-reference property (VARCHAR) to that entity and atriggerscollection to the.model(TriggerSupport+EdmIntentGenerator.buildTriggers); (3) thetemplate-application-events-javatemplate (intent-driven, like the other language templates) reads thattriggerscollection and emits onegen/events/<Process>Trigger.javaper trigger - a client-Java self-describingMessageHandler(a@Componentwhosedestination()is the entity's per-operation topic viatopicSuffixand whosekind()isTOPIC) that loads the entity, applies thewhenguard, callsProcess.start(<process>, businessKey, <entity JSON>), and writes the instance id back toProcessId(so it starts at most once). The Java DAO template (template-application-dao-java) now publishes the create event (Producer.sendToTopic('${projectName}-${perspectiveName}-${name}', json)) the way the TS DAO does - that's the topic the handler binds to.gen/eventsis a sibling ofgen/<model>, so it survives the per-model regeneration wipe. The events template iterates the model'striggersvia a newtriggerscollection case inservice-generate/template/generateUtils.js(the engine's collection switch is hardcoded; the case has its own loop because triggers are not entity-shaped). The BPM business key defaults to the entity's primary key but is configurable:trigger: { ..., businessKey: <field> }names which trigger-entity field becomes the started instance's business key (the listener still loads the entity by its PK viafindById; only the business key differs — a separatebusinessKeyPropertyin.glue). An optionalbusinessKeyStrategy: timestampmints ayyyyMMddHHmmssvalue into that field when it is blank and persists it via the listener's existing update — the simple "for now" generator and the extension point for richer pluggable number generators later (sequential, zero-padded, config-prefixed invoice numbers); the parser validates the field exists, the strategy is supported, and (fortimestamp) the field isstring/text.TriggerSupport.triggerBusinessKey/triggerBusinessKeyStrategyread them;GlueIntentGeneratoremitsbusinessKeyProperty+generateBusinessKey;Trigger.java.templaterenders the mint-if-blank block.onScheduleis still unmodelled. Casing subtlety in the generated handler: itsimport gen.<genFolder>.data.<perspective>.<Entity>{Entity,Repository}must use the lowercased Java package segment (javaPerspective=sanitizeJavaIdentifier(perspective), matching the DAO/entity templates'javaPerspectiveNamefolder), while thedestination()topic ("<project>-<perspective>-<entity>") keeps the raw perspective so it matches the topic the DAO publishes to (${projectName}-${perspectiveName}-${name}). Thetriggerscollection case ingenerateUtils.jssupplies both (javaPerspectivefor the import,perspectivefor the topic). Using the raw perspective in the import compiled on macOS (case-insensitive FS) but failedjavacwith "package gen.x.data.Member does not exist" because the entity files declare the lowercased package.- The YAML
name:field is the intent's identity for outputs.IntentNaming.baseNameprefers it over the artefact name derived from the file name (which is conventionally justappfromapp.intent); single-file outputs are<name>.edm/<name>.model/<name>.rolesand the table prefix is its upper-snake. - Physical table names are intent-prefixed:
<INTENT>_<ENTITY>upper-snake (ORDERS_ORDER), viaIntentNaming.tableName, consistently across.edmdataName,.reporttableand.csvimtable. This avoids SQL reserved words (ORDER,USER, ...) and cross-project collisions in a shared schema. If the downstream "Generate from EDM" wizard asks for a table prefix, intent projects must leave it empty - the prefix is already part ofdataName.
- Comments are allowed and encouraged. Lines starting with
#survive a SnakeYAML load → JSON round-trip only as dropped content, so they are NOT preserved across regeneration of the intent itself - but since the intent is the only authored artefact and no tool ever rewrites it, comments authored by a developer stay put. The LLM patch path must respect the surrounding comments. - No anchors / aliases (
&foo/*foo) at v1. They cut copy-paste corners for the author but make the file far harder for the LLM to diff. If duplication becomes painful, introduce a top-leveldefaults:block rather than YAML's structural aliasing. - No multi-document YAML (
---). One intent file, one YAML document. - Tags forbidden. Already enforced by
SafeConstructor; mentioned here so a future reader does not "fix" it.
- Don't emit code-shaped files from any intent generator. No
*.ts,*.java,*.html,*.sql,*.css. Output extensions are restricted to the model layer (.edm/.model/.bpmn/.form/.report/.roles/.access/.dsm/.schema/.table/.view/.csvim/.csv). The existing template engine produces code; the intent layer produces models. - Don't make intent multi-tenant. Authoring is single-tenant; generated artefacts handle their own tenancy.
- Don't let intent rewrite or sort itself. Diff stability matters - the LLM has to produce minimal patches, which only works if the on-disk shape is stable. No auto-formatting, no field reordering.
- Write only via
IntentGenerationContext.writeModelFile(project root). The post-pass scrub deletes model files that were not re-emitted throughwriteModelFile- a generator that writes throughIRepositorydirectly would see its output deleted right after producing it. Never write intogen/: the model-to-code templates wipe that folder wholesale on every regeneration. - Don't reference template-engine output paths. Intent generators must be ignorant of which downstream template the user will run. The
.accessconstraints must not namegen/<Entity>Controller.tspaths; either use the EDM's owngenerateDefaultRoles="true"flag (preferred) or emit role / path tokens the template engine resolves itself. - Don't add a Mermaid round-trip authoring editor. Mermaid is read-only visualisation (it lives in the right pane of the Intent Editor). Authoring is the YAML text pane + prompt + structured panel.
- Diagram editors render only from their layout blocks - always emit them. The EDM editor decodes
mxGraphModel, the BPMN modeler decodesbpmndi:BPMNDiagram; both open to an empty canvas if the block is missing (this shipped once - the generators wrongly assumed the editors auto-lay-out on open). Any generator whose target opens in a visual modeler must emit a deterministic layout, not just the logical model. - Don't reuse the existing modelers for intent projects. That would re-expose
gen/as an authoring surface and undo the whole point. - Don't read env vars or system properties directly - go through
DirigibleConfigper the platform-wide rule.
The intent already generates two kinds of process glue (.glue → template-application-events-java): triggers (onCreate → start a process) and decision resolvers (load a related entity's field for a gateway). These are the first two instances of one abstraction we want to grow, so that common integrations and activities are declared in the intent and need no hand-written code:
glue =
on <event> → do <action>, with action parameters bound by resolver paths.
Three axes:
- Event — entity
onCreate/onUpdate/onDelete(+ awhen:guard), a process step reached/completed,onSchedule: <cron>, or an inbound message / webhook / file. The event-binding map key isevent:, noton:— YAML 1.1 resolves a bareon(alsooff/yes/no) to the booleantrue, so SnakeYAML would swallow anon:key and the binding would silently never populate (this bit the notifications increment;IntentEngineITcaught it). Likewise an action key isdo:, neveron:. - Action —
startProcess,notify,callHttp/publish,setField/upsert,generateDocument,assign. - Binding — the existing resolver path grammar (
member.email,book.genre.name): relation walks off the triggering entity / process context, validated at parse time likethen/else. Already built — reuse it everywhere, don't re-invent data access per action.
Decision (supersedes, for the glue layer only, the "prefer a model artifact, Java glue is the exception" wording elsewhere in this guide): every glue activity is generated as a client-Java class against the SDK (org.eclipse.dirigible.sdk.*) under gen/events, exactly as the trigger glue already is (a self-describing MessageHandler — destination()/kind() — that calls Process.start(...) and uses Json). The class is the model/artefact — engine-java synchronizes and runs it; it is deterministic, regenerated with the app, and replaceable via a /custom/ override. We do not emit .listener / .job XML/JSON artefacts that point at a handler, and we do not target TypeScript.
Handler style (per ../engine-java/CLAUDE.md): listener-style glue is generated as a self-describing MessageHandler (a @Component supplying destination()/kind()) — not a class-level @Listener (which is method-level only now, and mixing an interface with the annotation is rejected). Scheduled glue is a @Component implements JobHandler (self-describing cron()) or a @Scheduled method; websocket glue a WebsocketHandler or @Websocket+@OnX. One style per @Component. Older bullets below that say "@Listener/@Scheduled class" predate this and should be read as "the listener/scheduled glue", emitted in the current style.
Why: TypeScript is being deprecated. Client-Java (engine-java + data-store-java; the @Entity/@Controller/@Repository/@Listener/@Scheduled SDK surface) is now the primary runtime; the GraalJS path is a dead-end and TS will be removed once the Java surface is comfortable. So all new code-gen — glue, and increasingly the template engine via the *-java templates the .settings recipe already prefers — targets annotated Java; do not invest in TS-handler-shaped glue. The line we still hold is unchanged: no hand-written business logic in gen/ — the moment an action needs real logic it is a script step or a /custom/ hook, never more intent syntax. (The core model generators — entities→.edm, processes→.bpmn, forms→.form, reports→.report, roles→.roles, seeds→.csvim — stay model files: they feed the modelers and the template engine.)
Every action below has a real SDK surface to generate against, so none of this needs a new runtime:
| Action | SDK surface (org.eclipse.dirigible.sdk.*) |
|---|---|
| react to an entity event | messaging.Listener + MessageHandler (topic per entity, as the trigger glue) |
| schedule | job.Scheduled (expression() = cron) + job.JobHandler |
| notify — email | mail.Mail |
| notify — in-app / push | net.Websockets / messaging.Producer |
| call out (HTTP / webhook) | http.HttpClient |
| publish / consume message | messaging / kafka / rabbitmq Producer/Consumer |
| inbound webhook | http.Controller + @Get/@Post |
| read / write / upsert data | db.Store / the generated <Entity>Repository |
| generate a document | pdf.Pdf (+ cms.Cmis to file it) |
| start process / complete task | bpm.Process / bpm.Tasks |
| config / secrets | core.Configurations / core.Env (the @config: sugar) |
| dynamic assignment / auth | security.Roles / security.User |
Tier 1 — build first (reuses trigger + resolver almost entirely):
- Generalized lifecycle reactions — generalize the trigger from "onCreate→startProcess" to
{onCreate|onUpdate|onDelete} + when:→ any action. Prereq: the Java DAO must publish update/delete events (create is already published). →gen/events/<name>Reaction.java(@Listener).reactions: - { event: { onUpdate: Loan, when: "status == 'APPROVED'" }, do: notify(loanApprovedEmail) }
- Notifications — the most-requested business glue. (v1 implemented.)
→
notifications: - { name: orderUpdated, event: { onUpdate: Order }, channel: email, to: ops@example.com, subject: "Order {id} updated, total {total}", body: "The order changed." }
gen/events/<Name>Notification.java(@Listener) usingsdk.mail.Mail, bound to the entity's create /-updated/-deletedtopic; sender fromDIRIGIBLE_MAIL_SENDER. The author-facing fields are translated to Java byNotificationSupport(unit-tested) and emitted into.glue(GlueIntentGenerator), rendered byNotification.java.templatevia thenotificationscollection case ingenerateUtils.js. Scope:to/{placeholder}resolve a literal, a direct field, or a one-hoprelation.fieldof a to-one relation — the listener loads the related entity once by FK id (same one-hop mechanism as the decision resolvers;NotificationSupport.planemits therelationLoadsthe template renders).when:supports a singlefield ==|!= literalon a direct field. Multi-hop paths (a.b.c) and relation-pathwhen:are the remaining gap; the parser rejects multi-hoptowith a clear message. - Scheduled activities — cron reminders / cleanups; query an entity and act per matching row. (v1 implemented.)
→
schedules: - name: overdueLoans cron: "0 0 9 * * ?" entity: Loan where: - { field: dueOn, op: lt, value: CURRENT_DATE } # eq/ne/gt/ge/lt/le/like; CURRENT_DATE/CURRENT_TIMESTAMP -> now() - { field: status, op: eq, value: "ACTIVE" } notify: { to: member.email, subject: "Overdue: {book.title}", body: "Your loan is overdue." }
gen/events/<Name>Job.java: a@Scheduled(expression=cron)JobHandlerthat runsnew <Entity>Repository().findAll(Criteria...)(the typedCriteriaquery API) and performs the per-rownotify(reusingNotificationSupport.planagainst the row entity - same relation-load + interpolation as notifications).ScheduleSupport.criteriaExpressionbuilds theCriteriachain (where-> typed conditions; field names PascalCased to the entity properties). Honors the.settingsoverride. Gap: the action isnotifyonly (other actions +between/inoperators are later); processtrigger: { onSchedule: <cron> }(timer start event) is still open. - Outbound integrations — "tell another system" on an event. (v1 implemented.)
→
integrations: - { name: pushBookToCatalog, event: { onCreate: Book }, method: POST, url: "@config:CATALOG_URL" }
gen/events/<Name>Integration.java: an@Listenerthat forwards the entity-event JSON to the URL viasdk.http.HttpClient(IntegrationSupportmaps the method + the@config:KEYURL sugar toConfigurations.get). The event-binding (EventBinding) + topic are shared with notifications. Gap: body forwards the whole entity (custom body mapping + headers/auth are later).
Tier 2 — high value:
- Inbound webhooks — "another system tells us": a webhook that ingests a JSON payload into an entity. (v1 implemented.)
→
inbound: - { name: ingestOrder, path: /ingest, create: Order }
gen/events/<Name>Webhook.java: a@Controllerwith a@Post("<path>")that deserializes the body (via the java.time-aware SDKJson) into the entity andsaves it through the repository, returning the saved JSON. Served at/services/java/<project>/gen/events/<Name>Webhook<path>. Gap: v1 action iscreate(ingest); upsert/match-set and start-process, plus queue/topic sources (@Listener/Consumer), are later. - Status lifecycle (state machine) on an entity — order/ticket/loan status; the highest "removes custom code" leverage of the set.
→ guarded-transition glue (+ optionally a small
entities: - name: Loan lifecycle: field: status states: [REQUESTED, APPROVED, ACTIVE, RETURNED, OVERDUE] transitions: - { from: REQUESTED, to: APPROVED, guard: "book.available", do: notify(loanApprovedEmail) }
.bpmn), reusing resolvers for guards and reactions fordo:. - Document generation (PDF) — agreements / invoices.
→
documents: - { name: loanAgreement, event: { onUpdate: Loan, when: "status=='APPROVED'" }, template: loan-agreement, data: { member: member.name, book: book.title }, output: attachTo(Loan) }
@Listenerusingsdk.pdf.Pdf+sdk.cms.Cmis.
Tier 3 — denormalization & compliance (cheap once Tier 1 exists):
- Audit / history —
audit: trueon an entity → shadow<Entity>Historyentity + a write-on-change@Listener. - Rollups / counters — maintain a denormalized count on a parent. (v1 implemented.)
→ two
rollups: - { name: memberLoanCount, entity: Loan, via: member, field: loanCount } # Member.loanCount = #Loans whose `member` FK = that Member
gen/events/<Name>RollupOn{Create,Delete}.java@Listeners on the child's create/delete topics that recompute the affected parent's count via a typedCriteria(findAll(Criteria.create().eq("<Fk>", entity.<Fk>)).size()) and write it back. Recompute-on-event (self-healing); eventually consistent, not transactionally exact under heavy concurrency. Gap: nowherefilter (counts all children), and re-parenting on child update isn't tracked (only create/delete). - Dynamic user-task assignment —
assignee: { fromPath: member.branch.manager }, resolver-driven (extends the existing user-task glue).
- Curated action vocabulary, not a DSL. Real logic → a
scriptstep or a/custom/hook (the escape hatch is non-negotiable; pure MDE always loses here). - Every generated glue artefact gets an override switch — the
.settingsoverrides.{...}.generate=falsemechanism (already honored for triggers/resolvers/forms) lets a hand-written/custom/class replace any single generated one. - Secrets / endpoints via
DirigibleConfig/sdk.core.Configurations(@config:sugar), never inline — and grep-clean before publish. - Bindings validated at parse, like
then/elsetoday (a danglingmember.branchzfails fast, not at runtime). - Determinism + diff stability + comment preservation, as for every generator; each generated class carries the "generated from intent — do not edit" header the trigger/resolver templates already use.
Build #1 (reactions) + #2 (notify) first: one new concept ("reaction"), reuses trigger + resolver, unlocks the most apps per line of new code. Then #3 (schedules) (sdk.job.Scheduled already exists). #6 (state machine) is the highest later-leverage item but needs the most design. Each ships behind a .settings override and a parser-validated binding grammar.
Implemented and generating annotated client-Java off the shared EventBinding / NotificationSupport / ScheduleSupport / Criteria core: notifications (#2, email; direct field / one-hop relation.field / literal; when guard), schedules (#3, cron → typed-Criteria query → per-row notify), outbound integrations (#4, event → HttpClient), inbound webhooks (#5, @Controller ingest → entity), lifecycle triggers (process trigger on onCreate/onUpdate/onDelete + when guard, with a configurable businessKey field and an optional businessKeyStrategy: timestamp), and rollups (#9, recompute a parent counter via Criteria). Still open / blocked: documents (#7 - the PDF engine is XSLT/XSL-FO, needs an HTML→PDF path first), state machine (#6 - needs write-path transition enforcement, deeper than a listener), audit/history (#8 - needs a generated shadow entity), dynamic task assignment (#10 - touches BPMN user-task generation).
The canonical, verified showcase is IntentEngineIT's INTENT_YAML fixture - a single Orders app.intent that declares entities (incl. a setting + a composition), a process (trigger + decision/resolver + user/service tasks), forms, reports, roles, seeds, and every glue block above; the glue_template_generates_the_trigger_and_resolver_handlers test generates from it and asserts the whole catalog (trigger, resolver, notification, schedule job, integration, webhook, rollup×2) is produced from that one file. Mirror it into dirigiblelabs/sample-intent-model when publishing a runnable sample.
.accessrules from intent. The current PermissionIntentGenerator deliberately emits only.roles; URL-shaped constraints (the<path, method, roles>table in.access) need to know the paths the downstream template engine will publish, so they live with that template. A future pass should either (a) wire intent to feed those paths into the EDM template generator so it can emit the matching.access, or (b) add a custom-action.accessblock to intent for non-CRUD operations like {@code Order:approve} where there is no template-owned URL.- Lower-priority model-layer generators: DSM / schema / table / view / csvim / csv. The EDM-only entry already covers the same surface implicitly, so these are optional refinements rather than gaps.
- Chain the model-to-code templates from the editor's Generate button so the developer sees the full app, not just the model files. Today the user opens the generated
.edm/.formand clicks Generate there. The hook is the.gendescriptor that real-world Dirigible application projects keep next to each model file (<model>.genbeside<model>.model, one<form>.genper form): it recordstemplateId,filePath,genFolderName,tablePrefix,dataSourceand the perspective layout - exactly the parameters the IDE generation service replays. A futureGenDescriptorIntentGeneratorcould emit these with"tablePrefix": ""baked in (the prefix already lives in the intent-prefixeddataName), making the editor's Generate chain straight intoGenerateService.generateFromModel(...)- the exact mechanism the form-builder's Regenerate button already uses (seeeditor-form-builder/js/editor.js). - Reference layout: production Dirigible application projects are the canonical real-world shape this engine generates towards - model files (
<app>.edm/.model,<process>.bpmn,*.form,*.report) at the project root, template output undergen/, hand-written BPMN service-task handlers undertasks/(ourcustom/concept), generated translation skeletons undertranslations/. Their.formfiles match the FormIntentGenerator output shape key-for-key (metadata/feeds/scripts/code/form). - Translation skeletons (
translations/<locale>/<form>.form.json,<model>.model.json) as an additional intent generator, mirroring the production project layout. - Mark intent-generated model files as not-for-hand-editing in the IDE (decoration in the Projects view or a banner in the modelers).
/custom/escape-hatch directory + per-slice hook points in the generators (the generators must learn to preserve/custom/files alongside their gen output).reverse-engineer intentcommand for migrating classic projects.onScheduletriggers (only entity-lifecycle triggers are wired today) are folded into the "Planned: declarative glue" section above. (A configurable business key + atimestampgeneration strategy are now done — see the trigger semantics bullet and the Done list.) Note: no TypeScript counterpart - TS is being deprecated; all glue targets annotated client-Java (@Scheduledetc.).
Done:
- AI assistant (Claude chat + patch preview), third pane of the Intent Editor. See the "AI assistant" section below. Server-side Anthropic bridge (
agent/IntentAgentService+IntentAgentEndpoint,POST /services/ide/intent/agent); the key lives server-side inDirigibleConfigand never reaches the browser. The assistant proposes the complete updatedapp.intentvia apropose_intenttool; the editor renders a Monaco diff and Accept replaces the buffer (still unsaved) - the agent never writes disk or runs the generators. Honors "edit shape, not file shape" via prompt discipline (change minimally, preserve key order + comments).IntentEngineIT.agent_reports_when_not_configuredcovers the network-free 412 path. kind: settingentities ->type="SETTING", routed under the dashboard Settings menu (relations targeting them resolve to theSettingsperspective). See the YAML-semantics bullet above.- Java report template ("Application Report - Table - Java",
template-application-ui-angular-java/template/template-report-file.js+ui/reportFile.js): a Java port of the working "Application Report - Table V2" (template-application-ui-angular-v2/.../template-report-file.js). Generates the report server-side as Java instead of TS -template-application-dao-java/data/reportFileEntity.java.template(a plain repository running the report SQL viaDatabase.queryNamed(...), query embedded as a pre-escaped Java string literalqueryJava) +template-application-rest-java/api/reportFileEntity.java.template(@ControllerwithGET /,GET/POST /count,POST /search,POST /export) - reusing the existing Java report-file UI perspective (which already targets the Java controller URL). The intent.settingsreport recipe now defaults to this Java template (IntentSettings.scaffold). Added a Refresh button (sap-icon--refresh, before Export/Print) +refresh()to the report-file UI controller. The deprecated path (report from an EDM Report Entity) is NOT this - this is the standalone-.report-file template, confusingly namedreport-file/reportFilebut it IS the working "Table V2" lineage. - Postgres-safe report SQL: all physical table/column identifiers in the generated
.reportqueryare double-quoted (aliases are not). See theReportIntentGeneratorbullet's quoting note. H2 worked unquoted, so this only surfaces on Postgres. - Platform-links injection moved to request time (engine-web, commit
c9969c0517):HtmlPlatformLinksInjectoris now a Spring bean injected intoWebService.sendResource, which replaces<meta name="platform-links">when serving registry HTML; the racy in-place file watcher (HtmlPublicLinksLocalRegistryWatcherHandler) was deleted. This fixed the long-standing "tag not replaced on first publish" bug (the watcher read files mid-copy and lost the event). Note: theWEB_CACHEETag cache caches tags, not bodies, and is never invalidated - a browser may need a hard refresh once after this change. - Editor-first architecture:
editor-intent(split text + live diagram, Save, Generate) registered for*.intent;POST /parse+ workspace-targetedPOST /generate; no synchronizer, no JPA artefact, nothing in the registry until publish. - Structural validation on parse: duplicate names, dangling relation / form / report / seed targets, unknown field / relation / step kinds, decision then/else target checks, multi-PK and empty-seed checks. Surfaced via
IntentValidationExceptionwith the complete list of issues in one error message; the editor shows them inline. - All six v1 model-layer generators:
EdmIntentGenerator(entities -> .edm + .model),BpmnIntentGenerator(processes -> .bpmn),FormIntentGenerator(forms -> .form),ReportIntentGenerator(reports -> .report),PermissionIntentGenerator(permissions -> .roles),CsvimIntentGenerator(seeds -> .csvim + .csv). - Integration test
IntentEngineIT: parse (full model + all-issues-at-once validation), generate into the workspace project with content assertions on every artefact, the stale-output scrub, and 422 on invalid intents. HTTP-only, no Selenide, no synchronization cycles - the whole class runs in under a minute. - Stale-output scrub on regeneration (the project-root ownership contract above).
- Reliable
forceProcessSynchronizers(bounded wait instead of silent skip) incore-initializers- kept even though intent no longer uses it; it fixes a whole class of IT flakes. - Diagram pane rendered with mxGraph 4.2.2 (replacing Mermaid) using a fixed brand-colour palette that reads on both themes - this resolved the dark-mode invisible-lines and theme-switch "Syntax error" defects by construction.
- Source pane is an embedded Monaco editor with YAML highlighting (replacing the plain textarea), theme-synced to the IDE via
ThemingHub, reusing the/webjars/monaco-editor/...webjareditor-monacoalready ships. - Integer-only primary keys: the parser rejects a non-integer PK (
integer/int/long) and the EDM generator only emitsdataAutoIncrementfor integer columns - fixes theAUTO_INCREMENTon aVARCHAR(36)uuid PK that H2 rejected during full-stack table creation. - Opt-in composition:
composition: true(not "first required to-one") marks a master-detail;requiredalone is just a NOT NULL FK association. - EDM output aligned to the Dirigible conventions: PascalCase property names (columns stay UPPER_SNAKE),
auditType="NONE"andisRequiredPropertyon every property, and full relationship metadata on every FK property (relationshipType/Cardinality/Name+relationshipEntityName/relationshipEntityPerspectiveName/relationshipEntityPerspectiveLabel) - the last two are what the generated UI uses to build the FK dropdown's data-service URL; without them the dropdown loadedapi/undefined/undefinedController.tsand failed. - REST template fix (
template-application-rest-v2): the max-length validation is now guarded by$property.dataLength, so a CLOB/text property (no length) no longer leaks a literal${property.dataLength}into the generated controller. The Java REST template already had the guard. - CLOB on ALTER TABLE fix (
modules/database/database-sqlDataTypeUtils): atextfield maps to a CLOB column, and while CREATE TABLE accepted the literalCLOB, the second sync'sTableAlterProcessorfailed withType [2005] not supported-getDatabaseTypeNamemaps a JDBC type code back throughDATABASE_TYPE_TO_DATA_TYPE, which was missingTypes.CLOB(2005) andTypes.NCLOB(2011) even thoughSTRING_TO_DATABASE_TYPEparsed"CLOB"the other way. Both are now mapped toDataType.CLOB/NCLOB; covered byDataTypeUtilsTest. (Platform fix, not intent-specific - any CLOB column hit this on ALTER.) - Reports rewritten to the Dirigible
.reportshape with a materialised SQLquery(was empty),relation.field->INNER JOIN,filter-> qualifiedWHERE, and default-rolesecurity. Covered byIntentEngineIT(aggregate + join/filter reports). - Cross-artefact PascalCase: the
.formcontrolmodel/idbind to the PascalCase EDM property name; a bare to-one relation report dimension auto-joins and shows the target'sname-like field instead of the raw FK id. - Process triggers (
trigger: { onCreate: <Entity> }) fully wired in Java: validated by the parser; the newtemplate-application-events-java("Application - Glue Code - Java") template generates agen/events/<Process>Trigger.javaself-describingMessageHandlerthat starts the process on the entity's create event; the Java DAO template publishes that event. The EDM keeps only the persistedProcessIdcolumn (EdmIntentGenerator). Covered byIntentEngineITend-to-end (verified live: create → trigger → process start → ProcessId written back). ProcessIdconsumed by the generated entity-view UI (in-context BPM task surfacing). TheProcessIdthe trigger writes back is read by the generated views: a sharedProcessTasksAngularJS module (components/resources/resources-dashboard/.../dashboard/services/process-tasks.js— service +<entity-process-tasks>directive) fetches the current user's Inbox tasks once, buckets them byprocessInstanceId, and a record shows its actionable tasks inline by matchingentity.ProcessId === task.processInstanceId. Wired into every generated view (list,manage,master-list/master-managedetailandmain-details) gated on ahasProcessflag thatparameterUtils.jssets when an entity has aProcessIdproperty — so non-process entities generate unchanged. The generated task form (FormIntentGenerator) completes via the per-task permission-checked/services/inbox/tasks/{id}(not the role-guarded/services/bpm/bpm-processes/tasks/{id}, which blocks candidate-group users) and self-closes on completion via bothDialogHub.closeWindow()(dialog/inbox) andwindow.close()(standalone window). (#6074 + refinements #6075.)- Process glue externalized to
<intent>.glue(the precedent:.report/.formwere lifted out of the EDM). Thetriggers+resolverscollections live in.glue(GlueIntentGenerator), NOT the.model- the EDM describes entities, the BPMN describes flow, neither owns "who starts a process / how its context is populated". The Glue-Code template binds toextension: "glue";generateUtils.jshastriggers+resolverscollection cases. (Supersedes the older "triggers in the .model" wiring.) - Decision resolvers (
relation.field): a decision condition likebook.price > 500referencing a one-hop to-one relation of the trigger entity gets a${JavaTask}resolver service task inserted before the gateway and the condition rewritten to the resolved variable (book_price); thegen/events/Resolve<Relation><Field>.javaJavaDelegate(generated from.glue) loads the related entity at the decision and sets the variable.ProcessResolverSupport+IntentEntities(shared perspective/PK resolution). Rewrite happens on a copy of the step list so the glue generator still sees the original path. <intent>.settings(IntentSettings, loaded/scaffolded byIntentGenerationService.loadOrScaffoldSettings): developer-owned, scaffolded once then preserved (not scrubbed). Holds thegenerationrecipe (template id + parameters per model type), per-artefactoverrides({triggers|resolvers|forms}.<name>.generate=false-> skip and reuse a hand-written one), anduserTasks.candidateGroupsExtra(defaults toADMINISTRATOR, appended to every user-taskcandidateGroups). Loaded intoIntentGenerationContextbefore generators run; honored by the Glue/Form/BPMN generators. The Generate endpoint returns acodeGenerationsplan from the recipe + written files, and the editor's Generate chains model->code by replaying it throughgenerate.mjs. Cross-module tenant-context fix: the Java@Listenerdispatch (ListenerClassConsumer) now runs in the message's tenant context.- Declarative-glue catalog (notifications, schedules, integrations, inbound webhooks, rollups) generated as annotated client-Java off the shared
EventBinding/NotificationSupport/ScheduleSupport/Criteriacore, plus<intent>.gluecollections +template-application-events-javatemplates + thegenerateUtils.jscollection cases. The canonical showcase isIntentEngineIT'sINTENT_YAML; mirrored intodirigiblelabs/sample-intent-model. See "Status of the catalog". - Configurable trigger business key +
timestampstrategy.trigger: { businessKey: <field>, businessKeyStrategy: timestamp }— the started process's BPM business key is a chosen field (PK by default); thetimestampstrategy mints ayyyyMMddHHmmssvalue when blank.IntentEngineITcovers the flagged key, the mint+persist, and the parse rejection. The strategy field is the extension point for future pluggable number generators (sequential / padded / config-prefixed). - BPMN readable names. Unified lower-camelCase element ids (resolver task id is the lower-camel handler) + humanized task/gateway/process
names viaIntentNaming.humanize/camelCase; the process id stays compact. Asserted byIntentEngineIT.assertBpmn+IntentEditorLoadsIT(canvas shows "Librarian Review"). - Editor renders the glue + outputs. New
renderGlue()"Glue & Outputs" mxGraph section with SAP-icon-badged cards edged to their entities (see the diagram section).IntentEditorLoadsITasserts theloanUpdatedcard renders. - Parser hardening. A wrong-typed scalar (e.g. an unquoted brace recipient
to: {member.email}, which YAML parses as an object) now surfaces as a cleanIntentValidationExceptionissue with a helpful message instead of a raw 500 Gson error that wedged the editor.IntentParserTestcovers it. - Externalized AI system prompt. Moved from an inline string to
intent-assistant-guide.md(classpath resource, fail-fast load), corrected to the full current schema incl. the glue catalog +businessKey/businessKeyStrategy, and restored the propose-the-whole-file tool contract the draft had dropped. - CI runs on Corretto 24 (compile target stays 21); the integration-test fork gets
-Xmx6g. (Root-level change; recorded here because it landed alongside the intent work.)
Cross-artefact field naming: the .form control model (and control id) bind to the entity property, so they use IntentNaming.pascalCase to match the EDM property names (loanedOn -> LoanedOn). The .report references physical UPPER_SNAKE columns and humanized display aliases (no camelCase property identifiers), so it needs no PascalCasing.