From e33f19663e601c320ad7e1695dec3dc898a0ab88 Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 10 Sep 2026 17:11:47 +0300 Subject: [PATCH] intent: a cross-model schedules[].where status name is refused at Generate (#7288) #7251 resolves a seeded status NAME in a schedule's row query for a same-model source and leaves a cross-model one alone on purpose - its nomenclature is seeded in the owner model, and which of the conditions even names the status is unknowable from this file. But the skip was silent on both ends: the parser's own invariant check returns early for a cross-model source, and the generation-time check validates only that each where field EXISTS. So a name against a cross-model source parsed, generated and rendered as .eq("Status", "OVERDUE") against an integer FK - a query matching nothing for as long as the job kept ticking, with no diagnostic. That is #7251's failure one `model:` key away. The refusal is made where the owner's `.model` is in hand: at generation, off the DOCUMENT_STATUS widget that tells which property is the status one, exactly as the sibling cross-model `items: where:` rule is refused (#7225) - both now through one shared `crossModelStatusName`. The message names the relation, the name, the owner model and the id-only rule, and the parser/resolver javadoc that deferred to "keeps the numeric seed id" now says where a name is refused instead. Only the status condition is refused: every other condition compares an ordinary column, where a string literal is just a literal. A seed id renders unchanged. Verified: engine-intent unit suite green (1211 tests), three new tests in GlueSchedulesTest (the refusal names all four facts, a seed id still renders as Criteria.create().eq("Status", 4), a string on a non-status condition still renders), formatter:validate with the cache wiped, and the -P release javadoc build on the module. No integration test run - no IT fixture declares a cross-model schedule with a status condition. Fixes #7288 --- components/engine/engine-intent/CLAUDE.md | 2 +- .../intent/generator/GlueIntentGenerator.java | 51 ++++++- .../intent/parser/IntentParser.java | 9 +- .../intent/parser/StatusSymbolResolver.java | 8 +- .../main/resources/intent-assistant-guide.md | 3 +- .../intent/generator/GlueSchedulesTest.java | 124 ++++++++++++++++++ 6 files changed, 185 insertions(+), 12 deletions(-) diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 84066c2a4c7..36e84bbb376 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -419,7 +419,7 @@ Semantics worth knowing: - **`trigger: { onCreate|onUpdate|onDelete: , when: "" }` starts the process on the named `` lifecycle event** - fully wired (Java). Any of the three events is supported: `onCreate` binds the entity's base topic, `onUpdate`/`onDelete` the `-updated`/`-deleted` topics the Java DAO publishes (`TriggerSupport` + `EventBinding`); an optional `when` guard (a single `field ==|!= literal`, via `NotificationSupport.guard`) gates `Process.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 a `ProcessId` back-reference property (VARCHAR) plus the per-process `ProcessIds` stamps column (VARCHAR, `Process=instanceId` pairs) to that entity and a `triggers` collection to the `.model` (`TriggerSupport` + `EdmIntentGenerator.buildTriggers`); (3) the **`template-application-events-java`** template (intent-driven, like the other language templates) reads that `triggers` collection and emits one **`gen/events//Trigger.java`** per trigger - a client-Java self-describing `MessageHandler` (a `@Component` whose `destination()` is the entity's per-operation topic via `topicSuffix` and whose `kind()` is `TOPIC`) that loads the entity, applies the `when` guard, calls `Process.start(, businessKey, )`, and writes the instance id back to `ProcessIds` against its own process name plus `ProcessId` (so THIS process starts at most once - one `ProcessId` cannot say WHICH process ran, and reading it as "some process ran" silently skipped every follow-up flow on an already-stamped record, #6862). **The write-back is crash-safe by construction (#6815):** the per-process stamp in `ProcessIds` IS the at-most-once guard while the start and the write-back commit independently, so everything that can precede the start does — the minted business key is persisted first, and every process variable (the `__entityUrl`/`__entityId` locators, the FK locators, `__personalUser`) rides the start payload instead of a post-start `setVariable` (all are known up front, and a wait-state-less process finishes inside `start`, where a `setVariable` would then throw). The one remaining post-start step is the targeted `updateProperties` of both process columns at once (a record carrying one without the other is either invisible to the task UI or blocked from ever starting the flow again), which a `checks:` gate can no longer refuse (the generated repository runs `enforceChecks` only for a write that touches an **authored** column - a gate has no opinion about which process handles the document, and by then the instance is running), a swallowed start (`null` id) is logged rather than written, and if the write still does not land — the row was deleted meanwhile, or it threw — the instance is **cancelled** (`Process.cancel`) and the failure re-thrown, rather than left running with nothing pointing at it. 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/events/` (the `` segment = the sanitized intent name, `IntentNaming.javaModule`) is a sibling of `gen/`, so it survives the per-model regeneration wipe. The events template iterates the model's `triggers` via a new **`triggers` collection case in the generation pipeline's `ModelGenerator`** (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: }` names which trigger-entity field becomes the started instance's business key (the listener still loads the entity by its PK via `findById`; only the business key differs — a separate `businessKeyProperty` in `.glue`). An optional `businessKeyStrategy: timestamp` mints a `yyyyMMddHHmmss` value 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 (for `timestamp`) the field is `string`/`text`. `TriggerSupport.triggerBusinessKey`/`triggerBusinessKeyStrategy` read them; `GlueIntentGenerator` emits `businessKeyProperty` + `generateBusinessKey`; `Trigger.java.template` renders the mint-if-blank block. `onSchedule` is still unmodelled. **Casing subtlety in the generated handler:** its `import gen..data..{Entity,Repository}` must use the **lowercased** Java package segment (`javaPerspective` = `sanitizeJavaIdentifier(perspective)`, matching the DAO/entity templates' `javaPerspectiveName` folder), while the `destination()` topic (`"--"`) keeps the **raw** perspective so it matches the topic the DAO publishes to (`${projectName}-${perspectiveName}-${name}`). The `triggers` collection case in the pipeline supplies both (`javaPerspective` for the import, `perspective` for the topic). Using the raw perspective in the import compiled on macOS (case-insensitive FS) but failed `javac` with "package gen.x.data.Member does not exist" because the entity files declare the lowercased package. - **`dependsOn` on a to-one relation or a field = the EDM Depends-On feature (cascading dropdowns + auto-populated fields).** `dependsOn: { relation: , valueFrom?: , filterBy?: }` — the widget reacts to the sibling trigger: the generated form loads the trigger's selected record, reads `valueFrom` (default: the trigger target's PK), then a **relation** re-filters its dropdown options where its own target's `filterBy` (default: that target's PK) equals the value (`POST /search` with an EQ condition; a single remaining option auto-selects), while a **field** copies the value (auto-population; `valueFrom` mandatory, `filterBy` rejected). Emitted by `EdmIntentGenerator.putDependsOn` as the four scalar `widgetDependsOn*` property attributes the AngularJS stacks already consume (so those work for free); the Harmonia runtime was added in the same pass (`form-page.js.template` per-property watcher + `applyDependsOn` methods covering manage/master-detail/allocation forms; `document-page.js.template` header watchers + a generic metadata-driven `applyDraftDependsOn` for the line-item dialog off `detail-register.js.template`'s `editColumns[].dependsOn`, with filtered options in a separate `draftOptions` store so the items table's label resolution keeps the full set; `ModelParameterProcessor` precomputes `widgetDependsOnControllerUrl` from the trigger sibling). `valueFrom`/`filterBy` use the target's **authored** property names (field lower-camel / relation as declared); same-model references are parse-validated, cross-model ones generation-validated against the resolved owner model (`CrossModelSupport.TargetInfo.propertyNames`). A `documentStatus` relation can neither declare nor trigger a dependsOn. Canonical cases (the `codbex-sample-model-depends-on` set): Country→City cascade (`filterBy` only), Product→UoM narrow-to-referenced (`valueFrom` only), Product→price auto-populate (field). **Conditional auto-populate (#6358):** a FIELD's `valueFrom` may be `{ by: , cases: { : }, default?: }` — the copied trigger-target property is picked by a classifier resolved from the `by` path (own property / one-hop `.` / a path starting at the composition parent relation = the open document header). Parser `validateConditionalValueFrom` (shape, path segments, case/default properties against the trigger target); EDM emits `widgetDependsOnValueBy` (+`ByHeader`/`ByHeaderEntity`/`ByEntity` for the hop fetch), `widgetDependsOnValueCases` (JSON string, PascalCased properties), `widgetDependsOnValueDefault`, and NO `widgetDependsOnValueFrom`; `ModelParameterProcessor` derives `widgetDependsOnValueByUrl` (the hop record's controller); Harmonia consumes it via `resolveDependsOnSource` (document page: dialog + header form; `resolveDependsOnSource` on the manage form) — Harmonia-only, the AngularJS `#if` guards skip it (no `ValueFrom`). Editor round-trip: the six attrs are in `model.js`/`serializer.js` (no dialog UI - intent is the source). **Header-mediated trigger (#6358, the issue's other half):** `relation: .
` on a document ITEM field (`relation: SalesInvoice.Customer, valueFrom: standardDiscount`) makes the line default from a record the open DOCUMENT points at instead of one of the line's own relations - the canonical case being a line discount defaulting from the header partner's terms. Parser `validateHeaderMediatedDependsOn` (fields only - a header selection has no option list to cascade, so `valueFrom` is mandatory and `filterBy` rejected; the first segment must be the composition parent, the second a to-one of the header, and `valueFrom` resolves against THAT relation's target). `putDependsOn` resolves the trigger through the header and adds `widgetDependsOnHeader` + `widgetDependsOnHeaderEntity`; `ModelParameterProcessor` resolves `widgetDependsOnControllerUrl` on the HEADER entity (the trigger is not a property of the item). Harmonia: `detail-register` emits `dependsOn.header`, and `document-page` gains `applyHeaderDependsOnToDraft` - called on a CREATE draft open and from a `form.` watcher while the dialog is open, never on an edit draft (the stored value may be a deliberate override); `applyDraftDependsOn`/the draft watchers explicitly skip header columns so a same-named row column cannot drive them. **Every sibling-assuming stack is guarded** (`&& !$property.widgetDependsOnHeader` in the four `-java`/`-v2`/legacy AngularJS controller templates and the Harmonia `form-page`) - without it they emit a watcher on `entity.` / `this.form.` that does not exist on the item. Composable with the conditional `valueFrom`. - **`postings:` (top-level) = declarative posting (source-document status → generated local document + computed items).** The accounting "documents → ledger" capability, generalized (spike-derived; see the driving suite's spike findings). `PostingIntent` + parser `validatePostings` (creates = local document owning a composition items child; backReference = its to-one to the source, the at-most-once guard; event trigger `onTransition` with a mandatory `when: " == "` status guard, or `onCreate` for a source with NO status lifecycle - a booked payment - binding the `-created` topic with the `when` guard optional (#6421); item cells = `rule()` refs into a single-selector rule entity or Calc arithmetic over the source; row `when: ==|!= `). `GlueIntentGenerator.buildPostings` pre-renders EVERYTHING as Java expressions (the expansions convention — the template stays shape-only): topic + re-load coordinates via `CrossModelSupport`, guard, header assignments (copy / literal / `{placeholder}` concat), `ruleRow.` refs, `Calc.eval("", source, )` amounts with the scale from the LOCAL item field, null-safe Calc row guards. `postings` glue collection → the pipeline's collection case (source gen folder = sanitized model alias, topic keeps the RAW perspective) → `Posting.java.template`: a `MessageHandler` on `---transitioned` (#6220's channel) that re-loads the source by id (the payload lacks later-step data — the stamped number), guards, resolves the rule row (missing row / null referenced column → SKIP, the unposted worklist), and writes target + items through the repositories — so numbering / status `init:` / `checks:` fire on the created document. **Idempotent + resumable + amendable, and the post itself is ONE transaction** — the handler's own writes (the stale rows a rewrite replaces, the header, every derived line) share a `UnitOfWork`, so a line the item repository refuses leaves the previous post standing instead of a header with a partial line set (#7132: unlike the half-post case there is no second event to self-heal from, so a partial rewrite ends up worse than the stale but balanced post it set out to fix). Across STEPS the model is unchanged and deliberately not transactional — the source's own commit, this handler's post and a reversal are separate events, and a bad post is unwound by a correcting entry, not a rollback: the handler derives the WHOLE content first and compares it with the post the back-reference finds — identical is a redelivery (no-op), different is either a HALF-post (an item write failed after the target was saved) to complete or an AMENDED source to rewrite from. The amendment half is #7071: the amend path (Confirm → Reject → edit the lines → Issue again) raises the SAME moment a second time, and the old `item count ≥ expectedItems` test read that as "already posted", so the entry kept the amounts of the previous issue while the document it references had moved on — no second entry (right) and a ledger 60.00 short (wrong), silently. Now the existing post is REWRITTEN in place (header assignments re-applied through `update`, items replaced) — but only while nobody has acted on the created document, which the posting itself defines: its `function: EntityStatus` relation still holds the `init:` the posting's own create wrote (a target with no status lifecycle is always rewritable, one whose status is declared without an `init:` is rewritable while still empty). Once it has moved, the divergence is LOGGED naming both documents and the entry is left alone — unwinding a posted entry is a correcting entry's job (`reverses:`), not a silent overwrite. The comparison is order-insensitive (row order is not a query guarantee) over the union of every cell the item rows assign (`itemComparedProps`), and numbers compare by VALUE so a rescaled amount is not a change. **It is over the values as they will be STORED, not as the derived rows stand**: `save()` fills a column before the insert, so each compared property carries the default its own derived side will end up with (#7131), and a column the WRITE fills is compared accordingly (#7177, #7234) - a column filled UNCONDITIONALLY is dropped from the comparison entirely and the discarded assignment reported at generation (its value never stays in the column, so left in it is a difference no redelivery can ever clear): a `calculatedOnCreate`/`calculatedActionOnCreate` one; an `aggregate: true` header column the lines also declare, which the document master's `recalculate()` sets to the SUM over the lines on every write (#7234 - the item sum the write stored against the source value the `map:` computed, off by a rounding, a sign convention or a partially posted line set; the master is resolved through `IntentEntities.documentMasters`, the SAME rule the `MANAGE_DOCUMENT` layout and so the DAO's `documentMaster` are emitted by, never through the broader `documentItemsChild`); and, on the created document only - it is rewritten IN PLACE through `update()`, where its lines are deleted and re-inserted - a `calculatedOnUpdate`/`calculatedActionOnUpdate` column (recomputed on every rewrite) or any `aggregate`/`readOnly` one (`update()` preserves it from the stored row), whose mapped value survives the create and is discarded by every rewrite after it, so after one legitimate amendment the compared cell mismatched forever. A `uuid` or `number:` one - filled only when the row leaves it empty, like a `date`/`timestamp` default only the DATABASE can apply - is compared only for the rows that do derive it. Header `map:` expressions are hoisted into numbered locals so the comparison and the assignment read ONE evaluation, after the back-reference lookup so a return that writes nothing never pays for them; `amendableGuard`/`itemComparedProps` are pre-rendered into the glue like everything else, and both default to the pre-#7071 behaviour when a `.glue` predates them - and `bindPosting` reads the "compare only when derived" flag under its #7163 spelling `expressionDefault` wherever the `compareOnlyWhenDerived` #7188 renamed it to is absent - through ONE rule, in the header-assignment normaliser #7256 added and in `GlueGenerator.comparedCells` for the item cells - so a `.glue` generated between the two keeps its CURRENT_DATE-default treatment instead of silently falling to a plain `same()` until the intent is re-generated (#7234). Concurrent-redelivery de-duplication is best-effort (a check-then-act on the back-reference) until a real UNIQUE key on the back-reference lands with schema constraint emission. Storno/negation mode LANDED as **`reverses:`** (paired with the `transitions:` void primitive - the "void-document event" is a transition into the void status): a reversal posting inherits creates/backReference/rule/map/items from the reversed sibling, negates every item amount expression on the SAME side (`Calc.eval("-()", ...)` - red storno), locates the original through the empty `storno:` self-link (none -> fail-soft skip), stamps the link on its creation, and both handlers' idempotency guards discriminate by that link (reversal counts linked rows, the sibling counts unlinked ones - `stornoProperty`/`stornoFilterProperty` in the glue). The explicit manual Reverse action (no source void) remains a follow-up. Compensation, not a transaction, is how a bad post is unwound. -- **Lifecycle-aware aggregates: seed-row `stage:` + report `scope:` + symbolic status names (#6645).** An aggregate over an entity carrying a `function: EntityStatus` was **wrong by default** - drafts nobody had issued, cancelled and voided (анулиране) rows all landed in the sum unless the author remembered a magic-number status predicate in `filter:`, and nothing said so (the motivating case: a voided invoice kept its 2000 in "Revenue this month" because the report declared dimensions + measures and no `filter`, so the emitted query had no `WHERE` at all). Four coordinated pieces, all in `LifecycleStages` + `ReportIntentGenerator.scopePredicate` + `StatusSymbolResolver`: (1) a status **seed row** classifies what the status MEANS with a closed-vocabulary `stage: draft|live|cancelled|void` - metadata, never a column (the CSV generator only emits declared fields + referenced FKs, and `CsvimIntentGeneratorTest` pins that); (2) a report declares `scope: all` or a stage name, emitted as `."" IN ()` ANDed onto the filter; (3) with the nomenclature classified, an **aggregating** report **defaults to `live`** - but only when its dimensions/`filter` do not already reference the status (a breakdown BY status must keep its draft rows, and an authored predicate is authoritative), so an existing model is byte-identical until it adopts `stage:`; (4) every site that names a status accepts the **seeded name** (`from: [ISSUED]`, `setStatus: VOIDED`, `init: DRAFT`, `setRelationField` `value:`, `abortOn.status`, a check's `status`/`setStatus`, `immutableWhen`, a posting's `event.when`, a report's `filter`, and the status condition of a `where` row query - a `schedules[]` one ([#7251](https://github.com/eclipse-dirigible/dirigible/issues/7251)) or a create-from's `items:` rule ([#7091](https://github.com/eclipse-dirigible/dirigible/issues/7091)), both through the shared `StatusSymbolResolver.rewriteConditions`, each on the QUERIED entity's own nomenclature) - resolved on the **raw YAML tree before the typed Gson mapping** (the `rejectRemovedNumberKeys` precedent), so every validator, generator and template keeps seeing plain integers. **Why names matter more than they look:** an id is positional, so inserting a status mid-nomenclature shifts every later id and silently retargets every guard authored against the old numbering - that is how a `reverses:` posting guarded `when: "Status == 8"` stopped matching a Void that now writes 9, leaving the ledger with a receivable for a document that no longer existed, with well-formed Java emitted throughout. **Boundaries, deliberate:** the nomenclature must be seeded IN THIS MODEL - the parser holds one file and no repository, so a **cross-model** status can neither be stage-scoped nor named (both fail loudly naming the numeric-id fallback; cross-model symbols need the name→id map on the generated `.model` and are follow-up work). A symbolic **ordering** comparison (`Status >= ISSUED`) is rejected - names have no order, that is what `scope:` is for. A nomenclature that declares its own `stage` property collides with the marker and is rejected rather than guessed. Nothing is emitted into the `.model` for `stage` - no consumer needs it yet (the Harmonia badge's `statusVariant` keyword guess is the obvious future one). **Part 3, the cheap half that catches everything the other three cannot:** when a report aggregates over a lifecycle entity and neither declares `scope:` nor filters on the status AND the nomenclature is unclassified, generation records a `context.addIssue` warning - surfaced in the generate response's `warnings` and now in the **Intent Editor**'s own amber strip (it used to discard them on success; the Builder shell already showed them). That warning, not the default, is what turns an invisible modelling omission into a visible one. **And the invariant is checked at the consuming site too, independently of the resolver's site list:** a `where` condition on the queried entity's `function: EntityStatus` relation must carry an integer by the time validation runs (`IntentParser.validateWhereStatusValue`), so a value no status can equal is refused instead of rendering `.eq("Status", "OVERDUE")` into a query that matches nothing for as long as the job keeps ticking. `schedules[].where` was left behind for exactly that reason - #7091 taught the resolver the items rule and not the construct it was modelled on, and nothing anywhere failed. +- **Lifecycle-aware aggregates: seed-row `stage:` + report `scope:` + symbolic status names (#6645).** An aggregate over an entity carrying a `function: EntityStatus` was **wrong by default** - drafts nobody had issued, cancelled and voided (анулиране) rows all landed in the sum unless the author remembered a magic-number status predicate in `filter:`, and nothing said so (the motivating case: a voided invoice kept its 2000 in "Revenue this month" because the report declared dimensions + measures and no `filter`, so the emitted query had no `WHERE` at all). Four coordinated pieces, all in `LifecycleStages` + `ReportIntentGenerator.scopePredicate` + `StatusSymbolResolver`: (1) a status **seed row** classifies what the status MEANS with a closed-vocabulary `stage: draft|live|cancelled|void` - metadata, never a column (the CSV generator only emits declared fields + referenced FKs, and `CsvimIntentGeneratorTest` pins that); (2) a report declares `scope: all` or a stage name, emitted as `."" IN ()` ANDed onto the filter; (3) with the nomenclature classified, an **aggregating** report **defaults to `live`** - but only when its dimensions/`filter` do not already reference the status (a breakdown BY status must keep its draft rows, and an authored predicate is authoritative), so an existing model is byte-identical until it adopts `stage:`; (4) every site that names a status accepts the **seeded name** (`from: [ISSUED]`, `setStatus: VOIDED`, `init: DRAFT`, `setRelationField` `value:`, `abortOn.status`, a check's `status`/`setStatus`, `immutableWhen`, a posting's `event.when`, a report's `filter`, and the status condition of a `where` row query - a `schedules[]` one ([#7251](https://github.com/eclipse-dirigible/dirigible/issues/7251)) or a create-from's `items:` rule ([#7091](https://github.com/eclipse-dirigible/dirigible/issues/7091)), both through the shared `StatusSymbolResolver.rewriteConditions`, each on the QUERIED entity's own nomenclature) - resolved on the **raw YAML tree before the typed Gson mapping** (the `rejectRemovedNumberKeys` precedent), so every validator, generator and template keeps seeing plain integers. **Why names matter more than they look:** an id is positional, so inserting a status mid-nomenclature shifts every later id and silently retargets every guard authored against the old numbering - that is how a `reverses:` posting guarded `when: "Status == 8"` stopped matching a Void that now writes 9, leaving the ledger with a receivable for a document that no longer existed, with well-formed Java emitted throughout. **Boundaries, deliberate:** the nomenclature must be seeded IN THIS MODEL - the parser holds one file and no repository, so a **cross-model** status can neither be stage-scoped nor named (both fail loudly naming the numeric-id fallback; cross-model symbols need the name→id map on the generated `.model` and are follow-up work). A cross-model **row query** is the one site where the parser cannot even say so — which of its `{ field, op, value }` triples names the status is knowable only from the owner's `.model` — so the refusal is made where that model is read, at generation: a condition on the owner's `DOCUMENT_STATUS` property whose value is not an integer is a 422 naming the relation, the name, the owner model and the id-only rule, for a create-from's `items:` rule ([#7225](https://github.com/eclipse-dirigible/dirigible/issues/7225)) and for `schedules[].where` ([#7288](https://github.com/eclipse-dirigible/dirigible/issues/7288)) alike — both through the shared `GlueIntentGenerator.crossModelStatusName`. Left silent, the schedule one was #7251's own failure mode one `model:` key away: `.eq("Status", "OVERDUE")` against an integer FK, matching nothing forever. A symbolic **ordering** comparison (`Status >= ISSUED`) is rejected - names have no order, that is what `scope:` is for. A nomenclature that declares its own `stage` property collides with the marker and is rejected rather than guessed. Nothing is emitted into the `.model` for `stage` - no consumer needs it yet (the Harmonia badge's `statusVariant` keyword guess is the obvious future one). **Part 3, the cheap half that catches everything the other three cannot:** when a report aggregates over a lifecycle entity and neither declares `scope:` nor filters on the status AND the nomenclature is unclassified, generation records a `context.addIssue` warning - surfaced in the generate response's `warnings` and now in the **Intent Editor**'s own amber strip (it used to discard them on success; the Builder shell already showed them). That warning, not the default, is what turns an invisible modelling omission into a visible one. **And the invariant is checked at the consuming site too, independently of the resolver's site list:** a `where` condition on the queried entity's `function: EntityStatus` relation must carry an integer by the time validation runs (`IntentParser.validateWhereStatusValue`), so a value no status can equal is refused instead of rendering `.eq("Status", "OVERDUE")` into a query that matches nothing for as long as the job keeps ticking. `schedules[].where` was left behind for exactly that reason - #7091 taught the resolver the items rule and not the construct it was modelled on, and nothing anywhere failed. - **`lifecycle:` on an entity = the declarative state machine (#6714).** The whole set of legal status edges, declared once over the entity's `function: EntityStatus` nomenclature (`edges: [{ from: DRAFT, to: [ISSUED, CANCELLED] }, ...]`, either side a seeded name or an id) and **enforced on every status write**. The gap it closes: the status machinery was a set of point constructs - `init:` names the start, a `transitions:` button guards the flips that go through THAT button, a workflow `setRelationField` writes one unguarded, a `checks:` rejection files another - and nothing declared which edges were legal at all, so any other writer (a workflow branch, a glue action, a plain REST call) could jump a document from any status to any other and nothing noticed. **Enforcement lives in the generated REPOSITORY, deliberately** (`Repository.java.template`: `LIFECYCLE_EDGES` + `enforceLifecycle` / `enforceLifecycleMove` / `enforceLifecycleStart`, `ValidationException` -> 400) - it is the ONE choke point every writer passes through: `update` (the REST payload), `updateWithoutEvent` (system writes), and `updateProperties` (which `updateProperty`, and therefore the transition controller, the workflow setters and `updateDerived`, all route through - so the targeted-write overrides are now emitted for a lifecycle entity too, not only for `documentChecks`/`hasLabel`). Guarding the transition endpoints instead would have left every other writer free, which is the whole defect. `enforceLifecycleStart` (emitted only when the status relation declares `init:`) additionally refuses a CREATE filed anywhere but at the start - entering the lifecycle mid-graph skips it rather than travelling it - and is placed BEFORE the aggregate-guard macros in `save()` so an `outcome: reject` can still file the record where the model says. Emission is three scalars on the entity map (`lifecycleStatusProperty`, `lifecycleEdges` as `1>2,1>9` pairs, `lifecycleStatusNames` as `1=DRAFT,...` so a rejection reads "cannot move from ISSUED to DRAFT" instead of quoting positional ids, plus `lifecycleInitialStatus`) - scalars, so they reach the `.edm` twin like `immutableStatusValues`. **Parse-time is where the other status sites are made to agree** (`validateLifecycles`): every `from` of a `transitions:` entry must reach its `setStatus` along an edge (a button is presentation over the graph), and a status written by a `setRelationField` step or forced by a check's rejection must be one some edge reaches - which is what catches a reject path transiting through an approved status when the file is read. **Deliberate boundaries:** no `on:` key - the graph is always over the EntityStatus relation, so naming it would be redundant, and YAML 1.1 reads a bare `on` as the boolean `true` (it would arrive as the key `true` and bind to nothing), so `rejectLifecycleOn` refuses it in the raw-tree preprocessing rather than dropping it silently; a cross-model nomenclature is seeded in its owner model and so is its lifecycle (refused, naming that); the nomenclature must be seeded here (the ids are validated against the seeds); no reachability check - one nomenclature may serve two entities with different graphs, so "unreachable here" is not an error. - **`immutableWhen:` / `immutable:` on an entity = user-write immutability.** `immutableWhen: "Status == 2"` (a boolean expression over EntityStatus seed ids, terms joined with `||`) makes update/delete through the generated REST controller answer 409 CONFLICT while the record's `function: EntityStatus` FK satisfies it; `immutable: true` is the unconditional append-only variant (mutually exclusive with `immutableWhen`; a non-existent id still yields 404, not 409). Emitted as the entity-level `immutableStatusProperty` + `immutableStatusValues` (or `immutableAlways`) model attrs; `requireMutable` fetches the existing row before writing. Repository writes are deliberately unaffected — the workflow (storno generation, roll-ups, ProcessId write-back) keeps working; this guards the USER surface, per the accounting audit-trail requirement (corrections are reversals, never edits). **The UI is gated up front, not just on the 409:** each of the three generated controllers (power / partner / my) also exposes a **`GET /{id}/mutable`** pre-check (`{"mutable": true|false}` via the shared `isMutable`, scoped like its reads), and every Harmonia surface consumes it — the manage form and document pages ask it on edit load and force the read-only preview mode with a "Read-only" title badge (so a directly typed `/edit` URL opens read-only), the partner/my form + document pages disable their controls (`fieldset :disabled`) and hide Save/Delete/item actions, while the browse tables (manage list, master) gate row Edit/Delete through a **baked `isRowImmutable(row)`** computed from the row's status FK against the generation-time immutable ids — no per-row API call, same generated-from-the-same-attrs no-drift argument as the client `validationSchema`. The pre-check fails OPEN (an outage must not lock the UI); the PUT/DELETE 409 stays the authoritative guard. Covered by `IntentEmissionCoverageIT` (endpoint tokens + page tokens + mutable=false/true over REST). Parser requires an EntityStatus relation. Alongside it (no DSL): every generated controller now maps a **database constraint violation on DELETE to 409** ("referenced by other records") instead of a 500. Scope of that mapping: the schema template does emit `type: "foreignKey"` structures, but `SchemasSynchronizer.parseImpl` drops them **by design** — a foreign key never becomes a database constraint on this platform, because a constraint binds insert/delete ORDER into the schema where seeds, imports, regeneration and deletes would all have to obey an ordering nothing in the model asked for; referential integrity is a business-layer check. Only the **unique** keys are carried over (`carryUniqueConstraints`, #6793), so the 409 engages for a business-key collision and never for a reference. Anything that must not outlive the record it points at therefore needs an explicit handler — which is what an expansion's `OnDelete` cleanup is (#6821). Date-based period locking (records whose date falls in a Locked period) is deliberately NOT part of this — its shape needs the real fiscal-period module and follows as its own PR. **The lock reaches the master's composition CHILDREN (#6695).** It was per-entity, and a child declares no immutability of its own — while its generated repository writes THROUGH to the master, recomputing `net`/`vat`/`total` on every `save`/`update`/`delete`. So `POST`/`PUT`/`DELETE` on a line of an ISSUED invoice succeeded over REST and silently rewrote the document's totals after the number was stamped, the immutable snapshot taken and the ledger posted — the UI forbade it, REST permitted it, and the permitted operation was the one `immutableWhen` exists to prevent. `ModelParameterProcessor.inheritMasterLock` now propagates the master's `immutableAlways` / `immutableStatusProperty` + values onto each direct composition child as a `masterLock` map (master entity + FK property + its `…Entity`/`…Repository` classes, resolved through the composition FK's perspective exactly as the personal/partner inheritance does), and all three generated controllers (power / partner / my) emit a `requireMasterMutable` that loads the master and answers the same 409 — on create (the payload's FK), on update (the STORED master *and* the incoming one, so a line cannot be moved into a locked document either), on delete, and on an attachment upload. Engine writers stay exempt by construction: they go through the repository, not the controller — which is why the issue-time snapshot generator (`Attachments.store` + `repository.save`) is untouched. The opt-out is the flag #6700 already introduced: `locksWithMaster: false` on the child (settlement is a different lifecycle from content), so the affordance and the REST guard are governed by one declaration and cannot drift apart. Only the DIRECT child is covered — that is the shape that writes through to the master. It composes with the prompted `generates` action (#6685): that create runs through the TARGET's repository, not a controller, so a guided create against a post-issue child keeps working on a locked document exactly as its per-record button (deliberately not gated on mutability) implies — the panel and the action remain the two separate answers to "this collection must go on being recorded". `IntentEmissionCoverageIT` carries both controls: `EntryLine` (silent → inherits) is refused create/update/delete on a POSTED entry and the master's total is asserted UNMOVED, while `CampaignNote` (`locksWithMaster: false`) still posts to a locked campaign. - **`period:` + `immutableInPeriod:` = date-based immutability, the fiscal-period half of the lock (#6535).** `immutableWhen` guards a record by what it IS; this guards it by WHEN it falls - once the accountant closes March, nothing dated in March may be created, edited or deleted, whatever status it carries. The shape the issue asked for is deliberately TWO declarations, not one: a fiscal period is an ordinary entity (two dates and a lifecycle), so a **`period: { start, end, closedWhen }`** marker on the register states the facts that live with the register - which fields are the bounds (both `date`; a timestamp would make "the period covering this date" depend on a time of day nobody authored, and the end is inclusive) and which statuses mean CLOSED (the `immutableWhen` grammar over its own EntityStatus, so a seeded name resolves through `StatusSymbolResolver` like every other status site) - while each guarded entity spends ONE line, **`immutableInPeriod: { period: , date: }`**. Closing a period needs no new machinery: it is a status transition, so a `transitions:` button, a `lifecycle:` edge or a workflow step does it, and nothing in this feature ever WRITES the register. **Enforcement is the controllers, not the repository** - the same line `immutableWhen` draws, and the whole point of the issue: workflow/system writes (the reversal booked into an open period, a roll-up, the ProcessId stamp) must keep working. Three differences from the status guard, all deliberate: a **CREATE** dated inside a closed window is refused (that is what closing a period MEANS - `immutableWhen` has no create to guard, a fresh record has no status yet), an update that would **MOVE** a record into a closed window is refused as well (the `requireMasterMutable` stored-and-incoming precedent), and a date covered by **no** period is OPEN - periods are opened as they are needed and an undeclared month must not freeze what is booked into it, so "no covering row" can only mean open (an unset date likewise falls in none). Emission is the established split: each entity carries only its own facts as `.edm` scalars (`periodStartProperty`/`periodEndProperty`/`periodStatusProperty`/`periodClosedValues` on the register, `periodLockEntity`/`periodLockDateProperty` on the guarded one) and `ModelParameterProcessor.resolvePeriodLock` joins them into the `periodLock` map the controller templates read - the pass that already knows every entity's generated package, exactly as `inheritMasterLock` does. **The lock reaches composition CHILDREN** through that same `masterLock` map (which gained `period`; the status half of the child's guard is emitted on `always || statusProperty` rather than on a flag, so a master locked by its period ALONE emits no status branch - `ChildLockControllerTemplateIT` renders these templates against a HAND-BUILT masterLock map, so a new required key there is a silent branch loss, and a derivable one cannot drift): a line write recomputes the document's totals, so a document dated in a closed period freezes its lines with it - the #6695 argument, and `locksWithMaster: false` is still the one opt-out. The UI needs no new mechanism either: the pre-check the status lock already exposes (`GET /{id}/mutable`) now answers for both halves, so a directly typed `/edit` URL opens read-only; the browse tables keep their BAKED per-row status check, which a data-driven period lock cannot join (a row's Edit opens a read-only form instead of being hidden - stated, not hidden). **Boundary, refused loudly:** the register must be an entity of the SAME model. The guard is generated into this model's controllers and queries the register's generated repository; a cross-model register is emitted as a read-only PROJECTION with no local DAO, so there would be nothing to query - it fails at parse naming that, rather than generating a guard that silently never fires. `IntentEmissionCoverageIT` carries the whole loop over the register's own lifecycle (book into an open period, close it, then 409 on edit/delete/create-into/move-into, `mutable=false`, and an uncovered date still writable) because the lock is DATA-driven: a token assertion alone would pass against a guard that never matches. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 7e1b2fba321..df4454aa359 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -4119,6 +4119,19 @@ private static List> buildSchedules(IntentModel model, Map entry = new LinkedHashMap<>(); @@ -4348,8 +4361,17 @@ private static EntityIntent crossModelRow(String entity, CrossModelSupport.Targe * mapping and action shape). */ static List> buildSchedulesForTest(IntentModel model) { + return buildSchedulesForTest(model, null); + } + + /** + * Test hook: build the {@code schedules} glue collection against a context, so what the generation + * reads off a cross-model source's owner {@code .model} - its perspective, its key, and which of + * its properties is the status relation - is the real fact rather than a naming-convention default. + */ + static List> buildSchedulesForTest(IntentModel model, IntentGenerationContext context) { return buildSchedules(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}"), - null); + context); } /** @@ -4620,12 +4642,33 @@ private static NotificationSupport.CrossModelLookup crossModelLookup(IntentModel * of a unit test), in which case nothing here can tell which condition is the status one. */ private static ScheduleConditionIntent crossModelItemStatusName(GeneratesItemsIntent items, CrossModelSupport.TargetInfo itemSource) { - if (items == null || !items.hasWhere() || itemSource == null || itemSource.statusProperty() == null) { + return items == null || !items.hasWhere() ? null : crossModelStatusName(items.getWhere(), itemSource); + } + + /** + * The condition of a cross-model row query that compares the owner's status relation with a NAME + * rather than a seed id, or null when there is none - no condition names the status, the one that + * does gives the id, or the owner model declares no status relation (or was not resolvable, the + * convention fallback of a unit test), in which case nothing here can tell which condition is the + * status one. + * + *

+ * Shared by the two sites whose {@code { field, op, value }} triples run against a row this model + * does not own, and whose status names the parser's resolver therefore had to leave alone: a + * create-from's items rule (#7225) and a schedule's {@code where} (#7288). + * + * @param conditions the authored conditions + * @param target the owner's resolved facts + * @return the offending condition, or null + */ + private static ScheduleConditionIntent crossModelStatusName(List conditions, + CrossModelSupport.TargetInfo target) { + if (conditions == null || target == null || target.statusProperty() == null) { return null; } - for (ScheduleConditionIntent condition : items.getWhere()) { + for (ScheduleConditionIntent condition : conditions) { if (condition.getField() != null && condition.getField() - .equalsIgnoreCase(itemSource.statusProperty()) + .equalsIgnoreCase(target.statusProperty()) && !isSeedId(condition.getValue())) { return condition; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 34bc1f74208..9fa012d134a 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -2050,9 +2050,12 @@ private static void validateScheduleMoment(ScheduleConditionIntent condition, En * *

* Only the status condition is checked: every other condition compares an ordinary column, where a - * string literal is just a literal. A cross-model source has no local relations to check against - * (its field references are resolved at generation time against the owner's {@code .model}), so it - * keeps the numeric-id form the same way every other cross-model status site does. + * string literal is just a literal. A cross-model source has no local relations to check against - + * neither its nomenclature nor even WHICH of the conditions names its status is knowable here - so + * it keeps the numeric-id form the same way every other cross-model status site does, and a name + * written there is refused where the owner's {@code .model} is in hand: at generation time, by + * {@code GlueIntentGenerator} (issue #7288), rather than left to render as a string compared + * against the integer status FK. */ private static void validateWhereStatusValue(ScheduleConditionIntent condition, EntityIntent source, String subject, List issues) { diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java index 3818bab1cc3..f6505003edb 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java @@ -343,9 +343,11 @@ private void rewriteGeneratesItemsWhere(Map generate, String subject) { *

* Same-model source only. A cross-model source ({@code model: }) is not in this file's * {@code entities}, so neither its nomenclature nor even WHICH of the conditions names its status - * is knowable here - its {@code where} field references are validated at generation time against - * the owner's {@code .model} - and it therefore keeps the numeric-id form, exactly as every other - * cross-model status site does. + * is knowable here - and it therefore keeps the numeric-id form, exactly as every other cross-model + * status site does. A name written there is not left in place to render as a string compared + * against the integer status FK: it is refused where the owner's {@code .model} is read, at + * generation time by {@code GlueIntentGenerator} (issue #7288), the same way the sibling + * cross-model {@code items: where:} rule is (#7225). */ private void rewriteSchedules(Map root) { for (Object node : asList(root.get("schedules"))) { diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index c8cf0311452..539da9be3b5 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -2981,7 +2981,8 @@ mid-nomenclature would silently retarget the query. A name that is not seeded is and so is a value that is no status at all - never a `.eq("Status", "OVERDUE")` that matches nothing for as long as the schedule keeps ticking. The nomenclature must be seeded in THIS model: a cross-model source (`model: `) keeps the numeric seed id, as every other cross-model -status site does. +status site does - a name there is refused at Generate (the owner's `.model` is what tells which +condition names the status), so write the id. **A `where` value may be a moment relative to now** - which is what makes the archetypal schedule, a **staleness sweep**, expressible at all ("stuck provisioning for 30 minutes", "unanswered for a week", diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java index 4201c32481e..bd3920b3b8b 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java @@ -12,13 +12,19 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.parser.IntentParser; import org.eclipse.dirigible.components.intent.parser.IntentValidationException; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; import org.junit.jupiter.api.Test; /** @@ -611,4 +617,122 @@ void keyTermsSerializeInDeclarationOrderOnEveryJvm() { assertEquals(List.of("kind", "property", "lower", "upper"), List.copyOf(unique.get(1) .keySet())); } + + /** + * A dunning run over another model's invoices - the cross-model SOURCE shape ({@code model:}), + * whose nomenclature is seeded in the owner model too. + */ + private static final String CROSS_MODEL_DUNNING = """ + name: dunning + uses: + - { model: invoices } + entities: + - name: DunningLetter + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + - { name: sentOn, type: date } + schedules: + - name: dunning + cron: "0 0 6 * * ?" + entity: SalesInvoice + model: invoices + where: + - { field: Status, op: eq, value: OVERDUE } + generate: + to: DunningLetter + map: + Number: Number + defaults: + sentOn: now + """; + + /** + * The owner model as the invoices project generated it: the status FK is the property the edm + * generator gave the {@code DOCUMENT_STATUS} widget, which is how a consumer learns WHICH property + * is the status one. + */ + private static final String OWNER_MODEL = """ + { + "model": { + "entities": [ + { + "name": "SalesInvoice", + "perspectiveName": "SalesInvoice", + "dataName": "INVOICES_SALESINVOICE", + "properties": [ + { "name": "Id", "dataName": "ID", "dataType": "INTEGER", "dataPrimaryKey": "true" }, + { "name": "Number", "dataName": "NUMBER", "dataType": "VARCHAR" }, + { "name": "Status", "dataName": "STATUS_ID", "dataType": "INTEGER", + "relationshipEntityName": "SalesInvoiceStatus", "widgetType": "DOCUMENT_STATUS" } + ] + } + ] + } + } + """; + + /** + * A cross-model source's nomenclature is seeded in the owner model, so a status NAME in the row + * query cannot resolve at parse - and used to be left in place, rendering as + * {@code .eq("Status", "OVERDUE")} against the integer status FK: a query that matched nothing for + * as long as the schedule kept ticking, with no diagnostic (dirigible #7288, the #7251 failure one + * {@code model:} key away). It is refused the way every other cross-model status site is - by seed + * id only - at the one point the owner {@code .model} tells which condition names the status. + */ + @Test + void aStatusNameOnACrossModelScheduleSourceIsRefused() { + IntentGenerationContext context = contextWithOwnerModel(IntentParser.parse(CROSS_MODEL_DUNNING)); + + IntentValidationException failure = + assertThrows(IntentValidationException.class, () -> GlueIntentGenerator.buildSchedulesForTest(context.getModel(), context)); + + assertTrue(failure.getIssues() + .stream() + .anyMatch(issue -> issue.contains("[Status]") && issue.contains("[OVERDUE]") && issue.contains("[invoices]") + && issue.contains("numeric seed id")), + "the refusal must name the relation, the name and the owner model: " + failure.getIssues()); + } + + /** The seed id is the cross-model form, and it renders exactly as a local query does. */ + @Test + void aStatusSeedIdOnACrossModelScheduleSourceRenders() { + IntentGenerationContext context = + contextWithOwnerModel(IntentParser.parse(CROSS_MODEL_DUNNING.replace("value: OVERDUE", "value: 4"))); + + Map s = GlueIntentGenerator.buildSchedulesForTest(context.getModel(), context) + .get(0); + + assertEquals(true, s.get("sourceCrossModel")); + assertEquals("Criteria.create().eq(\"Status\", 4)", s.get("criteriaExpression")); + // Read off the owner model, not guessed from the entity name. + assertEquals("SalesInvoice", s.get("perspective")); + } + + /** + * An ordinary column compared with a string stays a string: only the status condition is refused, + * because only there is a literal a value no row can ever carry. + */ + @Test + void aStringOnANonStatusConditionOfACrossModelScheduleSourceRenders() { + IntentGenerationContext context = contextWithOwnerModel(IntentParser.parse( + CROSS_MODEL_DUNNING.replace("{ field: Status, op: eq, value: OVERDUE }", "{ field: Number, op: eq, value: SI-1 }"))); + + Map s = GlueIntentGenerator.buildSchedulesForTest(context.getModel(), context) + .get(0); + + assertEquals("Criteria.create().eq(\"Number\", \"SI-1\")", s.get("criteriaExpression")); + } + + private static IntentGenerationContext contextWithOwnerModel(IntentModel model) { + IRepository repository = mock(IRepository.class); + IResource missing = mock(IResource.class); + when(missing.exists()).thenReturn(false); + IResource owner = mock(IResource.class); + when(owner.exists()).thenReturn(true); + when(owner.getContent()).thenReturn(OWNER_MODEL.getBytes(StandardCharsets.UTF_8)); + when(repository.getResource(anyString())).thenReturn(missing); + when(repository.getResource("/users/admin/workspace/invoices/invoices.model")).thenReturn(owner); + return TestContexts.context(model, repository, "/users/admin/workspace/dunning", "app"); + } }