Skip to content

fix(intent): re-parenting repairs both sides, from every write path - #6845

Merged
delchev merged 1 commit into
masterfrom
fix/rekey-targeted-writes
Aug 19, 2026
Merged

fix(intent): re-parenting repairs both sides, from every write path#6845
delchev merged 1 commit into
masterfrom
fix/rekey-targeted-writes

Conversation

@delchev

@delchev delchev commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes #6819.

Re-parenting a record left the vacated group's totals permanently stale, from two independent gaps.

1. -rekeyed was unreachable from the targeted writes. The event exists precisely to carry the row whose grouping moved, but it was published only from the full-row update(). A grouping FK moved by a workflow setter, by a resolves: lookup or by a task-form writer goes through updateProperties — which publishes no -updated either, so neither side of that move was ever recomputed. The move a process makes, on records nobody re-opens by hand, was the one that silently skipped the repair.

2. Roll-ups never consumed -rekeyed at all. A roll-up handler recomputes the parent named by the incoming payload, and the create/update/delete events all name the parent the child belongs to now — so the parent it was moved away from went on counting it forever. Only aggregates subscribed to -rekeyed, and aggregateKeys was populated exclusively from model.getAggregates(), never from a roll-up's via FK.

The change

  • groupingKeys (was aggregateKeys) on the entity's .model — now the union of every aggregate key over the entity and every roll-up via FK whose child it is, plus groupingSourcePk. The old name was aggregates-only, which is exactly why a re-parented roll-up child was invisible.
  • The DAO compares those columns on both write paths. update() keeps publishing the previous row (the group it moved into is recomputed off -updated like any other change). updateProperties snapshots the row as JSON before the incoming values are applied and, when a tracked column actually moved, publishes the previous row and the written one — on that path neither side has an event otherwise. Nothing but the generated aggregate / roll-up handlers subscribes to -rekeyed, so a targeted write still re-fires no reaction; that is what lets it signal them without re-publishing -updated.
  • Roll-ups bind it, as a fourth RollupOnRekey handler. It is the same idempotent recompute keyed on the payload's FK, so one class repairs whichever side it is fed.

The publish stays gated on a tracked column having actually changed, so an ordinary edit costs nothing extra and the transitive cascade still terminates at rest.

Tests

  • GlueRollupRekeyTest (new) — every roll-up binds -rekeyed, with the payload-keyed criteria, additional to the create/delete handlers.
  • EdmIntentGeneratorTest.rollupChildCarriesItsParentFkAsAGroupingKey (new) — a roll-up child carries its parent FK; the parent carries nothing.
  • GlueRollupLatestTest, EdmIntentGeneratorTest — updated for the fourth handler and the renamed model keys.
  • IntentEmissionCoverageIT — the targeted path's before/after comparison and its two publishes, plus the roll-up child's tracking and the ClaimLineClaimRollupOnRekey handler. These assertions fail on master.

Run green locally: engine-intent unit tests, the full -P unit-tests build, IntentEmissionCoverageIT, IntentEngineIT, JavaTemplateIT, ModelGenerationIT, formatter:validate.

Overlap with #6837

#6837 (a count roll-up recomputes on child update) touches the same buildRollups tail and the same engine-intent/CLAUDE.md bullet, and its GlueRollupCountTest counts the emitted handlers — whichever merges second needs a small rebase (the count becomes 4). The two are complementary: #6837 fixes the parent a child moved to on the full-row path, this one fixes the parent it moved away from, and both sides on the targeted path.

Docs

Re-parenting a record left the VACATED group's totals permanently stale, from two
independent gaps.

The "-rekeyed" event exists precisely to carry the row whose grouping moved, but it was
published only from the full-row update(). A grouping FK moved by a workflow setter, by a
resolves: lookup or by a task-form writer goes through the targeted primitive
updateProperties, which publishes no "-updated" either - so NEITHER side of that move was
ever recomputed. And roll-ups never consumed "-rekeyed" at all: their handlers recompute the
parent named by the incoming payload, and the create/update/delete events all name the parent
the child belongs to now, so the parent it was moved away from went on counting it forever.

Three parts:

- The entity's .model now carries groupingKeys - the union of every aggregate key over it AND
  every roll-up `via` FK whose child it is (it was aggregateKeys, aggregates-only, which is why
  a re-parented roll-up child was invisible), plus groupingSourcePk.
- The DAO compares those columns on BOTH write paths. update() keeps publishing the previous
  row (the group it moved into is recomputed off "-updated" like any other change);
  updateProperties snapshots the row as JSON before applying the incoming values and, when a
  tracked column actually moved, publishes the previous row AND the written one - on that path
  neither side has an event otherwise. Nothing but the generated aggregate / roll-up handlers
  subscribes to "-rekeyed", so a targeted write still re-fires no reaction.
- Roll-ups bind it, as a fourth RollupOnRekey handler. It is the same recompute keyed on the
  payload's FK, so one class repairs whichever side the payload names.

Closes #6819
@delchev
delchev force-pushed the fix/rekey-targeted-writes branch from 24eef42 to 0c72939 Compare August 19, 2026 13:19
@delchev
delchev merged commit 269f612 into master Aug 19, 2026
10 checks passed
@delchev
delchev deleted the fix/rekey-targeted-writes branch August 19, 2026 17:41
delchev added a commit that referenced this pull request Aug 20, 2026
The merged expectation: a count roll-up now emits four handlers - the
create/update/delete trio this branch adds, plus the RollupOnRekey #6845
merged meanwhile. The stale comment claiming the vacated parent was
'tracked separately' now points at that handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
delchev added a commit that referenced this pull request Aug 20, 2026
…6837)

* fix(intent): a count roll-up recomputes when a child changes parents

A `rollups:` entry with the default `op: count` got a create and a delete
handler but no `-updated` one - the generator assumed a count can only move
when a child is created or destroyed. A child changes parents by an ordinary
EDIT of its parent relation, so re-parenting recomputed neither count: the
parent that received the child never counted it.

Emit the update handler for every op. The recompute is the same query in all
three cases and it reads the child rows back from the store, so it is
idempotent and never op-specific - an edit that touched nothing the roll-up
reads finds the value unchanged and writes nothing. The parent a child moved
AWAY from is still stale until roll-ups also consume the `-rekeyed` event,
which is filed separately; this alone makes the new parent correct.

Covered by a `GlueRollupCountTest` unit test on the emitted descriptors and
by IntentEngineIT, which now asserts the generated
`LoanMemberRollupOnUpdate` binds the child's `-updated` topic and recomputes
exactly like the create handler.

Closes #6820

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(intent): the count roll-up trio composes with the rekey handler

The merged expectation: a count roll-up now emits four handlers - the
create/update/delete trio this branch adds, plus the RollupOnRekey #6845
merged meanwhile. The stale comment claiming the vacated parent was
'tracked separately' now points at that handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
delchev added a commit that referenced this pull request Aug 20, 2026
…h their writes (#6869)

* fix(templates): the rekey pair and the transition announce commit with their writes (#6867)

#6854 established the contract - announce an entity change by handing the
topic to the WRITE, never by publishing beside it - and converted the
generated repository's main paths. Two write+publish pairs survived it,
each a live instance of #6816's failure class (row committed, event lost
for good when the broker is briefly down, downstream reactions silently
never run):

- The DAO's targeted "-rekeyed" pair (#6845): one mutation had to record
  TWO bodies - the row as it stood, naming the group it left, and the row
  as written, naming the one it joined - and the targeted write could
  carry only one topic. The store and JavaRepository gain the
  event-carrying overloads (save and updateProperties with
  additionalEvents, mirroring update's trio), recorded only when the row
  actually existed to be written; the template hands both notices to the
  base write. A model without grouping keys regenerates byte-identically.

- The transition controller (Transition.java.template): updateProperty +
  post-commit re-read + bare publish becomes one targeted write carrying
  the "-transitioned" topic, so the flip and its announcement commit
  together and the event payload is the row exactly as the statement left
  it - never a re-read a concurrent write could have moved on. The reload
  stays only for the HTTP response and the notify block.

Deliberately NOT converted here, with the reasoning on #6867: the five
deferred/ordered announces (SetField, Writer, Numbering, StepEvent via
Process.executeAfterCommit - deferred so consumers observe the whole
synchronous Flowable chain's writes, which write-attachment would regress
- and Generate's completion announce, ordered after the target and its
items across several transactions). Those need a durable-enqueue
primitive, a follow-up of its own. Resolve.java.template follows once
#6841, which rewrites that exact region, lands.

JavaEventOutboxIT gains the retarget phase - one targeted mutation, two
notices, both delivered and both cleared - and IntentEmissionCoverageIT
pins the new contract, including that no bare Producer.sendToTopic
remains in a generated repository or transition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(templates): the resolve routing announce commits with its write (#6867)

With #6841 merged, the lookup's routing status was a targeted
updateProperty followed by a post-commit re-read and a bare publish of
"-transitioned" - the same write-beside-publish pair the rest of this
branch removes. The topic now rides the routing write into the outbox:
the flip and its announcement commit together, the payload is the row
exactly as the statement left it, and a rejected move (the
ValidationException the routing write catches) records no event at all -
a status the record could not take is not a transition.

The emission oracles pin the new contract: IntentEngineIT's two-writes
ordering assertion anchors on the topic-carrying updateProperties, and
IntentEmissionCoverageIT's resolve block asserts the topic on the write
and refuses any bare Producer.sendToTopic in the generated lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Re-parenting a child leaves the vacated parent stale: -rekeyed is unreachable from targeted writes and rollups never consume it

1 participant