Skip to content

validations[] script rules: the case that DEFINES them as invariants (a stored violation refused on an unrelated edit) has no pin #14891

Description

@claude

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

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions