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 @@ -422,6 +422,7 @@ Semantics worth knowing:
- **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.
- **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.
- **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).
- **A roll-up's `status:` is relinquished, not only set (#7016).** The `statusWhenFull` / `statusWhenPartial` branch modelled "money arrives" and forgot "money leaves": the recompute had no `else`, so deleting the only allocation of a PAID invoice left it PAID with Paid 0 / Balance = Payable - and invisible to the settlement, whose payable statuses are ISSUED/SENT/PARTIAL. Now the FIRST move into a roll-up-owned status snapshots the status it displaces into a hidden, read-only INTEGER column on the parent, `Displaced<Status>` (`IntentNaming.displacedStatusProperty`, emitted by `EdmIntentGenerator.displacedStatusProperty` for every local parent of a capacity roll-up with a status, one per status relation; `GlueIntentGenerator.buildRollups` hands it to the emitter as `statusDisplacedField`), and a sum back at zero restores it when - and only when - the parent still holds one of the two roll-up-owned statuses, then clears the snapshot (`RollupAggregates.appendStatus`; every variant, create/update/delete/rekey, since an allocation amended to 0 or re-parented away is the same situation as a deleted one). Remembering beats a declared `statusWhenEmpty:` - that is wrong for every invoice paid straight from ISSUED and never CONFIRMED - so there is no such key. A roll-up-owned status with no recorded predecessor (a deployment upgraded mid-payment) is logged and left alone, never guessed. Both writes ride the same `derived` map into ONE `updateDerived`, so the parent's listeners see one `-updated`. The column is hidden through the **`isHiddenProperty`** flag, which is now the ONE thing the Harmonia templates consult to leave bookkeeping out of forms, lists and details blocks (`ModelParameterProcessor` sets it from the model and BY NAME for `ProcessIds`, so a `.model` written before the flag existed still hides the stamps; the modeler's serializer carries unknown attributes through the generic pass, so a hand round-trip keeps it); `isReadOnlyProperty` puts it in `preservedOnUpdate`, so a full-row form save cannot null it. With a `lifecycle:` on the parent the moves back must be declared edges like the moves in.
- **`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):
- **`block`** (the default) - throws `ValidationException`, so the REST write fails with 4xx and nothing is persisted.
- **`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.
Expand Down
7 changes: 7 additions & 0 deletions components/engine/engine-intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,13 @@ rollups:
status: Status, statusWhenFull: 7, statusWhenPartial: 6 }
```

A status the roll-up sets, it also lets go of: the first move into `statusWhenFull` /
`statusWhenPartial` remembers the status it displaced in a hidden parent column (`Displaced<Status>`),
and a sum back at zero - the only allocation deleted, amended to 0 or re-parented away - restores
it, so a paid invoice returns to CONFIRMED (or to ISSUED, if it was paid straight from there) rather
than staying PAID with nothing paid. A status the roll-up did not set (a manual void of a partially
paid document) is never touched.

Roll-ups compose transitively across a multi-level composition (leaf edit -> mid total -> top
total); recomputation stops when values stop changing.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,11 @@ private static List<Map<String, Object>> buildRollups(IntentModel model, Map<Str
base.put("statusWhenPartial", withStatus && rollup.getStatusWhenPartial() != null ? rollup.getStatusWhenPartial()
.toString()
: "");
// The parent column remembering the status the roll-up DISPLACED when it first moved the parent
// into whenFull / whenPartial, so a sum that returns to zero can put it back (#7016). Emitted
// on the parent by the EDM generator (EdmIntentGenerator.displacedStatusProperty) under the
// same name.
base.put("statusDisplacedField", withStatus ? IntentNaming.displacedStatusProperty(rollup.getStatus()) : "");
// Recompute the value for the affected parent from the store on each child event.
base.put("criteriaExpression", "Criteria.create().eq(\"" + fkProperty + "\", entity." + fkProperty + ")");
// Handler name derives from the coalescing key (childEntity + parent-fk), NOT the roll-up name:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,4 +277,17 @@ public static String humanize(String name) {
public static String pluralize(String label) {
return NamingHelper.pluralizeLabel(label);
}

/**
* The parent property a capacity roll-up keeps the DISPLACED status in: the status the parent held
* before the roll-up first moved it into {@code statusWhenFull} / {@code statusWhenPartial}, put
* back when the summed children go away again (#7016). Named after the status relation so two
* roll-ups driving different status relations of one parent keep separate memories.
*
* @param statusRelation the roll-up's {@code status:} relation name
* @return the PascalCase property name, e.g. {@code DisplacedStatus}
*/
public static String displacedStatusProperty(String statusRelation) {
return "Displaced" + pascalCase(statusRelation);
}
}
Loading
Loading