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 @@ -445,6 +445,7 @@ Semantics worth knowing:
- **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: agree` = two relations of a JUNCTION row must agree on a shared target (#7409).** The shape no kind reached: `compare` relates two values of ONE row and `requiredWhen`/`forbidWhen` relate a child to its own parent, but nothing compared two DIFFERENT relations' targets - so a EUR `CustomerPayment` of customer B was allocated against a USD `SalesInvoice` of customer A and both writes answered 200. A fleet probe found the same shape in 7 more places across 6 modules (`PurchaseInvoicePayment`, `StockTransfer`, `EmployeeTimesheet`, `EmployeeProjectAssignment`, `Payslip`, `VacationDay`), every one closed by a hand-written `calculatedActionOnCreate`/`OnUpdate` guard class whose entire content was a rule the intent should be able to state. Authored as `{ kind: agree, relations: [<toOneA>, <toOneB>], onProperty: <shared property>, whenNull?: skip | refuse, message }`. **Both sides are ONE PATH each** - `<relation>.<onProperty>` through the same `ResolvePathSupport` walker every other path in the DSL uses, sharing ONE walker so each related record is loaded exactly once and a cross-model target reads through its `uses:` owner like any other hop - and the comparison is emitted into the three controllers' `validate()` as a 400, next to `exactlyOne` and `compare`. **The key is `onProperty`, not `on`:** YAML 1.1 resolves a bare `on` key to the boolean `true`, so an `on:` declaration would arrive as the key `true`, bind to nothing and generate a check with no property to agree on - `rejectCheckOn` refuses that spelling by name on the raw tree (the issue proposed it, so authors and the assistant will write it), the same treatment `lifecycle`'s `on` gets. **Refused at parse**, each because the declaration could not mean anything: fewer or more than two relations, the same relation twice (it always agrees with itself), a relation or an `onProperty` a target does not declare (the walker's own message), a `status:` gate (two relations either agree or they do not, from the first save), an unknown `whenNull`, an `onProperty` whose terminal is not an exact equality - a to-one's foreign key, a string, an integer or a boolean, the same line a `when` condition draws, so a `decimal`/`date` is refused rather than compared by an equality nobody means - and two terminals of DIFFERENT types, where the boxed comparison is silently always false. `whenNull` defaults to `skip`: a row not yet carrying both values has nothing to disagree about, and requiredness is the relation's own declaration. Unit: `IntentParserTest.agreeChecksParseAndValidate`, `EdmIntentGeneratorTest.agreeChecksEmitBothSidesAndTheirLoads`; IT: `IntentEmissionCoverageIT` (both `whenNull` readings, compiled).
- **`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
Original file line number Diff line number Diff line change
Expand Up @@ -2248,6 +2248,46 @@ private static List<Map<String, Object>> buildChecks(EntityIntent entity, List<E
checkMaps.add(checkMap);
continue;
}
if ("agree".equals(check.getKind())) {
// A junction row's two relations must point at the same third thing (#7409). Both sides
// are ONE path each off the record - `<relation>.<onProperty>` - resolved through the
// shared walker, so the two loads are accumulated once per relation and a cross-model
// target reads like any other hop. No gate: two relations either agree or they do not,
// from the first save.
List<String> relations = check.getRelations();
if (relations == null || relations.size() != 2 || check.getOnProperty() == null) {
continue; // the parser already reported it
}
ResolvePathSupport.Walker walker = ResolvePathSupport.walker(entity, byName, compositionParents, crossModel);
ResolvePathSupport.Path left = walker.resolve(relations.get(0) + "." + check.getOnProperty());
ResolvePathSupport.Path right = walker.resolve(relations.get(1) + "." + check.getOnProperty());
if (!left.resolved() || !right.resolved()) {
continue; // the parser already reported it
}
checkMap.put("leftExpression", left.expression());
checkMap.put("rightExpression", right.expression());
checkMap.put("leftLabel", left.label());
checkMap.put("rightLabel", right.label());
checkMap.put("whenNull", check.getWhenNull() == null || check.getWhenNull()
.isBlank() ? "skip"
: check.getWhenNull()
.trim()
.toLowerCase(java.util.Locale.ROOT));
List<Map<String, Object>> pathLoads = pathLoadsOf(walker);
if (!pathLoads.isEmpty()) {
checkMap.put("pathLoads", pathLoads);
}
if (check.getMessage() == null || check.getMessage()
.isBlank()) {
// A check with no authored message still has to say something the person who pressed
// Save can act on, and only the declaration knows what disagreed.
checkMap.put("message",
"The " + IntentNaming.humanize(relations.get(0)) + " and the " + IntentNaming.humanize(relations.get(1))
+ " must have the same " + IntentNaming.humanize(check.getOnProperty()));
}
checkMaps.add(checkMap);
continue;
}
if ("forbidWhen".equals(check.getKind())) {
// The reject-twin of requiredWhen (#7275): the condition compiled to a Java boolean, but
// now a term may read a value ONE HOP away (`SalesInvoice.Status == PAID`) so a child can
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
* enforced on every user write; with a {@link #status} gate it is the repository's, and holds when
* the record is persisted carrying that status - "days &gt; 0 before SUBMITTED" rather than on the
* first draft;</li>
* <li>{@code agree}: the two to-one {@link #relations} of a junction row must AGREE on
* {@link #onProperty} - the property both their targets declare (a payment allocated against an
* invoice of another customer, or in another currency, is the rule a hand-written guard class used
* to carry). Row-level, so it is enforced on every user write; {@link #whenNull} decides what an
* unset side means, and defaults to skipping (the relation's own {@code required} is what makes it
* mandatory);</li>
* <li>{@code requiredWhen}: {@link #field} - the record's own field, or a one-hop
* {@code Relation.field} - must carry a value while {@link #when} holds (an e-mailed invoice needs
* the customer's address). Enforced on every user write, or, with a {@link #status} gate, when the
Expand Down Expand Up @@ -75,6 +81,25 @@ public class CheckIntent {
private Object value;
/** {@code itemsSumEqual}: the two numeric item fields whose sums must be equal. */
private List<String> over;
/**
* {@code agree}: exactly two to-one relations of the entity - the two records the junction row
* links, which must point at the same {@link #onProperty}.
*/
private List<String> relations;
/**
* {@code agree}: the property BOTH targets declare and must agree on - a to-one of theirs (compared
* by its foreign key: the same {@code Customer}, the same {@code Currency}) or a scalar field with
* an exact equality. Spelled {@code onProperty} and not {@code on}, because YAML 1.1 resolves a
* bare {@code on} key to the boolean {@code true} and the declaration would silently bind to
* nothing - the parser refuses that spelling by name rather than dropping it.
*/
private String onProperty;
/**
* {@code agree}: what an unset side means - {@code skip} (the default: a row that does not carry
* both values yet has nothing to disagree about, and requiredness is its own declaration) or
* {@code refuse}.
*/
private String whenNull;
/** {@code itemsMin}: the minimum number of items. */
private Integer count;
/**
Expand Down Expand Up @@ -246,6 +271,30 @@ public void setOver(List<String> over) {
this.over = over;
}

public List<String> getRelations() {
return relations;
}

public void setRelations(List<String> relations) {
this.relations = relations;
}

public String getOnProperty() {
return onProperty;
}

public void setOnProperty(String onProperty) {
this.onProperty = onProperty;
}

public String getWhenNull() {
return whenNull;
}

public void setWhenNull(String whenNull) {
this.whenNull = whenNull;
}

public Integer getCount() {
return count;
}
Expand Down
Loading
Loading