diff --git a/.claude/docs/intent-layer.md b/.claude/docs/intent-layer.md index d9e7a3deefc..69af694ecf8 100644 --- a/.claude/docs/intent-layer.md +++ b/.claude/docs/intent-layer.md @@ -10,6 +10,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **Effective-dated register lookup (`resolves:`, [#6712](https://github.com/eclipse-dirigible/dirigible/issues/6712)):** a to-one filled from the register row whose validity period covers a date the record carries — the driver from a vehicle-assignment register on the violation date, the price from the list in force on the order date, the approver from the org assignment on the request date. `resolves: - { name, event: { onCreate|onUpdate: , when? }, set: , from: , match: { : }, between: { start?, end?, value }, outcome?: , found?/notFound?/ambiguous?: { setStatus } }` → a `resolves` glue descriptor → a generated `@Component MessageHandler` on the record's event topic. **All three outcomes are first-class:** exactly one covering row fills the relation, while zero and more-than-one both leave it unset — an automation that silently picks one of two candidates is worse than none, so an ambiguous register goes back to a human. Each outcome may route by `setStatus` (id or seeded name) and the attempt is observable: `outcome:` stamps `found`/`notFound`/`ambiguous` into a string field a list filter or a process `decision` can read. The copied value is derived, not authored — the register must carry exactly ONE to-one to the same target as `set:`, and zero or two is a validation error; a record that already carries the relation is skipped (a manual correction is never overwritten); the relation + outcome + status go out in ONE targeted `updateProperties`; a bound may be omitted on either side (open-ended), the end is inclusive, and a date-only bound covers its whole day. v1 is same-model and `onCreate`/`onUpdate` only. **The register may be queried by the DOCUMENT and may hand back its scalars ([#7025](https://github.com/eclipse-dirigible/dirigible/issues/7025)):** a `match` value and `between.value` may be a **to-one path off the record** (`salesInvoice.customer.priceList`), which is what makes "price this line from the list in force on the header's date" expressible at all - the alternative, copying the header's key and date down with `dependsOn`, is a UI-time copy that a REST create, a `generates:` create-from or a schedule fan-out never runs, so the lines produced by exactly the automated paths stayed unpriced while the interactive path looked correct. Every segment but the last is a to-one, the last is a field or a to-one whose FK is compared, hops load once per distinct path prefix, and a cross-model relation may only be the last hop. Alongside it **`copy: { : }`** writes the scalars the covering row NAMES (the price, the rate) - on `found` only, per field, skipping a field the record already carries a value in, riding the result write - and `set:` may point at the **register itself** for a value-bearing register, where the resolved value is the covering row's own key. Details in the engine-intent guide's `resolves:` bullet. +**A status the flow writes is the flow's column ([#7339](https://github.com/eclipse-dirigible/dirigible/issues/7339)):** an entity whose `function: EntityStatus` relation is moved by a `processes:` `setRelationField` step has that column refused on every generated REST surface - a create or update that sets or changes it answers **409** `'Status' changes through the workflow, not a direct edit`. Until then the column was an ordinary writable property, so a plain `PUT {"Status": 3}` moved a document straight into APPROVED with the flow bypassed end to end: no check ran, no task was ever raised, nothing the flow charges was charged, and the record read approved while the accounts knew nothing about it. `immutableWhen:` cannot close it - it locks the way OUT of a final status, while this is the way IN, from a DRAFT that is mutable by definition - and a `transitions[]` button is an ADDITIONAL guarded endpoint beside the plain PUT, not instead of it. The column is derived state owned by the flow, the same class as an `aggregate:`/roll-up target, and the flow's own writers lose nothing: a `setRelationField` step and a `transitions[]` endpoint reach the repository through the targeted `updateProperty`/`updateProperties` primitives, never through a controller. Two things are deliberately NOT refused: an **absent** value (a caller PUTs the fields its form edits, so the stored status is kept rather than erased) and a create carrying exactly the declared `init:`. A `transitions:`-only status stays writable on purpose - the button is a hand move over a status a person may also hold otherwise, and the construct that guards the other hand writes is `lifecycle:`, whose refusal would be observable from nowhere if the plain write were closed. + **The declarative state machine (`lifecycle:`, [#6714](https://github.com/eclipse-dirigible/dirigible/issues/6714)):** an entity may declare the WHOLE set of legal status edges over its `function: EntityStatus` nomenclature — `lifecycle: { edges: [{ from: DRAFT, to: [ISSUED, CANCELLED] }, ...] }`, either side a seeded name or an id — and every status write is validated against it. Until then the status machinery was point constructs (`init:`, a `transitions:` button's own guard, a workflow `setRelationField`, a check's rejection) with nothing stating which moves were legal at all, so any writer that was not a transition button could move a document from any status to any other. Enforcement is in the generated **repository** — the one choke point every writer passes through (`update`, `updateWithoutEvent`, and `updateProperties`, which the transition controller's `updateProperty` and the workflow setters route through) — rejecting an unmodeled move with 400 and a message naming both statuses; with `init:` declared, a record cannot be CREATED mid-lifecycle either. At parse time the graph is what the other status sites are held to: a `transitions:` entry's `from`→`setStatus` pair must be a declared edge (a button is presentation over the graph), and a status written by a workflow step or forced by a check must be one some edge reaches — so a reject path transiting through an approved status fails when the intent is read. There is no `on:` key (the graph is always over the EntityStatus relation, and YAML reads a bare `on` as `true`, so it is refused rather than silently dropped), and a cross-model nomenclature is declared where it is seeded. Details in the engine-intent guide's state-machine bullet. **A field's label, and a label the TENANT'S COUNTRY resolves (`label:` / `countryLabels:`, [#6424](https://github.com/eclipse-dirigible/dirigible/issues/6424)):** a field may now declare its display `label:` - emitted as the property's own `widgetLabel`, which every generated surface renders and the en-US catalog is seeded from, so an acronym or a unit (`nationalId` as "National ID", not the humanized "National Id") is expressed in the intent instead of hand-edited into a catalog the next Generate overwrites. Alongside it, `countryLabels: { BG: ЕГН, DE: Steuer-ID }` declares variants resolved from the **tenant's country** (`DIRIGIBLE_APPLICATION_COUNTRY`, ISO 3166-1 alpha-2, tenant-overridable in the application shell's Tenant Configuration) rather than from the UI language - which term a national identifier goes by is a property of the company, so keying it off the language catalogs is wrong in both directions at once (the Bulgarian-reading user of a German company gets the local term; the English-reading accountant of a Bulgarian one gets the generic one). It also cannot live in a catalog mechanically: the shared `i18n.js` does not load catalogs at all in the default language. So the variants travel as a language-independent overlay - a structured `widgetCountryLabels` on the property (Map, hence `.model`-only), flattened at UI generation into the `countryLabels` object `config.js` carries, keyed by the very translation key the views bind, which `T()` consults ahead of both i18next and the baked fallback in every language. An app declaring no variant issues no extra request and generates byte-identically. A key that is not a country is refused at parse time (it could never match a tenant); report column labels are deliberately out of scope, a column alias being its SQL alias too. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 0c7579f6472..f0f8c44c328 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -422,6 +422,7 @@ Semantics worth knowing: - **`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`, the `event.when` of a `notifications`/`integrations`/`outbound` entry ([#7289](https://github.com/eclipse-dirigible/dirigible/issues/7289)), 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. +- **A status a `processes:` flow writes is the FLOW's column, not a payload field (#7339).** An entity whose `function: EntityStatus` relation is moved by a `setRelationField` step carries `workflowStatusProperty` (the FK) and `workflowStatusInitial` (the relation's `init:`) on its entity map (`EdmIntentGenerator.putWorkflowStatus` / `writesStatus`, scalars reaching the `.edm` twin like `immutableStatusValues`), and all three generated REST controllers refuse a create/update that sets or changes it - **409** `'Status' changes through the workflow, not a direct edit`. The hole it closes is the whole point of having a flow at all: a plain `PUT {"Status": 3}` put a vacation request into APPROVED with the capacity check never run, no manager task ever raised and the leave account never charged - the document read approved and the accounts did not know. **`immutableWhen:` cannot close it** (it locks the way OUT of a final status; a DRAFT is mutable by definition, which is what the jump starts from) and neither can `transitions:` (a guarded EXTRA endpoint beside the plain PUT, not instead of it). Two deliberate non-refusals, each because refusing would be a different feature: an **absent** value is not a change - it is taken from the stored row, which is also what stops a partial payload from erasing the status - and a create carrying exactly the declared `init:` starts the record where the model says it starts (with no `init:`, any create value is refused). **A `transitions:`-only entity keeps its writable column on purpose:** the button is a user action over a status a person may also hold otherwise, and the construct guarding every other hand write is `lifecycle:`, enforced in the repository precisely because writers other than the button exist - claiming the column here would make an unmodeled move reachable from nowhere and the state machine's refusal observable from nowhere. The flow's own writers are untouched: a `setRelationField` step and a `transitions[]` endpoint reach the repository through the targeted `updateProperty`/`updateProperties` primitives, never through a controller. Unit: `EdmIntentGeneratorTest`; end-to-end: `IntentWorkflowStatusIT` (the refused jump, the refused create, the ordinary edit that still saves, the omitted status that is not erased, and the flow's own write still landing). - **`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. - **`checks:` on an entity = declarative cross-field / cross-line validations (the double-entry shape).** Row-level and document-level kinds (`CheckIntent`): row-level `exactlyOne` (`fields:` — exactly one non-null), `compare` (`field:` / `op:` / `than:` — two values of the SAME row related by an operator: a due date not before the document date, a validity `to` not before its `from`, #7095) and `requiredWhen` (see the next bullet), all emitted PascalCased into the `.model` `checks` list and enforced in the generated REST `validate()` with 400 — in all three surfaces' controllers (`EntityController`, `EntityMyController`, `EntityPartnerController`), which is what "every user write" means for a row check. A `compare` carries the Java comparison operator and a `numeric` flag precomputed by `EdmIntentGenerator` (`compareOperator` / `isNumericCompare`): two temporals compare through their own `compareTo`, which is why the parser holds both fields to ONE family (a `LocalDate` does not compare to an `Instant`), while two numbers compare by value through `BigDecimal` so a `decimal` against a `long` is still exact. An absent operand is NOT a violation — a comparison is about two values that exist, and requiredness is its own declaration — and only dates, timestamps and numbers compare (a `string`/`month`/`week` is refused rather than silently ordered lexicographically). Like `exactlyOne` it takes no `status` gate: a rule about two values of one row holds from the first save. And document-level `itemsSumEqual` (`over:` two item fields whose sums must match) / `itemsMin` (`count:`), both REQUIRING a `status:` gate (an EntityStatus seed id) — parser-enforced, because an ungated sum check would forbid drafting a document item by item. The EDM generator precomputes everything template-side (`buildChecks`: items entity + back-FK via **`IntentEntities.documentItemsChild`** — the ONE shared resolution of "what are this document's items" (`function: DocumentItem`, else the `*Item` name, else the sole composition child, else the first declared, always in entity-declaration order), also used by the parser's `compositionChildOf` and the glue's postings/generates item lines; scanning a hash-ordered index for *some* composition child let a multi-child document's gate count its printed snapshots instead of its lines, #7027 — plus `statusProperty`, PascalCased fields); `ModelParameterProcessor` splits `rowChecks`/`documentChecks`; the **DAO repository** enforces document checks in `save`/`update`/**`updateWithoutEvent`** whenever the persisted entity carries the gate status — so the workflow setter flipping DRAFT→POSTED hits `enforceChecks` and an unbalanced document FAILS the write instead of silently posting: it throws the SDK `org.eclipse.dirigible.sdk.db.ValidationException`, which the client-controller dispatcher (`ControllerInvoker`) maps to **HTTP 400** with the authored message on a REST create/update, and which rolls back the task completion on the BPMN path (the capacity guard on roll-ups throws the same). `recalculate()` deliberately bypasses it (it persists the recomputed totals through the BASE targeted write, `super.updateProperties(id, totals)`, so a document still being assembled line by line never fails its own gate). No Harmonia-side mirror in v1 — the task-completion error surfaces the authored message. **That last half is only true because the gated status-set is emitted WITHOUT `flowable:async`** (`BpmnIntentGenerator.synchronousNodes`, #7014): every other service task is async, and an async status-set runs in a detached job, so Flowable committed the user-task completion first and the rejection then dead-lettered as a process incident — the task left the Inbox, the document stayed in its old status, and the approver was told nothing. A setter declared on the `serviceTask` itself is that one node; a setter declared on a `userTask` is the delegate inserted after it, so the **writer** that persists the reviewer's edits (inserted before it) loses its async boundary too, or that boundary commits the completion before the gate is reached. Everything downstream (number stamping, snapshots, mail) stays async. **A gate one hop further down is the same transaction, and #7063 is where that showed:** the shape every approve/reject flow has is a user task falling through a `decision` into the `serviceTask` that sets the gated status, so the setter's own position is not enough - the writer, a resolver inserted before the decision, a step-completed emitter all still sat between the completion and the gate, and any one of their boundaries commits it. `completingTransactionNodes` therefore walks BACK from each gated step (`gatedSteps`) to the user tasks that reach it, and every node on the way loses its boundary too. The walk stops at anything that is not a `decision`: a second user task is its own wait state, and an authored service task in between is real asynchronous work whose action has already succeeded - nobody is waiting on the gate behind it, so that one is legitimately a background incident. The other half is `BpmInboxEndpoint`: a `ValidationException` in the cause chain of `completeTask` becomes **400 with the message as the response BODY** (`ClientValidationFailure` matches it by class NAME — this module cannot depend on `api-modules-java`, and Spring Boot strips a `ResponseStatusException` reason from the default error payload), which is exactly what the generated task form reads into its `Submit failed` notification. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index 0bf39a12000..2fed6bc1acd 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -629,6 +629,7 @@ else if (!extension && !dependent && !setting && !compositionParents.containsVal } putPeriod(entityMap, entity); putProcessDeleteGuards(entityMap, entity, model); + putWorkflowStatus(entityMap, entity, model); putLifecycle(entityMap, entity, model); if (entity.getHierarchy() != null && !entity.getHierarchy() .isBlank()) { @@ -2565,6 +2566,82 @@ private static void putProcessDeleteGuards(Map entityMap, Entity } } + /** + * The status column a FLOW owns (dirigible #7339): {@code workflowStatusProperty} = the + * {@code function: EntityStatus} FK, emitted when a {@code processes:} step is what moves it, and + * {@code workflowStatusInitial} = the status a record may still be CREATED in ({@code init:}). + * + *

+ * Without it the generated controllers treat that FK as an ordinary writable column, so a plain + * {@code PUT} carrying {@code "Status": 3} moves a document straight into APPROVED with the whole + * flow bypassed - no check ran, no task was ever raised, nothing the flow charges was charged, and + * the document reads approved. The column is derived state owned by the flow, exactly as a roll-up + * target is derived state owned by the roll-up; the flow's own writers never come through a + * controller (a {@code setRelationField} step and a {@code transitions[]} endpoint reach the + * repository through the targeted {@code updateProperty}/{@code updateProperties} primitives), so + * refusing it here costs them nothing. + * + *

+ * Derived rather than declared: a model that states a flow over the status has already said who + * owns it. Scalars, so both reach the {@code .edm} twin as attributes like + * {@code immutableStatusValues}. + * + * @param entityMap the entity's model map + * @param entity the authored entity + * @param model the whole intent - the processes are declared beside the entities, not on them + */ + private static void putWorkflowStatus(Map entityMap, EntityIntent entity, IntentModel model) { + RelationIntent status = entityStatusRelation(entity); + if (status == null || entity.getName() == null || !writesStatus(entity, status, model)) { + return; + } + entityMap.put("workflowStatusProperty", IntentNaming.pascalCase(status.getName())); + if (status.getInit() != null && status.getInit() + .matches("-?\\d+")) { + // The one value a create may still carry: the status the record starts in. Anything else is + // a jump into the middle of the flow, and so is a create that names a status with no start + // declared at all. + entityMap.put("workflowStatusInitial", status.getInit()); + } + } + + /** + * Whether a declared flow is what writes this entity's status: a {@code setRelationField} step of a + * process THIS entity triggers. + * + *

+ * A {@code transitions:} button deliberately does NOT claim the column. It is a user action over + * the status - the declared way a person moves it by hand - and the construct that guards every + * OTHER hand write is the state machine, {@code lifecycle:}, enforced in the repository precisely + * because writers other than the button exist. Claiming the column here would leave an unmodeled + * move reachable from nowhere and the state machine's refusal observable from nowhere: a different + * feature removed rather than this one delivered. A process is the other statement - a status a + * flow computes is not a value anybody hands in. + * + * @param entity the authored entity + * @param status its {@code function: EntityStatus} relation + * @param model the whole intent + * @return true when the status is the flow's to write + */ + private static boolean writesStatus(EntityIntent entity, RelationIntent status, IntentModel model) { + String statusProperty = IntentNaming.pascalCase(status.getName()); + for (ProcessIntent process : model.getProcesses()) { + if (!entity.getName() + .equals(TriggerSupport.triggerEntity(process))) { + continue; + } + for (StepIntent step : process.getSteps()) { + Object written = step.getArgs() + .get("setRelationField"); + if (written != null && statusProperty.equals(IntentNaming.pascalCase(String.valueOf(written) + .trim()))) { + return true; + } + } + } + return false; + } + /** * The two halves of date-based immutability, each emitted on the entity that DECLARES it. * diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index 8e92990c41d..794e8a3d3e9 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java @@ -691,6 +691,77 @@ void whenDeletedRefuseEmitsTheProcessDeleteGuardOnTheTriggerEntity() { assertNull(entityByName(entities(model), "Customer").get("processDeleteGuards")); } + /** + * A status a {@code processes:} step writes is the FLOW's column: the trigger entity carries the + * guard its controllers refuse a direct create/update with, plus the one status a create may still + * name - the relation's {@code init:} (dirigible #7339). An entity whose status no flow writes is + * untouched. + */ + @Test + void aProcessDrivenStatusIsEmittedAsWorkflowOwned() { + String yaml = """ + name: vacations + entities: + - name: RequestStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: VacationRequest + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: RequestStatus, function: EntityStatus, init: 1 } + - name: Employee + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: RequestStatus, function: EntityStatus, init: 1 } + processes: + - name: Approval + trigger: { onCreate: VacationRequest } + steps: + - { name: decide, kind: userTask, args: { assignee: manager, form: DecideRequest } } + - { name: approve, kind: serviceTask, args: { setRelationField: Status, value: 3 } } + - { name: end, kind: end } + forms: + - { name: DecideRequest, forEntity: VacationRequest, fields: [id], actions: [decide] } + """; + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "vacations"); + Map request = entityByName(entities(model), "VacationRequest"); + assertEquals("Status", request.get("workflowStatusProperty")); + assertEquals("1", request.get("workflowStatusInitial")); + // Same status nomenclature, no flow over it - an ordinary writable column. + assertNull(entityByName(entities(model), "Employee").get("workflowStatusProperty")); + } + + /** + * A {@code transitions:} button does NOT claim the column: it is a user action over the status, and + * the construct that guards the other hand writes is {@code lifecycle:}, enforced in the repository + * - whose refusal would be observable from nowhere if the plain write were closed here. + */ + @Test + void aTransitionAloneLeavesTheStatusWritable() { + String yaml = """ + name: ledger + entities: + - name: EntryStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: JournalEntry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 } + transitions: + - { name: void, forEntity: JournalEntry, from: [1], setStatus: 2, label: Void } + """; + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "ledger"); + assertNull(entityByName(entities(model), "JournalEntry").get("workflowStatusProperty")); + } + @Test void immutableWhenEmitsStatusGuardAttributes() { String yaml = """ diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template index 8da80174770..ba1ea33795e 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityController.java.template @@ -223,6 +223,9 @@ public class ${name}Controller { #end #if($isEntityPropertySecurityEnabled) applyOnCreate(entity); +#end +#if($workflowStatusProperty) + requireWorkflowStatusOnCreate(entity); #end validate(entity); #if($hasReferenceValidations) @@ -304,6 +307,9 @@ public class ${name}Controller { #if($immutableStatusProperty || $immutableAlways || $periodLock) requireMutable(id); #end +#if($workflowStatusProperty) + applyWorkflowStatus(id, entity); +#end #if($masterLock) // Neither an edit inside a locked ${masterLock.entity} nor a move into one. repository.findOne(id).ifPresent(stored -> requireMasterMutable(stored.${masterLock.fkProperty})); @@ -715,6 +721,48 @@ public class ${name}Controller { #end } +#end +#if($workflowStatusProperty) + // Workflow-owned status (dirigible #7339). ${workflowStatusProperty} is derived state owned by the + // flow that moves it - a `processes:` step or a `transitions[]` button - the same way a roll-up + // target is owned by its roll-up. Those writers reach the repository through the targeted + // updateProperty / updateProperties primitives, never through this controller, so refusing the + // column here takes nothing away from them: it closes the plain create/update that would otherwise + // jump a record to any status it likes, with every check, task and side effect of the flow skipped. + private void requireWorkflowStatusOnCreate(${name}Entity entity) { + if (entity.${workflowStatusProperty} == null) { + // Nothing said: the record starts where the model says it starts (the relation's `init:`, + // applied by the repository), which is what every generated form posts. + return; + } +#if($workflowStatusInitial) + if (String.valueOf(entity.${workflowStatusProperty}).equals("${workflowStatusInitial}")) { + return; + } +#end + throw new ResponseStatusException(HttpStatus.CONFLICT, WORKFLOW_STATUS_REFUSAL); + } + + // An absent value is not a change - a caller PUTs the fields its form edits - so it is taken from + // the stored row rather than refused, which is also what stops a partial payload from erasing the + // status. A value that DIFFERS is a direct edit of the flow's column and is refused. + private void applyWorkflowStatus(Object id, ${name}Entity entity) { + ${name}Entity stored = repository.findOne(id).orElse(null); + if (stored == null) { + return; + } + if (entity.${workflowStatusProperty} == null) { + entity.${workflowStatusProperty} = stored.${workflowStatusProperty}; + return; + } + if (!java.util.Objects.equals(entity.${workflowStatusProperty}, stored.${workflowStatusProperty})) { + throw new ResponseStatusException(HttpStatus.CONFLICT, WORKFLOW_STATUS_REFUSAL); + } + } + + private static final String WORKFLOW_STATUS_REFUSAL = + "'${workflowStatusProperty}' changes through the workflow, not a direct edit"; + #end #if($processDeleteGuards) // Delete guard (intent process `whenDeleted: refuse`): while a flow this record started is still diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template index 912d78fe503..c8e3da419ac 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityMyController.java.template @@ -211,6 +211,9 @@ public class ${name}MyController { entity.${property.name} = null; } #end +#end +#if($workflowStatusProperty) + requireWorkflowStatusOnCreate(entity); #end // Validated LAST, on what will actually be written: the owner is forced above and the // sensitive / role-scoped fields are cleared, so validating earlier would judge a payload the @@ -241,6 +244,9 @@ public class ${name}MyController { #if($masterLock) requireMasterMutable(existing.${masterLock.fkProperty}); #end +#if($workflowStatusProperty) + applyWorkflowStatus(existing, entity); +#end #if($periodLock) // The stored date is checked above; this refuses a move INTO a closed period. requirePeriodOpen(entity.${periodLock.dateProperty}); @@ -483,6 +489,44 @@ public class ${name}MyController { } #end +#if($workflowStatusProperty) + + // Workflow-owned status (dirigible #7339). ${workflowStatusProperty} is derived state owned by the + // flow that moves it - a `processes:` step or a `transitions[]` button - the same way a roll-up + // target is owned by its roll-up. Those writers reach the repository through the targeted + // updateProperty / updateProperties primitives, never through this controller, so refusing the + // column here takes nothing away from them: it closes the plain create/update that would otherwise + // jump a record to any status it likes, with every check, task and side effect of the flow skipped. + private void requireWorkflowStatusOnCreate(${name}Entity entity) { + if (entity.${workflowStatusProperty} == null) { + // Nothing said: the record starts where the model says it starts (the relation's `init:`, + // applied by the repository), which is what every generated form posts. + return; + } +#if($workflowStatusInitial) + if (String.valueOf(entity.${workflowStatusProperty}).equals("${workflowStatusInitial}")) { + return; + } +#end + throw new ResponseStatusException(HttpStatus.CONFLICT, WORKFLOW_STATUS_REFUSAL); + } + + // An absent value is not a change - a caller PUTs the fields its form edits - so it is taken from + // the stored row rather than refused, which is also what stops a partial payload from erasing the + // status. A value that DIFFERS is a direct edit of the flow's column and is refused. + private void applyWorkflowStatus(${name}Entity stored, ${name}Entity entity) { + if (entity.${workflowStatusProperty} == null) { + entity.${workflowStatusProperty} = stored.${workflowStatusProperty}; + return; + } + if (!java.util.Objects.equals(entity.${workflowStatusProperty}, stored.${workflowStatusProperty})) { + throw new ResponseStatusException(HttpStatus.CONFLICT, WORKFLOW_STATUS_REFUSAL); + } + } + + private static final String WORKFLOW_STATUS_REFUSAL = + "'${workflowStatusProperty}' changes through the workflow, not a direct edit"; +#end #if($processDeleteGuards) // Delete guard (intent process `whenDeleted: refuse`): while a flow this record started is still diff --git a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template index 1f52b6a7369..7e4948ac01b 100644 --- a/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template +++ b/components/template/template-application-rest-java/src/main/resources/META-INF/dirigible/template-application-rest-java/api/EntityPartnerController.java.template @@ -181,6 +181,9 @@ public class ${name}PartnerController { entity.${property.name} = null; } #end +#end +#if($workflowStatusProperty) + requireWorkflowStatusOnCreate(entity); #end // Validated LAST, on what will actually be written: the owner is forced above and the // sensitive / role-scoped fields are cleared, so validating earlier would judge a payload the @@ -211,6 +214,9 @@ public class ${name}PartnerController { #if($masterLock) requireMasterMutable(existing.${masterLock.fkProperty}); #end +#if($workflowStatusProperty) + applyWorkflowStatus(existing, entity); +#end #if($periodLock) // The stored date is checked above; this refuses a move INTO a closed period. requirePeriodOpen(entity.${periodLock.dateProperty}); @@ -439,6 +445,44 @@ public class ${name}PartnerController { #end } #end +#if($workflowStatusProperty) + + // Workflow-owned status (dirigible #7339). ${workflowStatusProperty} is derived state owned by the + // flow that moves it - a `processes:` step or a `transitions[]` button - the same way a roll-up + // target is owned by its roll-up. Those writers reach the repository through the targeted + // updateProperty / updateProperties primitives, never through this controller, so refusing the + // column here takes nothing away from them: it closes the plain create/update that would otherwise + // jump a record to any status it likes, with every check, task and side effect of the flow skipped. + private void requireWorkflowStatusOnCreate(${name}Entity entity) { + if (entity.${workflowStatusProperty} == null) { + // Nothing said: the record starts where the model says it starts (the relation's `init:`, + // applied by the repository), which is what every generated form posts. + return; + } +#if($workflowStatusInitial) + if (String.valueOf(entity.${workflowStatusProperty}).equals("${workflowStatusInitial}")) { + return; + } +#end + throw new ResponseStatusException(HttpStatus.CONFLICT, WORKFLOW_STATUS_REFUSAL); + } + + // An absent value is not a change - a caller PUTs the fields its form edits - so it is taken from + // the stored row rather than refused, which is also what stops a partial payload from erasing the + // status. A value that DIFFERS is a direct edit of the flow's column and is refused. + private void applyWorkflowStatus(${name}Entity stored, ${name}Entity entity) { + if (entity.${workflowStatusProperty} == null) { + entity.${workflowStatusProperty} = stored.${workflowStatusProperty}; + return; + } + if (!java.util.Objects.equals(entity.${workflowStatusProperty}, stored.${workflowStatusProperty})) { + throw new ResponseStatusException(HttpStatus.CONFLICT, WORKFLOW_STATUS_REFUSAL); + } + } + + private static final String WORKFLOW_STATUS_REFUSAL = + "'${workflowStatusProperty}' changes through the workflow, not a direct edit"; +#end #if($processDeleteGuards) // Delete guard (intent process `whenDeleted: refuse`): while a flow this record started is still diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentWorkflowStatusIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentWorkflowStatusIT.java new file mode 100644 index 00000000000..7fc8ce225d1 --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentWorkflowStatusIT.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.integration.tests.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.dirigible.components.initializers.synchronizer.SynchronizationProcessor; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; +import org.eclipse.dirigible.repository.api.IResource; +import org.eclipse.dirigible.tests.base.IntegrationTest; +import org.eclipse.dirigible.tests.framework.restassured.RestAssuredExecutor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.annotation.DirtiesContext; + +/** + * A status a {@code processes:} flow drives cannot be moved by a direct REST write (dirigible + * #7339). + * + *

+ * The generated controllers treated that column as an ordinary writable property, so a plain + * {@code PUT} carrying {@code "Status": 2} approved a document with the whole flow bypassed - no + * check ran, no task was ever raised, nothing the flow charges was charged, and the record read + * approved. {@code immutableWhen:} cannot close it: it locks the way OUT of a final status, while + * this is the way IN, from a status that is mutable by definition. + * + *

+ * So the four answers are asserted together, because each of them is a way the guard could be + * wrong: the jump is refused, the flow's own write of the very same column still lands, an ordinary + * edit that carries the status back unchanged still saves, and an edit that omits it does not erase + * it. + */ +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class IntentWorkflowStatusIT extends IntegrationTest { + + private static final String WORKSPACE = "workspace"; + private static final String PROJECT = "flowstatus"; + private static final String PROJECT_PATH = IRepositoryStructure.PATH_USERS + "/admin/" + WORKSPACE + "/" + PROJECT; + private static final String API = "/services/java/" + PROJECT + "/gen/" + PROJECT + "/api"; + private static final String INVOICES = API + "/invoice/InvoiceController"; + private static final String TASKS = "/services/inbox/tasks"; + private static final String REFUSAL = "'Status' changes through the workflow, not a direct edit"; + private static final long TIMEOUT_SECONDS = 90; + /** The task appears once the create event has started the instance. */ + private static final long PROCESS_TIMEOUT_SECONDS = 60; + + private static final String INTENT_YAML = """ + name: flowstatus + description: a status the approval flow owns - the plain create/update cannot move it + + entities: + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 200 } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + + processes: + - name: InvoiceApproval + trigger: { onCreate: Invoice } + steps: + - { name: review, kind: userTask, args: { assignee: approver, form: DecideInvoice } } + - { name: approve, kind: serviceTask, args: { setRelationField: Status, value: 2 } } + - { name: end, kind: end } + + forms: + - { name: DecideInvoice, forEntity: Invoice, fields: [note], editable: [note], actions: [approve] } + + seeds: + - name: invoice-statuses + entity: InvoiceStatus + rows: + - { id: 1, name: Draft } + - { id: 2, name: Approved } + """; + + @Autowired + private IRepository repository; + + @Autowired + private RestAssuredExecutor restAssuredExecutor; + + @Autowired + private SynchronizationProcessor synchronizationProcessor; + + @Test + void a_flow_driven_status_is_refused_to_a_direct_rest_write_and_still_written_by_the_flow() { + generateProject(); + publishProject(); + synchronizationProcessor.forceProcessSynchronizers(); + + int invoice = create("{\"Note\":\"draft\"}"); + awaitStatus(invoice, 1); + + // The jump the whole flow exists to prevent. + put(invoice, "{\"Note\":\"draft\",\"Status\":2}", 409, REFUSAL); + // ...and a create cannot start mid-lifecycle either: the record starts where `init:` says. + createRefused("{\"Note\":\"born approved\",\"Status\":2}"); + + // An ordinary edit still saves - it carries the status back unchanged... + put(invoice, "{\"Note\":\"edited\",\"Status\":1}", 200, null); + // ...and one that does not mention the status does not erase it either. + put(invoice, "{\"Note\":\"edited again\"}", 200, null); + read(invoice).body("Status", equalTo(1)) + .body("Note", equalTo("edited again")); + + // The flow's own writer is untouched: it reaches the repository through the targeted + // updateProperties primitive, never through the controller this guard sits in. + complete(taskFor(invoice)); + awaitStatus(invoice, 2); + } + + private void put(int invoice, String body, int expectedStatus, String expectedMessage) { + restAssuredExecutor.execute(() -> { + var response = given().contentType("application/json") + .body(body) + .when() + .put(INVOICES + "/" + invoice) + .then() + .statusCode(expectedStatus); + if (expectedMessage != null) { + response.body(containsString(expectedMessage)); + } + }); + } + + private void createRefused(String body) { + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body(body) + .when() + .post(INVOICES) + .then() + .statusCode(409) + .body(containsString(REFUSAL))); + } + + private io.restassured.response.ValidatableResponse read(int invoice) { + AtomicReference response = new AtomicReference<>(); + restAssuredExecutor.execute(() -> response.set(given().when() + .get(INVOICES + "/" + invoice) + .then() + .statusCode(200))); + return response.get(); + } + + private void awaitStatus(int invoice, int status) { + restAssuredExecutor.execute(() -> given().when() + .get(INVOICES + "/" + invoice) + .then() + .statusCode(200) + .body("Status", equalTo(status)), + PROCESS_TIMEOUT_SECONDS); + } + + private void complete(String task) { + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"action\":\"COMPLETE\",\"data\":{\"action\":\"approve\"}}") + .when() + .post(TASKS + "/" + task) + .then() + .statusCode(200)); + } + + /** The review task of this invoice's instance, found by the business key the trigger stamped. */ + private String taskFor(int invoice) { + AtomicReference task = new AtomicReference<>(); + restAssuredExecutor.execute(() -> task.set(given().when() + .get(TASKS + "?type=groups") + .then() + .statusCode(200) + .extract() + .path("find { it.processInstanceBusinessKey == '" + invoice + "' }.id")), + PROCESS_TIMEOUT_SECONDS); + return task.get(); + } + + private int create(String body) { + AtomicInteger id = new AtomicInteger(); + restAssuredExecutor.execute(() -> id.set(given().contentType("application/json") + .body(body) + .when() + .post(INVOICES) + .then() + .statusCode(200) + .extract() + .path("Id")), + TIMEOUT_SECONDS); + return id.get(); + } + + private void generateProject() { + writeIntent(); + AtomicReference>> plan = new AtomicReference<>(); + restAssuredExecutor.execute(() -> plan.set(given().when() + .post("/services/ide/intent/generate?workspace=" + WORKSPACE + "&project=" + + PROJECT + "&path=app.intent") + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList("codeGenerations"))); + for (Map codeGeneration : plan.get()) { + assertEquals(Boolean.TRUE, codeGeneration.get("generated"), + "generating code from " + codeGeneration.get("path") + " failed: " + codeGeneration.get("error")); + } + } + + private void publishProject() { + restAssuredExecutor.execute(() -> given().when() + .post("/services/ide/publisher/" + WORKSPACE + "/" + PROJECT + "/") + .then() + .statusCode(200)); + } + + private void writeIntent() { + String path = PROJECT_PATH + "/app.intent"; + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + } else { + repository.createResource(path, INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + } + } + + @AfterEach + void cleanup() { + restAssuredExecutor.execute(() -> given().when() + .delete("/services/ide/publisher/" + WORKSPACE + "/" + PROJECT) + .then() + .statusCode(greaterThanOrEqualTo(200))); + if (repository.hasCollection(PROJECT_PATH)) { + repository.removeCollection(PROJECT_PATH); + } + synchronizationProcessor.forceProcessSynchronizers(); + } +}