From 90ed65f08bb01de15000bcbdf896121f297dae98 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 09:28:37 +0000 Subject: [PATCH] test(objectql): pin the case that defines a `script` rule as an invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkPredicate` evaluates a `script` / `cross_field` condition against `ctx.merged` on every write, with no exemption for a violation that was already stored. A row that already violates is therefore refused on any edit until a repairing write lands — frozen, not bricked — and that is exactly what separates a `validations[]` invariant from a `Field.requiredWhen` transition gate, whose ADR-0113 exemption is pinned at `rule-validator.test.ts:65` ("legacy rows rest: a pre-existing violation does not block an unrelated write"). The `script / cross_field predicates` block pinned only the write that carries the offending value, the write that makes the predicate false, and the un-evaluable predicate. The distinguishing case — an unrelated-field write against an already-violating row — had no pin, so the two mechanisms' boundary had asymmetric coverage. Two `it` cases, no behaviour change: - unrelated-field write over a violating prior row is refused, asserted on the envelope (`VALIDATION_FAILED`, `_record` / `rule_violation`, authored message) rather than a bare `toThrow`; - the repairing write passes — frozen, not bricked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- .../src/validation/rule-validator.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/objectql/src/validation/rule-validator.test.ts b/packages/objectql/src/validation/rule-validator.test.ts index 57ba55257b..60a4050a50 100644 --- a/packages/objectql/src/validation/rule-validator.test.ts +++ b/packages/objectql/src/validation/rule-validator.test.ts @@ -1850,6 +1850,56 @@ describe('script / cross_field predicates', () => { evaluateValidationRules(schema, { a: 1 }, 'update', { previous: { a: 0 } }), ).toThrow(/rule 'broken' could not be evaluated/); }); + + // ── #14891 — the case that DEFINES a `script`/`cross_field` rule as an + // INVARIANT rather than a transition gate. `checkPredicate` (above) is handed + // `ctx.merged` 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 the exact opposite of `Field.requiredWhen`, whose ADR-0113 + // exemption is pinned at the top of this file — "legacy rows rest: a + // pre-existing violation does not block an unrelated write". Until these two + // cases landed, the boundary had asymmetric coverage: the gate's exemption + // was pinned, the invariant's *absence* of one was not — so a future edit + // that quietly gave `script` rules the same exemption would have gone + // unnoticed by this suite. + const discountSchema = { + validations: [ + { + type: 'script' as const, + name: 'discount_cap', + condition: { dialect: 'cel', source: 'record.discount > 60' }, + message: 'Discount may not exceed 60%.', + }, + ], + }; + + it('a stored violation FREEZES the row — an unrelated-field write is refused too (#14891)', () => { + // pre: discount 90 → ALREADY violates. The write touches only `note`, so it + // CREATES nothing; it is refused all the same, because the merged record + // still violates. (Asserting the envelope, not just `toThrow` — a bare + // throw here would also be satisfied by an unrelated fault.) + let caught: unknown; + try { + evaluateValidationRules(discountSchema, { note: 'x' }, 'update', { previous: { discount: 90 } }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ValidationError); + expect((caught as ValidationError).code).toBe('VALIDATION_FAILED'); + expect((caught as ValidationError).fields).toEqual([ + { field: '_record', code: 'rule_violation', message: 'Discount may not exceed 60%.' }, + ]); + }); + + it('…and the REPAIRING write passes — frozen, not bricked (#14891)', () => { + // Same violating prior row; this write brings the merged record back under + // the cap, so the invariant is satisfied and the row is editable again. + expect(() => + evaluateValidationRules(discountSchema, { discount: 50 }, 'update', { previous: { discount: 90 } }), + ).not.toThrow(); + }); }); describe('introspection', () => {