Skip to content

Commit 90ed65f

Browse files
committed
test(objectql): pin the case that defines a script rule as an invariant
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
1 parent 84b8190 commit 90ed65f

1 file changed

Lines changed: 50 additions & 0 deletions

File tree

packages/objectql/src/validation/rule-validator.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,6 +1850,56 @@ describe('script / cross_field predicates', () => {
18501850
evaluateValidationRules(schema, { a: 1 }, 'update', { previous: { a: 0 } }),
18511851
).toThrow(/rule 'broken' could not be evaluated/);
18521852
});
1853+
1854+
// ── #14891 — the case that DEFINES a `script`/`cross_field` rule as an
1855+
// INVARIANT rather than a transition gate. `checkPredicate` (above) is handed
1856+
// `ctx.merged` on EVERY write, with no exemption for a violation that was
1857+
// already stored, so a row that already violates is refused on *any* edit
1858+
// until a repairing write lands: frozen, not bricked.
1859+
//
1860+
// That is the exact opposite of `Field.requiredWhen`, whose ADR-0113
1861+
// exemption is pinned at the top of this file — "legacy rows rest: a
1862+
// pre-existing violation does not block an unrelated write". Until these two
1863+
// cases landed, the boundary had asymmetric coverage: the gate's exemption
1864+
// was pinned, the invariant's *absence* of one was not — so a future edit
1865+
// that quietly gave `script` rules the same exemption would have gone
1866+
// unnoticed by this suite.
1867+
const discountSchema = {
1868+
validations: [
1869+
{
1870+
type: 'script' as const,
1871+
name: 'discount_cap',
1872+
condition: { dialect: 'cel', source: 'record.discount > 60' },
1873+
message: 'Discount may not exceed 60%.',
1874+
},
1875+
],
1876+
};
1877+
1878+
it('a stored violation FREEZES the row — an unrelated-field write is refused too (#14891)', () => {
1879+
// pre: discount 90 → ALREADY violates. The write touches only `note`, so it
1880+
// CREATES nothing; it is refused all the same, because the merged record
1881+
// still violates. (Asserting the envelope, not just `toThrow` — a bare
1882+
// throw here would also be satisfied by an unrelated fault.)
1883+
let caught: unknown;
1884+
try {
1885+
evaluateValidationRules(discountSchema, { note: 'x' }, 'update', { previous: { discount: 90 } });
1886+
} catch (err) {
1887+
caught = err;
1888+
}
1889+
expect(caught).toBeInstanceOf(ValidationError);
1890+
expect((caught as ValidationError).code).toBe('VALIDATION_FAILED');
1891+
expect((caught as ValidationError).fields).toEqual([
1892+
{ field: '_record', code: 'rule_violation', message: 'Discount may not exceed 60%.' },
1893+
]);
1894+
});
1895+
1896+
it('…and the REPAIRING write passes — frozen, not bricked (#14891)', () => {
1897+
// Same violating prior row; this write brings the merged record back under
1898+
// the cap, so the invariant is satisfied and the row is editable again.
1899+
expect(() =>
1900+
evaluateValidationRules(discountSchema, { discount: 50 }, 'update', { previous: { discount: 90 } }),
1901+
).not.toThrow();
1902+
});
18531903
});
18541904

18551905
describe('introspection', () => {

0 commit comments

Comments
 (0)