Skip to content

A user update must not take system-owned fields from the payload - #6688

Merged
delchev merged 2 commits into
masterfrom
fix/dao-update-preserves-system-fields
Aug 13, 2026
Merged

A user update must not take system-owned fields from the payload#6688
delchev merged 2 commits into
masterfrom
fix/dao-update-preserves-system-fields

Conversation

@delchev

@delchev delchev commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Stacked on #6687 (only for a conflict-free diff; independent otherwise — retarget to master once that merges).

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 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.

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: 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 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 after recalculate(); 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

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
@delchev
delchev merged commit 2a7de75 into master Aug 13, 2026
5 of 7 checks passed
@delchev
delchev deleted the fix/dao-update-preserves-system-fields branch August 13, 2026 05:48
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.

1 participant