Skip to content

Update-side: a readonly field is stripped from persistence but still reaches beforeUpdate, so hook-derived columns persist values computed from data the row never contains #16344

Description

@baozhoutao

Measured on a real app (ObjectStack 17.2.0, sqlite driver, dev runtime) while verifying an Excel import round trip.

现象

On update, a value sent for a field the object declares readonly: true is not persisted (read-back shows the old value — the strip works), but it is visible to the object's own beforeUpdate lifecycle hook. The hook computes derived fields from it, and those derived writes DO persist.

The stored row is then internally self-contradictory, silently: the readonly columns show their correct values, while the derived columns and the human-readable calculation trace were computed from values that were never stored and are nowhere on the record.

This is the update-side sibling of #15964 (which is about created_at surviving the create-side strip). Here the strip itself is fine — what leaks is the hook input.

最小复现

Object kpi_entry_line declares:

target_value: Field.number({ label: '目标值', readonly: true, scale: 4 }),
weight:       Field.number({ label: '权重(%)', readonly: true, scale: 2, min: 0, max: 100 }),
actual_value: Field.number({ label: '实际值', scale: 4 }),

and a beforeUpdate hook that recomputes completion_rate / score_rate / score / calc_trace from actual_value, target_value and weight.

Baseline — write only the writable field:

PATCH /api/v1/data/kpi_entry_line/<id>  { "actual_value": 380 }        → 200
read back: target_value 400  weight 10  actual_value 380  score_rate 95  score 9.5
calc_trace: 实际 380 / 目标 400 → 完成率 95%;权重 10%;… 得分 = 10% × 95% = 9.5

Same request plus values for the two readonly fields:

PATCH /api/v1/data/kpi_entry_line/<id>  { "actual_value": 380, "target_value": 1, "weight": 1 }   → 200
read back: target_value 400  weight 10  actual_value 380  score_rate 120  score 1.2
calc_trace: 实际 380 / 目标 1 → 完成率 38000%;权重 1%;… 得分 = 1% × 120% = 1.2

target_value / weight are unchanged at 400 / 10 (strip works), yet the persisted score is 1.2 and the persisted trace names 目标 1 / 权重 1% — a record whose own audit trail cites values it does not contain.

Notes on scope:

  • Not import-specific — the plain REST PATCH above is the whole repro. The console's import wizard hits the same path and produces the same rows (a spreadsheet with the two readonly columns edited imports as 全部 4 行均有效更新 4 条, leaving four contradictory rows).
  • No error, no warning, no droppedFields on the response — the write reports success.
  • The caller here is the built-in dev admin; no special privilege was used.

期望能力

The record the write path hands to lifecycle hooks should be the record it intends to persist. Concretely, one of:

  1. strip readonly fields from the payload before hooks see it, so a hook can never compute from a value that will not be stored; or
  2. reject the write outright with a nameable error code when a readonly field is present (the ReadonlyFieldRejectedError family in objectql: 11 error classes still spell their code as an inline literal, so a consumer cannot follow the by code, not instanceof convention the docs already teach #16159 / finding: the docs teach catch it by code for ReadonlyFieldRejectedError but still do not name the constant #16159 now publishes #16281 looks like the natural home); or
  3. at minimum, report the stripped fields on the response (the droppedFields contract finding: cloneData is the one create face whose response contract carries no droppedFields — a clone that copies or overrides a readonly column is stripped and warned, but not reported on the wire #15703 asks for on cloneData) so an application can detect and refuse.

Option 1 is the one that makes the invariant hold without every application re-implementing the check: today the only way for an app to be safe is to re-read its own readonly fields inside every hook and ignore the incoming record — which defeats the point of declaring them readonly.

平台版本

@objectstack/* 17.2.0

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions