Skip to content

Commit f71562e

Browse files
delchevclaude
andauthored
feat(intent): a roll-up's counted child may be owned by another model (#6930) (#6932)
A `rollups:` entry required the CHILD - the rows being aggregated - to be declared in the same model as the parent whose field it maintains. The mirror case had been opened before (a local child, a cross-model PARENT reached through the `via` relation's own `model:`), but the inverse was inexpressible, and it is the one an n:m pairing forces: a link entity lives with the document that owns ONE side of the pairing, while the other side's total belongs to the module that owns that side. Accounts receivable is the case. `sales-invoices` owns the allocation entity and therefore the invoice-side roll-up (paid / balance / PARTIAL-PAID) works today. The payment side of the same rows could not be declared at all: the sum of a payment's allocations, and the unapplied figure derived from it, belong to `customer-payments`. So "is this payment fully applied?" was answered by a register report instead of a stored number - which cannot be a list column, a dashboard figure, a schedule condition or a report dimension. Two keys, declared by the module that owns the parent: rollups: - { name: paymentAllocated, entity: SalesInvoiceCustomerPayment, model: sales-invoices, parent: CustomerPayment, via: CustomerPayment, field: allocated, op: sum, of: amount, capacity: amount, balance: unapplied } `model:` is the owner's `uses:` alias - the same key, with the same meaning, that a cross-model schedule source already takes. `parent:` is authored rather than derived because a foreign child's relations are not in this document: nothing here can walk `via` to a target, which is also why `via` / `of` / `by` are resolved against the OWNER's `.model` at generation time and a miss drops the roll-up loudly. It must be LOCAL - a total landing in a third model is that model's roll-up to declare, and writing it from here would invert the dependency edge. The DSL follows the existing rollups vocabulary rather than the issue's `child:` / `match:` / `into:` sketch, which the issue's own stated precedent (schedules, `fromUses:`) implies. Emission is the child's coordinates coming from the owner: `childProject` (the topic - this project publishes nothing about that entity, so a local topic would subscribe to silence) and `childGenFolder` (the imports), both defaulting to this project, so a local roll-up renders byte-identically. The class name is prefixed with the owner alias and the pipeline's coalescing key gains `childModel`: a local and a foreign child of the same name rolling up through the same relation are two handlers, and one class name for both would have the pipeline write one file over the other. Guards against a silent wrong total: `via` must reference the declared parent, which needs the owner's `relationshipEntityName` - now carried on `CrossModelSupport.TargetInfo.propertyRelations`. A property that resolves but points elsewhere would key the aggregate on foreign ids and look the parents up by them. Three deliberate limits. `capacity` / `balance` / `status` ARE maintained (all three are writes on the local parent, and the unapplied balance is half the driver), but the overdraw GUARD is not installed - that check lives in the child's own write path, which the owner module generates - and Generate reports it rather than letting a capacity pass for an enforced limit. The vacated side of a re-parented foreign row is repaired only when the owner publishes a re-key notice for that relation, since `groupingKeys` is the union over the OWNER's own consumers; the handler is emitted regardless (same store-driven recompute, it converges whenever the notice does arrive) and delete + re-create is exact. And `sensitive:` / `visibleTo:` do not propagate from a foreign field, so a restricted total declares its own restriction. Both `EdmIntentGenerator` sites that walk the roll-ups skip a cross-model child (`groupingKeys`, `buildRollupGuards`), as do the two parser propagation loops - otherwise a local entity that merely SHARES the foreign child's name would be treated as it. The editor's glue diagram anchors such a card to the local parent and names the foreign child in its detail line, as a cross-model schedule does. Covered by `GlueRollupCrossModelTest`: the emitted coordinates and class name, the perspective read off a REAL owner model, a `via` pointing at another entity and an unresolvable `of` both dropped with an issue, every parse refusal, and the local case unchanged. `IntentEngineIT` (58) and `IntentEmissionCoverageIT` guard the local rendering path the template rename touched. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent e907fe4 commit f71562e

12 files changed

Lines changed: 620 additions & 24 deletions

File tree

components/engine/engine-intent/CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ Semantics worth knowing:
398398
- **`resolves:` = the effective-dated register lookup (#6712).** The enterprise shape with no declarative form before it: a register says "X applied to Y from A to B" (a vehicle assignment, a price list, a contract in force, an org assignment), a record carries the match key(s) and a date, and a to-one must be filled from the row whose period covers that date. Nothing else in the DSL reaches it - `dependsOn` is a UI-time copy with equality matching only, a `decision` condition is a single comparison, and `setField` writes constants - so every application hand-wrote the same delegate. Authored as `{ event: { onCreate|onUpdate: <Record>, when? }, set: <to-one>, from: <Register>, match: { <registerProperty>: <recordProperty>, ... }, between: { start?, end?, value }, outcome?: <string field>, found?/notFound?/ambiguous?: { setStatus } }`; `ResolveIntent` -> `GlueIntentGenerator.buildResolves` -> the `resolves` glue collection -> `Resolve.java.template`, a `@Component MessageHandler` on the record's event topic. **All three outcomes are first-class, and that is the point of the construct:** exactly one covering row fills the relation, NO covering row and MORE THAN ONE covering row both leave it unset (an automation that silently picks one of two candidates is worse than none - the ambiguous register goes back to a human). Each outcome may route the record by `setStatus` (seed id or seeded name, resolved by `StatusSymbolResolver` like every other status site), and the attempt is **observable**: `outcome:` stamps `found`/`notFound`/`ambiguous` into a string field of the record - queryable, filterable in a list view, and readable by a process `decision` - and the handler logs the keys and the date it checked. **Decisions worth keeping:** the value copied 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 rather than a guess (the same refusal, one altitude up); a record that already carries the relation is skipped, so a manual correction is never overwritten and a re-delivered event is a no-op; **the RESULT and the ROUTING are two targeted writes, in that order** - `updateProperties` of the relation + the outcome, then `updateProperty` of the status - because the DAO runs the `lifecycle:` and `checks:` gates against the post-write row BEFORE persisting anything, so batching the three meant a rejected status move discarded the identification and the audit trace with it (the lookup did the work, got the right answer, and threw all of it away). The routing write catches the `ValidationException`: retrying cannot help - nothing about the record changes by re-reading the register - so it logs and amends the trace to `<outcome>-notRouted`, which is what keeps a routed-but-rejected record distinguishable from a fully processed one. The parser enforces the trace field is long enough for those values (19 once any outcome routes), since truncation happens at the DB where nothing reports it. No `-updated` re-fires and no concurrent write to another column is reverted; period bounds are optional on either side (open-ended = still valid), the end is INCLUSIVE, and a date-only bound covers its whole day (the generated `millis`/`endExclusive` helpers put a `LocalDate` and an `Instant` on one epoch-milli axis, UTC). v1 is same-model (`from:` must be declared here) and binds to `onCreate`/`onUpdate` only - `onDelete` is refused, there is nothing left to fill. An optional **`where: { <register property>: <literal> }`** (one or more pairs, ANDed into the `Criteria`) is the only way to narrow the register by a constant, and it closes a defect that got worse with age: every `match` pair binds a register column to a column of the RECORD, so "and only the rows still valid" had no form at all - while a register KEEPS its corrections, so a cancelled row went on covering its old period forever and turned a lookup with exactly one right answer into a permanent `ambiguous` that routed to a human and logged "multiple matches". A status pair may use the seeded NAME, resolved on the REGISTER's own nomenclature (`StatusSymbolResolver.rewriteResolveWhere`) - the record's would hand back a plausible id from the wrong lifecycle - and only that one pair is offered to the resolver, so an ordinary string column like `kind: PRIMARY` is not reported as an unknown status. A pair repeating a `match` key is refused: on a column already bound to the record a literal either repeats the match or contradicts it into matching nothing, and which one depends on data the parser cannot see. Multiple pairs are allowed although the relation-level `where:` caps at one - that cap exists because it lands in two EDM attributes, whereas these are chained `Criteria.eq` calls where a second condition costs nothing. Covered by `GlueResolveWhereTest` (including the two nomenclatures deliberately numbered differently, so resolving against the wrong entity cannot pass) and an `IntentEngineIT` assertion. The parser refuses a `when` guard it cannot render rather than degrading it to an always-open guard.
399399
- **Every DERIVED write is targeted (document totals, `rollups:`, `aggregates:`) — the last member of the lost-update family.** A recompute reads a row, changes the one or two columns it computes, and persists. Persisting the WHOLE row silently reverts any concurrent write to another column of that row: the trigger `ProcessId` variant was fixed in #6226 and the workflow setter/writer variant in #6306, and the recompute variant was live-reproduced against a roll-up (REST-create a parent, PUT another column immediately after → 200, but a re-read shows the OLD value; the recompute had read the row before the PUT and wrote its stale snapshot after it). All three recompute sites now write only what they computed: `Repository.recalculate(Object)` collects the document totals into a map and calls the base `super.updateProperties` (no gate checks, no `-updated` — exactly the previous `super.update` semantics minus the merge); `Rollup.java.template` and `Aggregate.java.template` collect each recomputed column into a `derived` map and persist through the generated **`updateDerived(id, values)`**, which routes through `updateProperties` (so a `checks:` entity still runs its gate and a labelled entity still refreshes its `Name`) and then re-publishes `<project>-<perspective>-<entity>-updated` — the event contract the old full-row `update()` provided, which TRANSITIVE roll-ups above the row depend on. Two invariants when touching these: a column assigned in the recompute must also be put into `derived` (a capacity roll-up writes count + balance + status), and an EMPTY `derived` map means nothing is persisted, so the map is what the emission oracle asserts. Covered by the `IntentEmissionCoverageIT` derived-write assertions (Bill document totals, `ClaimLineClaimRollupOnCreate`, `LedgerTotalAggregateOnCreate`). **The reverse direction had the same hole (#6822):** the master's resum was wired only to the item's FULL write paths (`save`/`update`/`delete`), so a line written by a TARGETED primitive - a workflow `setField`, any glue `updateProperty`/`updateProperties`/`updateDerived`, or the event-suppressed `updateWithoutEvent` - moved the line and left the header displaying, printing and POSTING a total that did not equal the sum of its lines. Those paths now resum too, guarded on the columns actually written (an aggregated column, or the FK - which MOVES the line, so both the document it joined and the one it left are resummed), so a status hop still costs nothing extra. It cannot recurse: the master's `recalculate` persists through the BASE targeted write.
400400
- **Re-parenting is a two-sided event, and `-rekeyed` is the whole mechanism (#6819).** A row whose grouping column moves - an `aggregates:` key, or a `rollups:` child's `via` FK - leaves one group and joins another, and the ordinary events name only the group it belongs to NOW: `-updated` carries the written row, so the group it LEFT is named by nothing and kept the row's contribution forever (a cost centre reassigned by a workflow step; a `sum` roll-up whose parent FK an ordinary edit re-points). The repair is one dedicated topic, `<project>-<perspective>-<entity>-rekeyed`, which **only** the generated aggregate / roll-up handlers subscribe to - so a write can signal them without re-publishing `-updated` and spuriously re-firing every reaction. Three parts, and all three are needed: (1) the entity's `.model` carries **`groupingKeys`** - the union of every aggregate key over it AND every roll-up `via` FK whose child it is (`EdmIntentGenerator`; it used to be `aggregateKeys`, aggregates-only, which is why re-parenting a roll-up child was invisible); (2) the DAO compares those columns before/after on **both** write paths - the full-row `update()` publishes the PREVIOUS row (the group it moved into is recomputed off `-updated` like any other change), and `updateProperties` - the targeted primitive every workflow setter, `resolves:` and task-form writer goes through, which publishes no `-updated` at all - publishes the previous row AND the written one, since on that path neither side has an event otherwise; (3) both handler families bind it, the aggregate as its `OnRekey` variant and the roll-up as `RollupOnRekey`. Each handler recomputes the group the PAYLOAD names, from the store, so one class repairs either side and re-delivery converges. The publish is gated on a key having actually moved, so a normal edit costs nothing extra and the cascade still terminates at rest.
401+
- **A roll-up's CHILD may be owned by another model (#6930), which is the n:m allocation direction.** `rollups: [{ entity: <foreign link>, model: <uses alias>, parent: <local entity>, via: <the foreign child's FK>, field: ..., op: sum, of: ... }]` - declared by the module that owns the PARENT. The cross-model *parent* direction (a local child, `via`'s own `model:`) already existed, but the inverse was inexpressible, and it is the one an n:m pairing forces: the link entity lives with the document that owns ONE side (`SalesInvoiceCustomerPayment` belongs to `sales-invoices`, whose `invoicePaid` roll-up is local and works), while the OTHER side's total (`CustomerPayment.allocated`, and the `unapplied` figure derived from it) belongs to the module that owns the payment - so it had no declarative form at all and was answered by a register report instead of a stored, filterable number. **`parent:` is authored rather than derived** because a foreign child's relations are not in this document: nothing here can walk `via` to a target, which is also why `via` / `of` / `by` are resolved against the OWNER's `.model` at generation time (`firstUnresolvableChildProperty`, the schedules' cross-model-source rule) and a miss drops the roll-up loudly. The parent must be LOCAL - a total landing in a third model is that model's roll-up to declare, and writing it from here would invert the dependency edge. Emission-wise the child's coordinates simply come from the owner: `childProject` (the topic - this project publishes nothing about that entity, so a local topic would subscribe to silence) and `childGenFolder` (the imports), both defaulting to this project so **a local roll-up renders byte-identically**; the class name is prefixed with the owner alias and the pipeline's coalescing key gains `childModel`, because a local and a foreign child of the same name rolling up through the same relation are two handlers, and one class name for both would have the pipeline write one file over the other. Three deliberate limits: **`capacity`/`balance`/`status` are refused** (the capacity guard lives on the CHILD's DAO, which the owner model generates - a recomputed balance with no guard behind it would look like a limit and enforce nothing); **the vacated side of a re-parent is repaired only if the owner marks that relation as a grouping key**, since `-rekeyed` is published by the owner's DAO and `groupingKeys` is the union over the OWNER's own consumers (the handler is emitted regardless - it is the same store-driven recompute and converges whenever the notice does arrive; delete + re-create is always exact); and **`sensitive:`/`visibleTo:` do not propagate** from a foreign `of` field, so a restricted total must declare its own restriction. Both `EdmIntentGenerator` sites that walk `model.getRollups()` skip a cross-model child (`groupingKeys`, `buildRollupGuards`), as do the two parser propagation loops - otherwise a local entity that merely SHARES the foreign child's name would be treated as it. Covered by `GlueRollupCrossModelTest` (the emitted coordinates + class name, the local case unchanged, and every refusal).
401402
- **`checks: kind: guard` = a precondition over a keyed `aggregates:` sum, with three outcomes.** The negative-stock / credit-limit / remaining-allowance shape: `aggregate:` names an `aggregates:` entry whose `of` is THIS entity (v1 self-referential), and the post-state is checked against `minimum:` (default 0). The sum is recomputed SYNCHRONOUSLY from the guarded entity's own store for the incoming row's key-tuple, excluding this row on update, then the incoming value is added - deliberately NOT read from the async-maintained aggregate target, so the decision cannot race the handler. Consequence worth remembering: the guard and the materialised aggregate are two independent computations of the same sum, and the guard is the authoritative one - do not "optimise" it into a target read. `enabledBy: <CONFIG_KEY>` wraps the whole guard in a `Configurations.get(key) == "true"` gate (a tenant-level business toggle). Emitted by `EdmIntentGenerator.buildChecks` (keys + `sumField` + `pk` + `minimum` + `enabledBy` + `outcome`) → `ModelParameterProcessor` splits `guardChecks` out → the DAO's `#aggregateGuardCheck` macro at both the save and update sites. **`outcome:` decides what a violation DOES**, and each non-default outcome carries its own companion key (parser-validated - a companion belonging to another outcome is an ERROR, since the write would look guarded and do nothing):
402403
- **`block`** (the default) - throws `ValidationException`, so the REST write fails with 4xx and nothing is persisted.
403404
- **`task`** + **`marker: <boolean field>`** - does NOT fail the write. It stamps the marker (`false` on violation, `true` when it holds) as the BRANCH INPUT a process `decision` reads to route the record to a hold/review step. The division of labour is deliberate and must stay documented as such: this keyword stamps a flag, the process decides what the flag means - the DSL neither creates nor routes to a task.

components/engine/engine-intent/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,23 @@ rollups:
548548
Roll-ups compose transitively across a multi-level composition (leaf edit -> mid total -> top
549549
total); recomputation stops when values stop changing.
550550

551+
Either end may be owned by another model. A cross-model PARENT is named by the `via` relation's own
552+
`model:` alias (the child is local and owns the event). A cross-model CHILD is named by the roll-up's
553+
`model:` plus a `parent:` naming the local entity the total lands on - the n:m allocation direction,
554+
where the link rows live with one side of the pairing and the other side's total belongs here:
555+
556+
```yaml
557+
rollups:
558+
- { name: paymentAllocated, entity: SalesInvoiceCustomerPayment, model: sales-invoices,
559+
parent: CustomerPayment, via: CustomerPayment, field: allocated, op: sum, of: amount }
560+
```
561+
562+
`capacity`/`balance`/`status` are refused for a cross-model PARENT (they read its own fields and
563+
seeds) but work for a cross-model CHILD, where they are writes on the local parent - except the
564+
overdraw guard, which belongs to the child's own DAO and is reported as not installed. The vacated
565+
side of a re-parented FOREIGN child is repaired only when the owner model marks that relation as a
566+
grouping key.
567+
551568
## settlements - payment allocation
552569

553570
```yaml

0 commit comments

Comments
 (0)