From aec49eb7af8ef411d43657983b7d0d99c988b22d Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 17 Sep 2026 13:29:07 +0300 Subject: [PATCH] docs: entity-level unique: is described in the in-repo contract (#7228) #7116 let an entity-level `unique:` key name a cross-model to-one, and the only place the construct was written down at all was `intent-assistant-guide.md` - neither `.claude/docs/intent-layer.md` nor `engine-intent/CLAUDE.md` had ever described the composite business key from #6793/#6796. Both now carry it: the shape, the member rules the parser enforces (an own field or an own to-one; a to-many, a `subset`, a single-name key, a repeated member and a repeated key all refused), what `EdmIntentGenerator.uniqueConstraint` emits (the `_` constraint name, the derived `upperSnake(entity)_upperSnake(member)` columns, the `properties` twin the modeler rebuilds the `.model` from, the authored or generated message), why a cross-model member needs no resolution step - the column name is derived from the names, and the consumer holds the target's id in its own FK column - and that a collision is answered 409 with that message. `IntentCrossModelUniqueIT.cleanup` asserted `greaterThanOrEqualTo(200)` on the unpublish, which accepts a 404 or a 500; it now uses the sibling `IntentCrossModelFormFieldIT`'s `both(greaterThanOrEqualTo(200)).and(lessThan(300))`. Fixes #7228 Co-Authored-By: Claude Opus 5 --- .claude/docs/intent-layer.md | 2 ++ components/engine/engine-intent/CLAUDE.md | 1 + .../integration/tests/api/IntentCrossModelUniqueIT.java | 4 +++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude/docs/intent-layer.md b/.claude/docs/intent-layer.md index f344126e4ab..eb7bdc63c8c 100644 --- a/.claude/docs/intent-layer.md +++ b/.claude/docs/intent-layer.md @@ -54,6 +54,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **...and a field against a LITERAL (`checks: compare` with `value:`, [#7338](https://github.com/eclipse-dirigible/dirigible/issues/7338)):** all five check kinds related two things the model already NAMED - two fields of a row, two item sums, an item count - so the commonest validation in a business model had no declaration at all: `VacationDay.Days > 0` (a negative row silently inflates the parent entitlement, because the roll-up sums the column verbatim), a quantity `>= 0`, a percentage `<= 100`. The three workarounds in the fleet were each worse than the gap: a hand-edit of the generated controller's `validate()` (dropped by the next regeneration, silently), a `calculatedActionOnCreate` that throws (a calculation, not a refusal, firing only on the field that declares it and reaching the caller as whatever the action's exception carries), or not enforcing it at all. `- { kind: compare, field: days, op: gt, value: 0, message: ... }` reuses `compare` and its whole implementation: `value:` and `than:` are mutually exclusive and exactly one is required, since a comparison has one right-hand side. The literal is TYPED by the field it is compared with, by `CheckSupport.compareLiteral` - the one rule the parser refuses on and the generator renders with, so nothing is refused that would have generated and nothing generates that was not refused. A numeric field takes a number (compared by value through `BigDecimal`, exact across the widths); a temporal one takes a moment (`CURRENT_DATE` / `CURRENT_TIMESTAMP` / `NOW` with at most one signed ISO-8601 offset - the vocabulary `items: where:` already carries, resolved against the clock of the WRITE) or a quoted ISO-8601 date/instant, rendered in the shape the generated column actually carries (`LocalDate` for a `date`, `Instant` for a `timestamp`; a comparison across the two does not compile). An absent operand is not a violation, exactly as with `than:`. **The second half is the gate.** `compare` used to refuse a `status:`; it now takes the optional one `requiredWhen` has, and that is the routing: without a gate the rule holds on every user write (the three generated controllers, 400 with the authored message), with one it is the repository's and holds when the record is persisted CARRYING that status. "days > 0 before SUBMITTED" is the rule base-vacations actually needed and mis-authored as an `itemsMin`, which counted a child the approval delegate had not created yet and refused every submission in the field for three weeks. +**What a row IS when no single field says it (entity-level `unique:`, [#6793](https://github.com/eclipse-dirigible/dirigible/issues/6793)/[#6796](https://github.com/eclipse-dirigible/dirigible/issues/6796), cross-model since [#7116](https://github.com/eclipse-dirigible/dirigible/pull/7116)):** an entity declares its **composite business keys** - `unique: [{ fields: [ProjectTimesheet, Employee], message: "..." }]` - beside the field attribute `unique: true`, which can only ever say that ONE column is a key. Each member is an own field or an own **to-one** relation, which contributes its foreign-key column; a to-many and a `subset` are refused (neither has a column on this side to constrain - a subset column holds a normalized SET of the target's keys, not an identity), a single-name key is refused naming the field attribute it duplicates, and so are a repeated member and a repeated key. `EdmIntentGenerator.uniqueConstraint` emits one `.edm`/`.model` constraint per key: the name is `_...` in PascalCase and each column is `upperSnake(entity)_upperSnake(member)` - **derived from the names, with no lookup of the member's target**, which is exactly why a **cross-model** to-one qualifies like a same-model one (#7092/#7116): the consumer stores the target's id in its own FK column and the projection entity is only the read-side copy that feeds the dropdown. That is the shape most transactional keys in a modular fleet have - `(projectMonth, Employee)`, `(payrollRun, Employee)`, `(Customer, period)` - where the master data is owned by another module by design. The constraint carries `properties` (the authored property names, for the `.edm` twin the modeler rebuilds the `.model` from, so a later `dataName` change follows the key) alongside the `columns` the schema template emits, and a `message` - the authored one, else a generated "A with the same , and already exists". The key is created in the database AND recognised by the generated controller, so a colliding write is answered **409 with that message** instead of a driver-specific 500 (anchored to the innermost `SQLException`, [#7138](https://github.com/eclipse-dirigible/dirigible/issues/7138)). A partitioned document number (`number: { per: ... }`) synthesizes the `(partition, number)` key it means, unless the author already spelled it out in either column order. + **A form may show a field of its COUNTERPARTY ([#7093](https://github.com/eclipse-dirigible/dirigible/issues/7093)):** a task form's `fields` take one-hop `relation.field` paths, but the validator resolved the hop against LOCAL entities only - so the one relation a billing document's form most needs to read a field of, its counterparty, was the one it refused (`form [SendSalesInvoice] field [Customer.email] references unknown field [email] on [Customer]`), while the same path already resolved cross-model as a `notify` recipient and a `languageFrom`. A cross-model to-one is now resolved where every other cross-model reference is: at GENERATION, against the owner model's `.model`, which supplies the perspective the generated resolver's imports name and the key type behind its `Number` accessor - the delegate loads the OWNER's `gen..data.` Entity/Repository, the registry-wide-compile mechanism a notify relation load already uses, and the control renders read-only like a local hop. A field the owner model does not declare is a **422** rather than a skipped resolver: skipping it would leave the BPMN with a service task pointing at a handler nothing generated and the control bound to a variable nothing ever sets. The same path in a `decision` condition comes with it, being one resolver. **Deleting a header deletes the lines it owns (`whenMasterDeleted:`, [#7100](https://github.com/eclipse-dirigible/dirigible/issues/7100)):** a deleted master left its composition children behind - rows pointing at an id that no longer exists, invisible in the UI (no parent page renders them) and still counted by every report and roll-up over the child, so a deleted vacation request's five days kept the entitlement EXHAUSTED. The cascade is now emitted for EVERY composition master, because it is what composition MEANS: the master's generated repository deletes the children at the head of `delete`/`deleteById`, in the same transaction and through each child's OWN repository, so the child's `-deleted` event (hence the roll-up relinquishing), its history trail and its own cascade all run - a deep chain unwinds level by level. The reverse index this needs (`CompositionChildren` in `ide-template`) is DERIVED from the child's `masterEntity`/`masterEntityId`, so a hand-authored `.edm` gets it too. The author's alternative is `whenMasterDeleted: refuse` on the child's composition relation - the same method rejects the master's delete while any child exists, naming both entities - which is refused at parse on a non-composition and on a SECOND composition (the EDM emits that one as a plain association, so the key would ask for a cascade nothing would run). `cascade` is the default and emits no `.edm` attribute, so an untouched model is byte-identical. This is the data-side half of the process-side `whenDeleted: abort | refuse` (#7074). diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 66034c63ebe..75b77caa56a 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -424,6 +424,7 @@ Semantics worth knowing: - **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). +- **`unique:` on an entity = the composite business key (#6793/#6796; cross-model members since #7116/#7092).** `unique: [{ fields: [ProjectTimesheet, Employee], message: "..." }]` declares what a row IS when no single field says it - the field attribute `unique: true` can only ever key ONE column. `UniqueIntent` + parser `validateUnique`: every member must resolve to an own field or an own **to-one** relation (whose foreign-key column is what the key spans); refused are a to-many, a `subset` relation (its column holds a normalized set of the target's keys, not an identity), a single-name key (naming the field attribute it duplicates - two ways to say the same thing is how the two drift apart), a repeated member and a repeated key. **A to-one passes whether or not its target is cross-model, and that is the whole of #7116**: both store the target's id in this entity's own `_` column, the projection entity being only the read-side copy that feeds the dropdown - so `EdmIntentGenerator.uniqueConstraint` derives the column name (`upperSnake(entity)_upperSnake(member)`) and the constraint name (`_...`, PascalCase) from the NAMES, with no lookup of the owner's `.model`, which is why no cross-model resolution step was needed. The natural key of most transactional rows in a modular fleet has exactly that shape - `(projectMonth, Employee)`, `(payrollRun, Employee)`, `(Customer, period)` - where the master data is owned elsewhere by design. Each constraint map carries `columns`/`columnsCsv` (what the schema template emits), `properties` (the authored property names, so the modeler's `.edm` twin re-resolves them when it rebuilds the `.model` and a later `dataName` change follows the key) and `message` (the authored one, else a generated "A with the same and already exists") - which the generated controller answers a collision with, as a **409** anchored to the innermost `SQLException` (#7138) rather than a driver-specific 500. A `number:` field partitioned by `per:` synthesizes the `(partition, number)` key it means, unless the author already declared it in either column order. Covered by `IntentCrossModelUniqueIT` (the cross-model half, generation + collision) and the `PartyCode` fixture in `IntentEmissionCoverageIT` (the same-model half at runtime: 200, 409 on the repeat, 200 with one column flipped). - **`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:` or `value:` — a value of the row related by an operator to a second one: another of its own fields (a due date not before the document date, a validity `to` not before its `from`, #7095) or a LITERAL (a quantity greater than zero, a percentage at most 100, #7338)) 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 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). **The right-hand side may be a LITERAL instead of a second field (#7338)**, `value:` and `than:` mutually exclusive and exactly one required — which is what makes "a quantity is positive", "a percentage is at most 100" and "a date is not in the past" declarations instead of a hand-edit of the generated `validate()` (dropped, silently, by the next regeneration) or a `calculatedActionOnCreate` that throws (a calculation, not a refusal, and only on the field that declares it). The literal is TYPED by the field it is compared with — a number for a numeric field; for a temporal one a moment (`CURRENT_DATE` / `CURRENT_TIMESTAMP` / `NOW` with at most one signed ISO-8601 offset, the vocabulary a schedule's `where:` already carries, resolved against the clock of the WRITE) or a quoted ISO-8601 date/instant — and `CheckSupport.compareLiteral` is the ONE rule the parser refuses on and the generator renders with, so nothing is refused that would have generated and nothing generates that was not refused. It renders in the shape the generated column actually carries (`LocalDate` for a `date`, `Instant` for a `timestamp`), because a comparison across those two does not compile. Unlike `exactlyOne`, a `compare` takes the OPTIONAL `status:` gate, the same routing `requiredWhen` has: without one it holds on every user write (the three controllers), with one it is the repository's and holds when the record is persisted carrying that status — "days > 0 before SUBMITTED", the rule base-vacations mis-authored as `itemsMin`, which counted a child the approval delegate had not created yet and refused every submission in the field for three weeks. 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. **And it crosses an authored service task too, since #7371:** a custom `delegate:` between the decision and the setter (base-inventory's six posting flows) used to stop the walk, on the reasoning that its own completion is what the person waited for - measured, its boundary commits the completion and the gate then refuses a detached job: 200 with an empty body, the task consumed, the document never moved, and once the job's retries are exhausted an instance stranded with no task in anyone's Inbox. `joinsTheCompletingTransaction` is now what the walk stops at: a second user task or a `wait` is its own wait state, `end` is the end, and a step declaring `retry:` keeps its boundary because a Flowable failed-job retry cycle re-runs a JOB and there is no job without one (an `onError:` alone needs none - `IntentStepResilience` converts a synchronous first-and-final failure just as it does an exhausted asynchronous one). A gate reachable only across a retrying step is therefore still a background incident, and the generator says so in a WARN naming the gate, the task and the way out. 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/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCrossModelUniqueIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCrossModelUniqueIT.java index 5f3170a684b..220bc1dd0e6 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCrossModelUniqueIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCrossModelUniqueIT.java @@ -10,7 +10,9 @@ package org.eclipse.dirigible.integration.tests.api; import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.both; import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThan; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -264,7 +266,7 @@ void cleanup() { restAssuredExecutor.execute(() -> given().when() .delete("/services/ide/publisher/" + WORKSPACE + "/" + project) .then() - .statusCode(greaterThanOrEqualTo(200))); + .statusCode(both(greaterThanOrEqualTo(200)).and(lessThan(300)))); if (repository.hasCollection(projectPath(project))) { repository.removeCollection(projectPath(project)); }