Skip to content

Commit 7bce521

Browse files
committed
fix(objectql): a faulting option visibleWhen refuses the write instead of admitting it
ADR-0137 D2 reaches the server option gate on the write path: an option predicate that cannot be evaluated refuses the write through the field-rule unevaluable envelope (constraint.rule 'visibleWhen', the option as value), naming the option, the field and the fault. The no-acting-user arm (a system write whose predicate reads the acting user) stays admitted and loud. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3054516 commit 7bce521

4 files changed

Lines changed: 549 additions & 105 deletions

File tree

‎packages/objectql/src/engine-field-predicate-fault.test.ts‎

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,16 @@
1414
//
1515
// Driven end-to-end through the real engine and a real (in-memory) driver, so
1616
// "nothing is persisted" is read off the store rather than inferred from a
17-
// throw (PD #10: check the CALL SITE, bulk path included). The two CONTROL
18-
// blocks pin what must NOT move: a predicate that evaluates behaves exactly as
19-
// before in both directions, and option `visibleWhen` — which D2 does not
20-
// reach — stays fail-open.
17+
// throw (PD #10: check the CALL SITE, bulk path included). The CONTROL block
18+
// pins what must NOT move: a predicate that evaluates behaves exactly as
19+
// before in both directions.
20+
//
21+
// Block (d) is the option gate. #22402 (ruling A) settled that D2 reaches it on
22+
// the WRITE path — the gate is the server's enforcement of who may pick the
23+
// option (ADR-0124 D1) — so a faulting option `visibleWhen` refuses the write
24+
// through the same envelope, `constraint.rule: 'visibleWhen'`, with the option
25+
// as `value`. Only a system write with no acting user whose predicate reads
26+
// one stays admitted.
2127

2228
import { describe, it, expect, beforeEach } from 'vitest';
2329
import { validationFailureDetails } from '@objectstack/types';
@@ -195,9 +201,18 @@ describe('ADR-0137 D2 — a faulting field-rule predicate refuses the submit (se
195201
engine.registry.registerObject({
196202
name: 'fp_option',
197203
fields: {
204+
status: { type: 'text' },
198205
tier: {
199206
type: 'select',
200-
options: [{ value: 'basic' }, { value: 'gold', visibleWhen: "record.statsu == 'vip'" }],
207+
options: [
208+
{ value: 'basic' },
209+
// The typo, on an option gate.
210+
{ value: 'gold', visibleWhen: "record.statsu == 'vip'" },
211+
// Evaluates: the control both ways.
212+
{ value: 'silver', visibleWhen: "record.status == 'vip'" },
213+
// Reads the acting user: a system write has nobody to ask about.
214+
{ value: 'staff', visibleWhen: "'org_admin' in current_user.positions" },
215+
],
201216
},
202217
},
203218
} as any, 'test-package');
@@ -320,12 +335,75 @@ describe('ADR-0137 D2 — a faulting field-rule predicate refuses the submit (se
320335
});
321336
});
322337

323-
// ── (d) CONTROL: option `visibleWhen` is outside D2 ───────────────────────
338+
// ── (d) option `visibleWhen`: D2 reaches the gate on the write path ───────
339+
340+
describe('(d) an option `visibleWhen` whose predicate faults (#22402)', () => {
341+
const ACTING = { context: { userId: 'u1', positions: ['org_member'] } } as any;
342+
343+
/** The option-gate refusal, asserted as a whole: the D2 envelope, naming the option, the field and the fault. */
344+
function expectOptionGateRefusal(err: any, field: string, option: string) {
345+
expect(err.name).toBe('ValidationError');
346+
expect(err.code).toBe('VALIDATION_FAILED');
347+
expect(validationFailureDetails(err)).toMatchObject({ code: 'VALIDATION_FAILED' });
348+
const entry = (err.fields as any[]).find((f) => f.field === field);
349+
expect(entry).toMatchObject({
350+
field,
351+
code: 'rule_violation',
352+
value: option,
353+
constraint: expect.objectContaining({ rule: 'visibleWhen', reason: 'unevaluable', missingKey: 'statsu' }),
354+
});
355+
expect(entry.message.startsWith(`Option '${option}' of field '${field}' visibleWhen could not be evaluated (runtime: No such key: statsu)`))
356+
.toBe(true);
357+
return entry;
358+
}
359+
360+
it('refuses an authenticated INSERT — and nothing is stored', async () => {
361+
const err = await refusalOf(() => engine.insert('fp_option', { tier: 'gold' }, ACTING));
362+
expectOptionGateRefusal(err, 'tier', 'gold');
363+
expect(rows('fp_option')).toHaveLength(0);
364+
});
365+
366+
it('refuses a system INSERT too: the predicate names no user, so the gate is broken, not unasked', async () => {
367+
const err = await refusalOf(() => engine.insert('fp_option', { tier: 'gold' }));
368+
expectOptionGateRefusal(err, 'tier', 'gold');
369+
expect(rows('fp_option')).toHaveLength(0);
370+
});
371+
372+
it('refuses a by-id UPDATE — and the stored row is untouched', async () => {
373+
storeFor('fp_option').set('o1', { id: 'o1', status: 'open', tier: 'basic' });
374+
const err = await refusalOf(() => engine.update('fp_option', { id: 'o1', tier: 'gold' }, ACTING));
375+
expectOptionGateRefusal(err, 'tier', 'gold');
376+
expect(storeFor('fp_option').get('o1')).toEqual({ id: 'o1', status: 'open', tier: 'basic' });
377+
});
378+
379+
it('refuses on the BULK path, before any matched row is written', async () => {
380+
storeFor('fp_option').set('b1', { id: 'b1', status: 'open', tier: 'basic' });
381+
storeFor('fp_option').set('b2', { id: 'b2', status: 'open', tier: 'basic' });
382+
const err = await refusalOf(() =>
383+
engine.update('fp_option', { tier: 'gold' }, { where: { status: 'open' }, multi: true, ...ACTING } as any));
384+
expectOptionGateRefusal(err, 'tier', 'gold');
385+
expect(rows('fp_option').map((r) => r.tier)).toEqual(['basic', 'basic']);
386+
});
387+
388+
it('validate() previews the same refusal', async () => {
389+
const preview = await engine.validate('fp_option', { tier: 'gold' }, { mode: 'insert', ...ACTING } as any);
390+
expect(preview.results?.[0]?.valid).toBe(false);
391+
expect(preview.results?.[0]?.errors).toEqual([
392+
expect.objectContaining({ field: 'tier', code: 'rule_violation', value: 'gold' }),
393+
]);
394+
});
395+
396+
it('CONTROL — an evaluable gate is judged exactly as before: FALSE refuses `invalid_option`, TRUE writes', async () => {
397+
const err = await refusalOf(() => engine.insert('fp_option', { status: 'open', tier: 'silver' }, ACTING));
398+
expect(err.fields).toEqual([expect.objectContaining({ field: 'tier', code: 'invalid_option' })]);
399+
const row = await engine.insert('fp_option', { status: 'vip', tier: 'silver' }, ACTING);
400+
expect(row).toMatchObject({ tier: 'silver' });
401+
expect(rows('fp_option')).toHaveLength(1);
402+
});
324403

325-
describe('(d) CONTROL — option `visibleWhen` is not a field-rule predicate, and stays fail-open', () => {
326-
it('a faulting option predicate still lets the choice through', async () => {
327-
const row = await engine.insert('fp_option', { tier: 'gold' });
328-
expect(row).toMatchObject({ tier: 'gold' });
404+
it('CONTROL — a system write of a user-gated option stays ADMITTED (`no-acting-user`)', async () => {
405+
const row = await engine.insert('fp_option', { tier: 'staff' });
406+
expect(row).toMatchObject({ tier: 'staff' });
329407
expect(rows('fp_option')).toHaveLength(1);
330408
});
331409
});

‎packages/objectql/src/engine-option-permission-predicate.test.ts‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
* kept across writes (a revoked grant is seen on the very next write);
1616
* - a write whose gates never call `can` never asks — so it cannot be refused
1717
* by a resolution it did not depend on (the ruling's control);
18-
* - no resolver ⇒ NO permission data: the gate stays loudly unevaluable and
19-
* the value is admitted with the warn naming the missing input — ⛔ not a
20-
* denial;
18+
* - no resolver ⇒ NO permission data: the gate cannot be evaluated, so the
19+
* write is REFUSED through the unevaluable envelope naming the missing
20+
* input (ADR-0137 D2, which #22402 ruled reaches the option gate on the
21+
* write path) — ⛔ never `invalid_option`, which would read as a measured
22+
* denial, and ⛔ never an admission;
2123
* - a resolver that THROWS fails the write CLOSED with its own error, and a
2224
* map that is not the published shape is refused the same way.
2325
*/
@@ -205,14 +207,27 @@ describe('#18783 — the engine answers `can` in option visibleWhen from the sec
205207
expect(r.asks).toHaveLength(0);
206208
});
207209

208-
it('NO resolver ⇒ no permission data: loudly unevaluable and ADMITTED, never a silent denial', async () => {
209-
await expect(
210-
engine.insert('crm_case', { subject: 's', stage: 'escalated' }, { context: ACTING } as any),
211-
).resolves.toBeTruthy();
210+
it('NO resolver ⇒ no permission data: REFUSED as unevaluable naming the missing input — never a silent denial, never an admission', async () => {
211+
const err = await refusal(engine.insert('crm_case', { subject: 's', stage: 'escalated' }, { context: ACTING } as any));
212+
expect(err).toBeInstanceOf(ValidationError);
213+
expect(err.code).toBe('VALIDATION_FAILED');
214+
expect(err.fields).toHaveLength(1);
215+
expect(err.fields[0]).toMatchObject({
216+
field: 'stage',
217+
code: 'rule_violation',
218+
value: 'escalated',
219+
constraint: { rule: 'visibleWhen', reason: 'unevaluable' },
220+
});
221+
expect(err.fields[0].constraint.fault).toContain('carries no permission data');
222+
// Nothing is persisted.
223+
expect(created()).toHaveLength(0);
212224
const gate = log.warns.filter((w) => w.meta?.field === 'stage');
213225
expect(gate).toHaveLength(1);
214226
expect(gate[0].meta).toMatchObject({ value: 'escalated', reason: 'predicate-fault' });
215227
expect(gate[0].meta.error.message).toContain('carries no permission data');
228+
// Control on the same engine: an ungated pick still writes.
229+
await expect(engine.insert('crm_case', { subject: 's', stage: 'open' }, { context: ACTING } as any)).resolves.toBeTruthy();
230+
expect(created()).toHaveLength(1);
216231
});
217232

218233
it('a resolver that THROWS fails the write CLOSED with its own error, untouched', async () => {

0 commit comments

Comments
 (0)