You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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".
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:
Same object, same "paid" condition, opposite outcomes.
Root cause
packages/objectql/src/validation/rule-validator.ts → isReadonlyWhenLocked()
(~L296-311) evaluates with a two-key context:
constres=ExpressionEngine.evaluate<boolean>(toExpression(def.readonlyWhen!),{record: merged,
previous,});if(!res.ok){logger?.warn?.(`readonlyWhen for '${name}' failed to evaluate — change allowed through`);returnfalse;// ← 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:
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.
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
os dev --ui --seed-admin in examples/app-showcase
POST /api/v1/auth/sign-in/email as admin@objectos.ai / admin123
Find an invoice with status=paid, take one of its showcase_invoice_line rows
PATCH /api/v1/data/showcase_invoice_line/<id> with {"quantity":9999}
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.
Summary
readonlyWhen: parent.<field> …is enforced only in the client grid. Theserver-side strip cannot evaluate the predicate — there is no
parentbinding inits 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 declarereadonlyWhen: 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, statuspaid:Contrast — the record-scoped lock on the same object family,
showcase_invoice.tax_ratewithreadonlyWhen: P\record.status == 'paid'`` (L144),on the same paid invoice:
Same object, same "paid" condition, opposite outcomes.
Root cause
packages/objectql/src/validation/rule-validator.ts→isReadonlyWhenLocked()(~L296-311) evaluates with a two-key context:
parentis never bound —grep -n parent rule-validator.tsreturns nothing.So every
parent.*predicate lands on the!res.okbranch and returnsfalse("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
locked, so the UI agrees with the author's intent while the database does not.
enforcement point and the client is courtesy. Here it is inverted: only the
courtesy layer enforces.
unit price on a settled invoice.
ordinary seeding of the showcase app, so the condition is routine, not exotic.
Suggested direction
Two separable decisions:
parentfor parent-scoped predicates in the server strip (themaster-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.
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
readonlyWhenis intended to be client-only, then the specand the showcase comment both need to say so plainly — right now
invoice.object.ts:195-201tells the reader the opposite.Repro steps
os dev --ui --seed-admininexamples/app-showcasePOST /api/v1/auth/sign-in/emailasadmin@objectos.ai/admin123status=paid, take one of itsshowcase_invoice_linerowsPATCH /api/v1/data/showcase_invoice_line/<id>with{"quantity":9999}WARN readonlyWhen for 'quantity' failed to evaluate — change allowed throughObserved on
main@ 0e96e46. Found while browser-sweeping showcase + Studio for #4879.