Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/engine/engine-intent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ Semantics worth knowing:
- **`hierarchy:` on an entity + `leafOnly:` on a to-one relation = tree entities (chart-of-accounts shape).** `hierarchy: Parent` names the entity's own optional to-one self-relation as the tree edge (parser `validateHierarchy`: self-target, non-composition, optional — a required parent leaves no way to author a root). Emitted as the entity-level `hierarchyProperty` (PascalCase FK property) on the `.edm`/`.model`; `CrossModelSupport.TargetInfo` gained a `hierarchyProperty` component so a cross-model referencing module can read it off the owner's `.model`. `leafOnly: true` on a relation targeting a hierarchical entity emits `widgetLeafOnly` + `widgetHierarchyProperty` (the TARGET's edge property) on the FK; parse-checked same-model, generation-checked (loud) for a resolved cross-model target. Enforcement is dual-layer like `where:`: the generated REST controller gains `validateReferences` (leafOnly = child-count via the target's repository — `ModelParameterProcessor.leafOnlyRepositoryClass`, a cross-model Java import that resolves because client-Java compiles registry-wide; plus a walk-up cycle guard for the entity's own edge, depth-capped at 100), while the Harmonia pickers mirror it (`hierarchizeOptions`: depth-first order, em-space indentation, leaves only — form page, document header, and the item dialog via `detail-register`'s `#hierMeta` → `col.hier` + the `filteredOptions` store). The manage LIST renders as a Harmonia `x-h-tree` when the entity declares a hierarchy — fixed-depth markup recursion (6 levels; Alpine has no recursive template), search/column-filters fall back to the flat table so matches in collapsed branches stay findable. Plain (non-leafOnly) pickers to hierarchical targets stay flat — indentation rides the leafOnly metadata only (documented v1 scope).
- **`order:` on an entity = explicit UI control order.** A list of property names (fields + to-one relations interleaved, matched case-insensitively against the authored names) that sequences the generated form inputs / list columns / detail rows. Default (no `order`) is fields-in-declaration-order then to-one relations, which pushes every relation last — bad UX for a line-item form where `Product`/`UoM` want to sit next to `Name`/`Quantity`. `EdmIntentGenerator.applyOrder` reorders the built `properties` list (which the templates AND the mxGraph diagram both consume) before emitting; a **partial** order is honoured (unlisted properties keep their relative position, appended after the listed ones), and system properties (`ProcessId`/audit columns) are simply left unlisted. The parser (`validateOrders`) checks every listed name resolves to a declared field/relation and rejects duplicates. Worked example: `sample-intent-multi-model` `SalesInvoiceItem` (`order: [Id, SalesInvoice, Product, Name, Quantity, UoM, Price, Discount, Net, Vat, Total]`).
- **The notify fan-out addresses TWO records, and says which one it means (`attach: recordPrint` + the `record.` scope, #6717).** `notify.forEach` sends one message per row of a related entity, and until now everything in the block resolved against the ROW - right for the payslip case (one document per row), impossible for its mirror: **one** document to **many** recipients, where the rows are only the recipient list (a request for quotation mailed to each invited supplier, an agenda to each participant) and the document belongs to the record they hang off. **`attach: recordPrint`** renders the fan-out's **anchor record** instead of the row - fan-out-only (without one, `attach: print` already renders that record), the ANCHOR is what must be a printable document, and `language:`/`languageFrom:` then read off it, because the render happens **ONCE**: `NotifySupport.printAttachment` pre-renders every expression against the `source` local (`RECORD_LOCAL`) instead of the per-row `entity`, and both fan-out templates (`Send`, `Transition`) call a `renderDocument(source)` before the loop - and not at all when there are no rows, so an empty recipient list costs no render and cannot fail a step for nobody. **Placeholders are explicitly scoped:** a bare path keeps resolving against the ROW and the reserved prefix **`record.`** reaches the anchor (`{record.number}`, ONE field of it - a walk on would need a second load per message and belongs in a field of the record). The recipient may never be record-scoped (the rows ARE the recipients, so it would mail one address once per row), and `record.` outside a fan-out is an error (there the bare placeholder already IS the record's) - implicit mixing is how a message quotes the wrong party with nothing in the output to show it. The generated per-row send method takes the anchor only when a message actually quotes it (the `notifyRecordScoped` glue key). **Alongside it, a `forEach` at a call site that cannot generate one is now REJECTED** - a `schedules[].notify` already runs per matched row and a `notifications[]` entry is about the event record, so both parsed it and silently sent the single per-record message instead (the authored-but-unconsumed class). Covered by `GlueSendDocumentTest` (scoping + the six rejections) and `IntentEmissionCoverageIT` (the `BillFlow` `shareBill` step: one render call, `renderDocument(source)` fed with the anchor's key, the row-scoped recipient, the record-scoped subject).
- **A notify body carries LINKS, and the intent never spells a route (`{recordUrl}` / `{inboxUrl}`, #6553).** `{appUrl}` (#6642) supplies only the origin, so "you have an approval waiting" still had to hand-type `{appUrl}/services/web/<project>/gen/<model>/index.html#/Order/{id}/edit` into the body - the generated app's URL layout, typed into the one artifact that is forbidden to know it, and silently stale the day a template changes it. **`{recordUrl}`** is the deep link to the record the message is about and **`{inboxUrl}`** the link to the recipient's process Inbox; both are reserved tokens resolving to a **bare Java identifier** - `NotificationSupport.Resolver` emits the name and records the use, `NotifySupport.deepLinkFields` carries the only facts the intent owns (`recordUrlEntity` / `recordUrlKeyProperty` - model facts, not paths) plus a `uses*` flag per link, and the **events template DECLARES the local**, composing `<appUrl>/services/web/<project>/gen/<genFolder>/index.html#/<Entity>/<id>/edit` from the parameters it already has. That split is the whole design: it is the template layer that knows the routes (the same reason `Trigger.java.template`, not the generator, assembles `__entityUrl`), so the path-agnostic rule holds and a route change stays a template change. A link is declared only where the message names it (no dead local), and one **fan-out** links the ROW like every other bare path - `{record.<field>}` reaches the anchor for VALUES, but there is deliberately no anchor LINK. All four notify call sites got it from the two shared seams (`buildNotifications`, `buildSchedules`, `notifyFields` -> transitions + sends), so no call site can be forgotten. Covered by `NotificationSupportTest`, `IntentEngineIT` (both links, and the unused one NOT declared) and `IntentEmissionCoverageIT` - where it is also the compile proof: an undeclared local fails the whole client-Java batch and every REST assertion in that gate.
- **`transitions:` (top-level) = guarded on-demand status flip (void / cancel / close / reopen).** The missing affordance for a document whose create-time process has ENDED: process triggers fire only on create/update/delete, and `actions:` only opens a custom page - nothing declarative could transition a finished document again. `TransitionIntent` + parser `validateTransitions` (forEntity must declare a `function: EntityStatus` relation; `from:` = non-empty list of allowed source seed ids; `setStatus:` = target seed id not in `from`; optional `when: "<Field> ==|!= <number>"` guard over an own field, resolved case-insensitively - the identifier follows the Calc PascalCase convention). Two halves, the `generates` pattern: `TransitionsIntentGenerator` (`@Order(470)`) contributes the per-record button (`<name>-transition-action.extension`/`.js` on `<project>-custom-action`, descriptor carries `endpoint`); `GlueIntentGenerator.buildTransitions` pre-renders EVERYTHING (the `allowedExpr` over an `int currentStatus` local, the `when` guard as a full `Calc.eval(...).compareTo(...)` expression - null field reads as 0) into the `transitions` glue collection -> the pipeline's collection case -> `Transition.java.template`: a `@Controller` at `gen/events/<module>/<ClassName>Transition/run` that re-loads the record, returns **409** (via `sdk.http.Response.setStatus`) with the reason when a guard fails, flips ONLY the status column via the targeted `updateProperty` (no `-updated` re-fire - no onUpdate reactions), re-loads, and publishes `-transitioned` - the SAME channel the workflow setters and `generates.sourceStatus` publish, so `postings:` glue observes a manual void exactly like a workflow transition. This realizes the "guarded transition" half of the Tier-2 `lifecycle:` sketch below for the post-process case. Covered by `TransitionsIntentTest` + `GlueTransitionsTest` + the `IntentEmissionCoverageIT` transitions assertions.
- **`generates` + `event:` = the create-from runs itself (#6711).** A create-from was strictly a **user action** - a button on the source view - so "when the source reaches this state, mint the follow-up document" had no expression: a `generates` button plus a process `wait` degraded the automation to a person remembering to click (and an unclicked record parks its instance forever), `posts` is event-driven but emits **flat mapped rows** and cannot reference the freshly created header, and the remaining option was a hand-written `delegate`. A `generates` entry now accepts `event: { onTransition: <from>, when: "<StatusRelation> == <status>" }` (guard mandatory, status by seeded NAME or id) or `{ onCreate: <from> }` (guard optional - a source with no lifecycle), mirroring `postings`' event axis. **The event says WHEN, never what**: the entity it names must be the one `from:` declares and `model:` is rejected (`fromUses:` owns that), both parser-checked - two ways to name the source could only drift. **At-most-once is derived, not declared twice**: the `map` entry copying the source's PK IS the back-reference, so `GlueIntentGenerator.putGeneratesEvent` derives `backRefProperty` from it and fails loudly when it is missing (the parser catches the local case earlier with the fix in the message; the cross-model source's key field is only known once the owner `.model` resolves). Emission: the existing `Generate.java.template` was refactored so its body is a `create(Integer sourceId)` method carrying the guard (`findAll(eq(backRef, sourceId))` -> return the existing document), and a new **`GenerateOnEvent.java.template`** renders a `MessageHandler` on the source's `-transitioned` (or bare create) topic that re-loads the source, applies the status guard and calls `new <X>Generate().create(id)` - **it carries no mapping of its own**, which is what keeps the two triggers from diverging. The listener is a collection of its own (`generateEvents`, the filtered `generates` list - one file per entry is the collection contract, and a create-from with no event must contribute no listener) but shares `bindGenerate`, so both templates see the same descriptor. `button:` decides the click half: default **true** without an event and **false** with one (declaring an event is how an author says nobody has to click), `button: true` keeps both (they share the one guard), `button: false` with no event is rejected - the action would have no trigger at all. Without a button the class gets no `@Controller`/`@Post` and no custom-action descriptor or i18n label - no endpoint nothing links to. **The template gates the controller half on the NEGATIVE (`#if(!$eventOnly)`)** so a `.glue` written before this key existed keeps rendering the endpoint it always did. `sourceStatus:` composes (the flip cannot re-trigger the create-from - the guard has already claimed the source). Covered by `GeneratesIntentTest` + `GlueGeneratesTest` + `ModelGenerationIT`'s glue fixture (the listener renders with no unresolved reference) + `IntentEmissionCoverageIT` at both layers: posting a Slip mints the Voucher **with its computed line** while nobody calls the create-from, and a click afterwards returns that same voucher.
- **`prompt:` on a `generates` action = a declared input form before the create (#6685).** The gap it closes: `transitions:` writes but takes no input and `generates:` creates but declares every value up front, so an action that collects the two answers the source cannot derive (which payment, how much) had to be a hand-written page. It reaches a post-issue child on an IMMUTABLE document too, because per-record action buttons are deliberately NOT gated on mutability (that is why Void works) - the **action-shaped sibling of `locksWithMaster: false`** (#6700), which reopens the child's own panel: the panel is the affordance for ordinary data entry, a prompted action for a guided create over mostly-derived values. `prompt:` entries name fields / to-one relations of the TARGET; parser (`validateGeneratesPrompt`): local target only, target must declare a composition to-one relation to `forEntity` (that guarantees the generated detail registration the dialog renders from), scope `entity`, no `timestamp` fields, no overlap with `map`/`defaults` (one writer), no duplicates, and **no `event:`** (an event-driven create-from runs with nobody there to answer the form - which is also why the prompted values ride the ENDPOINT path only: `run()` checks the required ones and passes the map into `create(sourceId, values)`, while the event listener's `create(sourceId)` signature is untouched). Server half: `promptFields` in the glue (PascalCase prop + required + a pre-rendered `Object raw` -> field-type conversion), `Generate.java.template` takes `values` in the Request, 400s on a missing required input BEFORE anything is written, and sets prompted values after map/defaults - the save still goes through the target's repository so numbering/checks/events fire. Client half: the descriptor carries `prompt` + `promptEntity` (authored names ONLY - control types, lookup URLs and `dependsOn` metadata are resolved AT RUNTIME from `App.detailsFor(view)`'s edit-columns registration, so the intent layer never references template routes); the shared `customActions` store opens an input dialog instead of the plain confirm (`openPrompt`/`promptRun` + a mini dependsOn cascade seeded from the clicked master id - the invoice's Customer chain narrows the payment list, `valueFrom` defaults the amount), degrading to the confirm when the registration is absent (the shared shell). Dialog markup rides in all five shells wrapped in the `customActionPrompt` Alpine component so the Velocity shell stays `$store`-free. Covered by the `GeneratesIntentTest` prompt tests + `GlueGeneratesTest.promptFieldsRenderTypedConversions` + the `IntentEmissionCoverageIT` prompted-generates assertions (emission + 400 + value-reaches-the-row).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ private static List<Map<String, Object>> buildNotifications(IntentModel model, M
entry.put("subjectExpression", plan.subjectExpression());
entry.put("bodyExpression", plan.bodyExpression());
entry.putAll(NotifySupport.attachmentFields(attachment));
entry.putAll(NotifySupport.deepLinkFields(plan, byName.get(entity)));
notifications.add(entry);
}
return notifications;
Expand Down Expand Up @@ -1086,6 +1087,7 @@ private static Map<String, Object> notifyFields(NotificationIntent notify, Entit
// named `entity` in the templates for exactly this reason, so one expression set serves both
// shapes), the ANCHOR record's for `attach: recordPrint`.
fields.put("attachKeyProperty", send && attachment != null ? IntentEntities.keyFieldName(document) : "");
fields.putAll(NotifySupport.deepLinkFields(send ? plan : null, about));
return fields;
}

Expand Down Expand Up @@ -2849,9 +2851,11 @@ private static List<Map<String, Object>> buildSchedules(IntentModel model, Map<S
// engine IT keys "no trigger was generated" on trigger-only keys being absent.
entry.put("attachKeyProperty", sourceCrossModel ? sourceTarget.keyField() : IntentEntities.keyFieldName(byName.get(entity)));
entry.put("criteriaExpression", ScheduleSupport.criteriaExpression(schedule));
// The attachment keys are always present (empty for a generate schedule): an undefined
// Velocity variable renders as its own name, so a template must never rely on absence.
// The attachment and deep-link keys are always present (empty for a generate schedule): an
// undefined Velocity variable renders as its own name, so a template must never rely on
// absence.
entry.putAll(NotifySupport.attachmentFields(null));
entry.putAll(NotifySupport.deepLinkFields(null, null));

if (generates) {
// Scheduled record generation: the queried row is the source, so its create-from maps the
Expand Down Expand Up @@ -2902,6 +2906,7 @@ private static List<Map<String, Object>> buildSchedules(IntentModel model, Map<S
entry.put("subjectExpression", plan.subjectExpression());
entry.put("bodyExpression", plan.bodyExpression());
entry.putAll(NotifySupport.attachmentFields(attachment));
entry.putAll(NotifySupport.deepLinkFields(plan, byName.get(entity)));
}
schedules.add(entry);
}
Expand Down
Loading
Loading