A user update must not take system-owned fields from the payload - #6688
Merged
Conversation
A to-one relation could not express a DERIVED default. `init:` covers the fixed case (a literal target seed id), but nothing covered "read it off another record" - a sales document's currency defaulting from its company's base currency. The adjacent hooks all fall short by design: `dependsOn` is a UI-only cascade that never fires on a server-side create, and the `setRelationField` process step takes a literal id too. Authoring `calculatedActionOnCreate` on a relation looked like it should work and silently did nothing: the typed mapping is Gson, which ignores unknown properties, so the key was dropped at parse time with no error - the #6541 family, one level up from seed rows. RelationIntent gains calculatedActionOnCreate/OnUpdate + isCalculated(), and the new shared EdmIntentGenerator.putCalculatedAction emits isCalculatedProperty plus the action names from BOTH relation builders (same-model relationProperty and crossModelRelationProperty). The DAO template needed NO change, which is the point worth recording: a relation is already an ordinary property in the .model - the FK column, typed to the target's key - so the template's shared property loop and its actionAssign macro emit `entity.<Relation> = Beans.get(<class>.class).calculate(entity);` against the Integer FK with no new branch. The gap was engine-only, so consuming it needs a jar swap and no registry re-seed. The parser rejects the combination where there is no single FK to assign, or where the value is not the author's to set: a collection relation, a composition parent (preset by the layout) and an EntityStatus badge (owned by the workflow transitions - `init:` is its starting-value hook). Tests: EdmIntentGeneratorTest asserts the three emitted keys on both builders and that an action-less relation stays unmarked; IntentParserTest asserts the key binds onto the model (the typed-mapping test is the regression guard for the silent drop) and that a collection relation is rejected. Full engine-intent suite 406 green. NOT done, owed: an IntentEmissionCoverageIT case. That fixture has no calculated action of either kind today, and adding one needs a compilable custom/ class in the emission-test project - a generated repository referencing a missing class fails the whole client-Java batch and with it every REST assertion in that gate. Left for a change that can run the gate rather than guessed at.
…the payload The generated update() persisted the whole incoming row, so a partial payload - the UI header form PUTs only the fields it edits - erased every column the write path owns. Reproduced on production (invoice 0000000181, 2026-08-12): a draft header edit nulled Paid and wrote Balance 0, and the customer-facing PDF printed "amount due: 0" on an 8,556 invoice. Two defects compounded: - nothing preserved the system-owned columns, so the roll-up target Paid (written only by the allocation listeners) took the payload's null; - the expressionUpdate recompute (Balance = Total - Paid) ran BEFORE recalculate(), reading the payload's nulls as zeros - Balance persisted 0 while recalculate() then repaired Net/Vat/Total from the reloaded items, which is exactly why the corruption looked impossible: correct totals next to a nulled Paid and a zero Balance. update() now reloads the stored row once and takes the system-owned values from THERE, never from the payload: isReadOnlyProperty fields (author readOnly, roll-up targets, the document number and uuid - which also covers ProcessId and the audit columns) and aggregate footer fields with no update-time recompute (Net/Vat/Discount/Total/Paid). The expressionUpdate / actionUpdate assigns move AFTER recalculate(), so Balance = Total - Paid reads the resummed Total and the preserved Paid. Deliberately NOT preserved: a calculated create-only field that stays user-editable (a Currency relation defaulted from the company) carries neither flag, so a user's edit of it is respected. Safe against workflow writes by construction: every system writer (number stamp, status transitions, roll-ups) uses the targeted updateProperty / updateProperties / updateDerived primitives, which bypass update() entirely - the #6226/#6306 lost-update family, user-update edition. Verified on a live instance (registry wipe + template reseed + regen of sales-invoices and timesheets): emitted update() preserves Number/Net/Vat/Discount/Total/Paid/Uuid/ProcessId/audit and recomputes Balance after recalculate; runtime reproduction (create invoice + item, PUT a header-only payload) keeps Paid 0.00 / Balance 120.00 / Number intact where the previous build persisted Paid null / Balance 0. Client-Java batch 256 units, 261 class files. audit-dsl-emission.py: 729 OK, 1 pre-existing unrelated FAIL (timesheets personal-group, regen owed).
Base automatically changed from
feat/intent-calculated-action-on-relation
to
master
August 13, 2026 05:48
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The generated
update()persisted the whole incoming row, so a partial payload — the UI header form PUTs only the fields it edits — erased every column the write path owns. Reproduced on a production tenant (2026-08-12): a draft invoice header edit nulledPaidand wroteBalance0, and the customer-facing PDF printed "amount due: 0" on an 8,556 invoice. Two defects compounded:Paid(written only by the allocation listeners) took the payload's null;expressionUpdaterecompute (Balance = Total - Paid) ran BEFORErecalculate(), reading the payload's nulls as zeros —Balancepersisted 0 whilerecalculate()then repaired Net/Vat/Total from the reloaded items, which is exactly why the corruption looked impossible: correct totals next to a nulled Paid and a zero Balance.Changes (
template-application-dao-java,Repository.java.template)update()reloads the stored row once and takes the system-owned values from THERE, never from the payload:isReadOnlyPropertyfields (author readOnly, roll-up targets, the document number and uuid — which also covers ProcessId and the audit columns) andaggregatefooter fields with no update-time recompute (Net/Vat/Discount/Total/Paid).expressionUpdate/actionUpdateassigns move AFTERrecalculate(), soBalance = Total - Paidreads the resummed Total and the preserved Paid.updateProperty/updateProperties/updateDerivedprimitives, which bypassupdate()entirely — the fix(intent): trigger ProcessId write-back via targeted single-column update — kill the stale-snapshot race #6226/fix(intent): setter/writer delegates use targeted updateProperty writes #6306 lost-update family, user-update edition.Verified on a live instance (registry wipe + template reseed + regen of sales-invoices and timesheets): the emitted
update()preserves Number/Net/Vat/Discount/Total/Paid/Uuid/ProcessId/audit and recomputes Balance afterrecalculate(); the runtime reproduction (create invoice + item, PUT a header-only payload) keeps Paid 0.00 / Balance 120.00 / Number intact where the previous build persisted Paid null / Balance 0. Client-Java batch: 256 units, 261 class files.🤖 Generated with Claude Code