Observation, filed unassigned. Found while writing the spec docblocks for #13879 (transition gate vs invariant). Not a behaviour defect — the engine does the right thing; the pin for the case that defines the behaviour is missing.
What the engine does
checkPredicate (packages/objectql/src/validation/rule-validator.ts) evaluates a script (and cross_field) condition against ctx.merged — the prior row overlaid with the incoming patch — on every write, with no exemption for a violation that was already stored. So a row that already violates is refused on any edit until a repairing write lands: frozen, not bricked. That is exactly what makes it an invariant rather than a transition gate, and as of #13879 it is stated as contract text in packages/spec/src/data/validation.zod.ts (the script rule's condition describe string and the module docblock, both of which ship as the JSON Schema description and the generated reference page).
The gap
packages/objectql/src/validation/rule-validator.test.ts, block script / cross_field predicates, pins:
- a write that CARRIES the offending value ("rejects when the failure predicate is true (end before start)"),
- a write that makes the predicate false ("allows when the predicate is false (merged record honours unchanged fields)"),
- an unevaluable predicate (fail-closed).
Nothing pins the write that touches an unrelated field while the stored row violates. That case is the whole difference from Field.requiredWhen, whose corresponding case IS pinned on the other side of the boundary ("legacy rows rest: a pre-existing violation does not block an unrelated write"). So the two mechanisms' distinguishing behaviours have asymmetric coverage: the gate's exemption is pinned, the invariant's absence of one is not.
Measured
2026-09-03 at 2263ca4d6, scratch vitest over evaluateValidationRules with a rule condition: record.discount is greater than 60:
| write | previous row | result |
|---|
| touch an unrelated field only | discount 90 | ValidationError (frozen) |
| repairing write, discount 50 | discount 90 | passes (not bricked) |
Both behave as the new spec text now claims. The scratch file was deleted rather than landed, to keep that PR inside packages/spec.
Suggested fix
Two it cases in the existing script / cross_field predicates block, one per row above. Note PR #14801 is open on rule-validator.ts itself; the test file is a separate path, but whoever picks this up should check that PR's state first.
Generated by Claude Code
Generated by Claude Code
Observation, filed unassigned. Found while writing the spec docblocks for #13879 (transition gate vs invariant). Not a behaviour defect — the engine does the right thing; the pin for the case that defines the behaviour is missing.
What the engine does
checkPredicate(packages/objectql/src/validation/rule-validator.ts) evaluates ascript(andcross_field) condition againstctx.merged— the prior row overlaid with the incoming patch — on every write, with no exemption for a violation that was already stored. So a row that already violates is refused on any edit until a repairing write lands: frozen, not bricked. That is exactly what makes it an invariant rather than a transition gate, and as of #13879 it is stated as contract text inpackages/spec/src/data/validation.zod.ts(thescriptrule'sconditiondescribe string and the module docblock, both of which ship as the JSON Schema description and the generated reference page).The gap
packages/objectql/src/validation/rule-validator.test.ts, blockscript / cross_field predicates, pins:Nothing pins the write that touches an unrelated field while the stored row violates. That case is the whole difference from
Field.requiredWhen, whose corresponding case IS pinned on the other side of the boundary ("legacy rows rest: a pre-existing violation does not block an unrelated write"). So the two mechanisms' distinguishing behaviours have asymmetric coverage: the gate's exemption is pinned, the invariant's absence of one is not.Measured
2026-09-03 at
2263ca4d6, scratch vitest overevaluateValidationRuleswith a rulecondition: record.discount is greater than 60:Both behave as the new spec text now claims. The scratch file was deleted rather than landed, to keep that PR inside
packages/spec.Suggested fix
Two
itcases in the existingscript / cross_field predicatesblock, one per row above. Note PR #14801 is open onrule-validator.tsitself; the test file is a separate path, but whoever picks this up should check that PR's state first.Generated by Claude Code
Generated by Claude Code