Skip to content

Parent-scoped readonlyWhen is unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889

Description

@xuyushun441-sys

Summary

readonlyWhen: parent.<field> … is enforced only in the client grid. The
server-side strip cannot evaluate the predicate — there is no parent binding in
its evaluation context — and the failure path is fail-open, so the write goes
through. The lock ADR-0036 / #1581 documents as a server guarantee does not exist
for the parent-scoped form.

The record-scoped form works correctly, which is what makes this easy to miss.

Reproduced end-to-end

showcase_invoice_line.{product,quantity,unit_price} all declare
readonlyWhen: P\parent.status == 'paid'` (examples/app-showcase/src/data/objects/invoice.object.ts:201,219,225`),
described there as "once the header invoice is Paid, its lines are frozen".

Against INV-1003, status paid:

BEFORE: quantity=6  unit_price=49.99

PATCH /api/v1/data/showcase_invoice_line/t7GzyZFFi-3Tiq7R
  {"quantity":9999,"unit_price":0.01}
→ HTTP 200

AFTER (re-read): quantity=9999  unit_price=0.01     ← persisted

Contrast — the record-scoped lock on the same object family,
showcase_invoice.tax_rate with readonlyWhen: P\record.status == 'paid'`` (L144),
on the same paid invoice:

BEFORE: tax_rate=8  status=paid
PATCH {"tax_rate":99} → HTTP 200
AFTER:  tax_rate=8                                  ← correctly stripped

Same object, same "paid" condition, opposite outcomes.

Root cause

packages/objectql/src/validation/rule-validator.tsisReadonlyWhenLocked()
(~L296-311) evaluates with a two-key context:

const res = ExpressionEngine.evaluate<boolean>(toExpression(def.readonlyWhen!), {
  record: merged,
  previous,
});
if (!res.ok) {
  logger?.warn?.(`readonlyWhen for '${name}' failed to evaluate — change allowed through`);
  return false;          // ← fail-open
}

parent is never bound — grep -n parent rule-validator.ts returns nothing.
So every parent.* predicate lands on the !res.ok branch and returns false
("not locked").

The docblock states the fail-open is deliberate ("A broken predicate is
fail-open … matching the strip's historical behaviour"), which is defensible for
a malformed expression. But here the expression is well-formed and
spec-sanctioned — the evaluator simply doesn't support its scope, so a supported
authoring construct is silently reclassified as "broken" and waved through.

Severity

  • Silent. The write returns 200 and the client grid still renders the cell
    locked, so the UI agrees with the author's intent while the database does not.
  • Server is supposed to be the authority. Per ADR-0057 D10 the server is the
    enforcement point and the client is courtesy. Here it is inverted: only the
    courtesy layer enforces.
  • Financial fields. In the reference app the affected fields are quantity and
    unit price on a settled invoice.
  • It is audible but unread: the server logged this warning 15 times during
    ordinary seeding of the showcase app, so the condition is routine, not exotic.

Suggested direction

Two separable decisions:

  1. Bind parent for parent-scoped predicates in the server strip (the
    master-detail header is resolvable from the child's lookup on the update path
    the strip already runs on), so the construct is enforced where it is
    documented to be enforced.
  2. Reconsider fail-open for the "unsupported scope" case. An expression the
    evaluator cannot bind is different from one that threw. At minimum it should
    not be silently permissive on a field the author declared locked — and a
    predicate referencing a scope the runtime does not support should ideally fail
    the build (Prime Directive Add comprehensive test suite for Zod schema validation #12: declared, not guessed) rather than warn at
    runtime forever.

If parent-scoped readonlyWhen is intended to be client-only, then the spec
and the showcase comment both need to say so plainly — right now
invoice.object.ts:195-201 tells the reader the opposite.

Repro steps

  1. os dev --ui --seed-admin in examples/app-showcase
  2. POST /api/v1/auth/sign-in/email as admin@objectos.ai / admin123
  3. Find an invoice with status=paid, take one of its showcase_invoice_line rows
  4. PATCH /api/v1/data/showcase_invoice_line/<id> with {"quantity":9999}
  5. Re-read → the value persisted; server log carries
    WARN readonlyWhen for 'quantity' failed to evaluate — change allowed through

Observed on main @ 0e96e46. Found while browser-sweeping showcase + Studio for #4879.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions