Skip to content

Commit 159e05e

Browse files
os-muskclaude
andauthored
fix(objectql): name which case the per-option visibleWhen fail-open log took (#14485)
A system write can never bind `current_user`, so every gated option a seed carries took the fail-open branch and logged `failed to evaluate — allowed through` — 27 identical lines on one ordinary boot, on the correct path. An authenticated caller whose predicate genuinely faults produced the identical line, and that one is a gate that is not being enforced. The admission is unchanged. The branch now says which of the two cases it took, in the message and in structured `meta.reason`, and both stay at `warn` (the published sink declares no other level, and the authenticated fault must not get quieter). The discriminator needs both facts — no acting user AND a predicate that references one — and reads the second off the parsed CEL AST rather than off the fault text, which was measured not to be a reliable key. Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a40c0f9 commit 159e05e

3 files changed

Lines changed: 333 additions & 3 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): the per-option `visibleWhen` fail-open log now names which of its two cases it took (#14416)
6+
7+
`evaluateOptionVisibility` admits an option whose `visibleWhen` predicate cannot
8+
be evaluated, and logs it. **That admission is unchanged and deliberate** — a
9+
seed that could not write a gated value would have to walk every row through the
10+
state machine, and a job with no acting user could not write at all. Only the
11+
log changes.
12+
13+
One sentence was describing two different facts. A system write (a declarative
14+
seed, an in-process job — anything with no acting user) can never bind
15+
`current_user`, so every gated option it carries took the fail-open branch and
16+
logged `option visibleWhen for '<f>=<v>' failed to evaluate — allowed through`:
17+
measured at 27 identical lines on one ordinary boot of a seeded app, one per
18+
seeded row, on the correct path. An authenticated caller whose predicate
19+
genuinely faults — a typo'd field, a missing root — produced the **identical**
20+
line, and that one is a gate that is not being enforced. A warning that fires
21+
this often on the expected path stops being read, and takes the real case with
22+
it.
23+
24+
The branch now states which case it is:
25+
26+
- **No acting user, and the predicate asks for one** — reworded to
27+
`option visibleWhen for '<f>=<v>' not evaluated: no acting user to bind
28+
current_user (system write) — allowed through`. It deliberately no longer
29+
contains the phrase `failed to evaluate`, so an operator grepping a boot log
30+
for that phrase stops matching the expected path.
31+
- **Everything else** — kept loud and now says the gate was not enforced and
32+
needs checking, with the caller named (`authenticated caller` / `system
33+
write`).
34+
35+
**Log level is unchanged: both branches stay at `warn`.** The authenticated
36+
fault must not get quieter, and the published sink type
37+
(`EvaluateRulesOptions['logger']`) keeps its shape — it declares `warn` only,
38+
and this change does not widen it. Demoting the no-acting-user case to `debug`
39+
would require adding a `debug` member to that published type and is
40+
deliberately **not** done here.
41+
42+
Both calls now also pass structured `meta` through the sink's already-declared
43+
`(msg, meta?)` second parameter — **additive, no type change**:
44+
45+
- `field` — the field name
46+
- `value` — the picked option value, stringified
47+
- `reason``'no-acting-user'` or `'predicate-fault'`, the machine-readable
48+
form of the distinction above
49+
- `error` — the engine's `EvalError` (`{ kind, message }`), so the underlying
50+
fault stays recoverable from the line even when the line is the quiet one
51+
52+
The discriminator needs **both** facts — no acting user **and** a predicate that
53+
references the acting user — and reads the second off the parsed CEL AST
54+
(`collectCelRootIdentifiers`, the reader this file already uses for the `parent`
55+
root), never off the fault's message text. Measured: with no acting user,
56+
`'admin' in current_user.positions` reports `Unknown variable: current_user`,
57+
but `'admin' in current_user.positions && record.typo == 1` reports `No such
58+
key: typo` — a message-matching key would file that second predicate as a live
59+
gate failure on every system write. `current_user`'s ADR-0068 aliases (`user`,
60+
`ctx.user`, `os.user`) count as the same root, since `buildScope` mounts one
61+
`EvalUser` under all four and none of them without a user.
62+
63+
No behaviour change: the accept/reject set does not move, a clean `false` is
64+
still refused with `invalid_option`, and an unevaluable predicate is still
65+
allowed through.

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

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,189 @@ describe('per-option visibleWhen — role gating', () => {
102102
});
103103
});
104104

105+
/**
106+
* #14416 — the fail-open branch must say WHICH of its two cases it took.
107+
*
108+
* A system write (a declarative seed, an in-process job) can never bind
109+
* `current_user`, so a role-gated option logged one `failed to evaluate —
110+
* allowed through` per seeded row — 27 on one ordinary boot, on the correct
111+
* path. An authenticated caller whose predicate genuinely faults produced the
112+
* identical line, and that one is a gate that is not being enforced.
113+
*
114+
* Both branches stay at `warn` (the sink declares only `warn`, and the
115+
* authenticated fault must not get quieter). What the pins hold is that the two
116+
* are told apart, that the fail-open ADMISSION is unchanged in both, and that
117+
* the discriminator needs BOTH facts — no acting user AND a predicate that asks
118+
* for one. A test that only checked the new wording would pass with the branch
119+
* still absent, so every case below asserts `meta.reason` too.
120+
*/
121+
describe('per-option visibleWhen — fail-open diagnostics name their case (#14416)', () => {
122+
/** Collect `(msg, meta)` pairs off the declared `{ warn? }` sink. */
123+
function capture() {
124+
const warns: Array<{ msg: string; meta: any }> = [];
125+
return { warns, logger: { warn: (msg: string, meta?: any) => warns.push({ msg, meta }) } };
126+
}
127+
128+
// A predicate that names no user root at all and faults on a typo'd field:
129+
// the case the "no acting user" discriminator MUST NOT swallow.
130+
const typoSchema = {
131+
fields: {
132+
grade: {
133+
type: 'select',
134+
options: [{ value: 'gold', visibleWhen: 'record.typo_field == 1' }],
135+
},
136+
},
137+
};
138+
139+
it('system write + a current_user predicate ⇒ one qualified warn, reason no-acting-user, value admitted', () => {
140+
const { warns, logger } = capture();
141+
expect(() =>
142+
evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', { logger }),
143+
).not.toThrow(); // fail-open admission unchanged — the seed writes the gated value
144+
145+
expect(warns).toHaveLength(1);
146+
expect(warns[0].msg).toBe(
147+
"option visibleWhen for 'tier=admin_only' not evaluated: no acting user to bind current_user (system write) — allowed through",
148+
);
149+
// The old line said "failed to evaluate", which is what an operator escalates.
150+
expect(warns[0].msg).not.toContain('failed to evaluate');
151+
expect(warns[0].meta).toMatchObject({
152+
field: 'tier',
153+
value: 'admin_only',
154+
reason: 'no-acting-user',
155+
});
156+
// The underlying fault stays recoverable from the line, not just its label.
157+
expect(warns[0].meta.error).toMatchObject({ kind: expect.any(String) });
158+
});
159+
160+
it('authenticated caller + a genuinely faulting predicate ⇒ the loud warn, reason predicate-fault, value admitted', () => {
161+
const { warns, logger } = capture();
162+
expect(() =>
163+
evaluateValidationRules(typoSchema, { grade: 'gold' }, 'insert', {
164+
currentUser: { id: 'u1', positions: ['admin'] },
165+
logger,
166+
}),
167+
).not.toThrow(); // still fail-open — this card changes the log, not the admission
168+
169+
expect(warns).toHaveLength(1);
170+
expect(warns[0].msg).toContain("option visibleWhen for 'grade=gold' failed to evaluate");
171+
expect(warns[0].msg).toContain('(authenticated caller)');
172+
expect(warns[0].msg).toContain('the option\'s gate was NOT enforced on this write');
173+
expect(warns[0].meta).toMatchObject({
174+
field: 'grade',
175+
value: 'gold',
176+
reason: 'predicate-fault',
177+
});
178+
});
179+
180+
it('system write + a predicate naming NO user root ⇒ still the LOUD line (the case the user-less test alone would misfile)', () => {
181+
// This is why the discriminator is not `currentUser === undefined` on its
182+
// own: nothing about this write is expected — the predicate is broken and
183+
// its gate is not being enforced, acting user or not.
184+
const { warns, logger } = capture();
185+
expect(() => evaluateValidationRules(typoSchema, { grade: 'gold' }, 'insert', { logger })).not.toThrow();
186+
187+
expect(warns).toHaveLength(1);
188+
expect(warns[0].msg).toContain('failed to evaluate');
189+
expect(warns[0].msg).toContain('(system write)');
190+
expect(warns[0].msg).toContain('Check the predicate.');
191+
expect(warns[0].meta).toMatchObject({ reason: 'predicate-fault' });
192+
});
193+
194+
it('reads the user root off the AST, not off the fault text (a second fault must not re-loud a seed line)', () => {
195+
// Measured on this tree: with no acting user,
196+
// `'admin' in current_user.positions` → Unknown variable: current_user
197+
// `'admin' in current_user.positions && record.typo == 1` → No such key: typo
198+
// so a key that matched the message would file the second one as a live
199+
// gate failure on every system write — the noise this branch removes.
200+
const both = {
201+
fields: {
202+
tier: {
203+
type: 'select',
204+
options: [
205+
{ value: 'admin_only', visibleWhen: "'admin' in current_user.positions && record.typo == 1" },
206+
],
207+
},
208+
},
209+
};
210+
const { warns, logger } = capture();
211+
expect(() => evaluateValidationRules(both, { tier: 'admin_only' }, 'insert', { logger })).not.toThrow();
212+
213+
expect(warns).toHaveLength(1);
214+
expect(warns[0].meta).toMatchObject({ reason: 'no-acting-user' });
215+
expect(warns[0].meta.error.message).toContain('No such key: typo'); // the other fault, still reported
216+
});
217+
218+
it('a user-root ALIAS on a system write is the same case (buildScope mounts one object under four roots)', () => {
219+
// ADR-0068 D1: `current_user` is canonical, `user` / `ctx.user` / `os.user`
220+
// are aliases for the SAME EvalUser — none of them bind without a user, so
221+
// an alias-spelled gate must not be the loud line on a seed either.
222+
for (const source of ['user.id == record.owner', 'ctx.user.id == record.owner', 'os.user.id == record.owner']) {
223+
const aliased = {
224+
fields: { flag: { type: 'select', options: [{ value: 'on', visibleWhen: source }] } },
225+
};
226+
const { warns, logger } = capture();
227+
expect(() => evaluateValidationRules(aliased, { flag: 'on' }, 'insert', { logger })).not.toThrow();
228+
expect(warns, source).toHaveLength(1);
229+
expect(warns[0].meta, source).toMatchObject({ reason: 'no-acting-user' });
230+
}
231+
});
232+
233+
describe('regression controls — the accept/reject set does not move', () => {
234+
it('authenticated caller + predicate FALSE ⇒ still refused with invalid_option', () => {
235+
const { warns, logger } = capture();
236+
let caught: any;
237+
try {
238+
evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', {
239+
currentUser: { id: 'u1', positions: ['sales'] },
240+
logger,
241+
});
242+
} catch (err) {
243+
caught = err;
244+
}
245+
expect(caught).toBeInstanceOf(ValidationError);
246+
expect(caught.code).toBe('VALIDATION_FAILED');
247+
expect(caught.fields).toEqual([
248+
expect.objectContaining({ field: 'tier', code: 'invalid_option' }),
249+
]);
250+
expect(warns).toHaveLength(0); // a clean FALSE is a decision, not a diagnostic
251+
});
252+
253+
it('authenticated caller + predicate TRUE ⇒ admitted, no warn', () => {
254+
const { warns, logger } = capture();
255+
expect(() =>
256+
evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', {
257+
currentUser: { id: 'u1', positions: ['admin'] },
258+
logger,
259+
}),
260+
).not.toThrow();
261+
expect(warns).toHaveLength(0);
262+
});
263+
264+
it('a cascade predicate that evaluates cleanly on a system write still rejects', () => {
265+
// No user root, nothing unbound — the gate is enforced on system writes too.
266+
const { warns, logger } = capture();
267+
expect(() =>
268+
evaluateValidationRules(schema, { country: 'us', province: 'zj' }, 'insert', { logger }),
269+
).toThrow(ValidationError);
270+
expect(warns).toHaveLength(0);
271+
});
272+
});
273+
274+
it('reproduces the card: N seeded rows log N lines, and none of them says "failed to evaluate"', () => {
275+
// The card measured 27 identical `failed to evaluate — allowed through`
276+
// lines on one boot, one per seeded row carrying a gated option value.
277+
const N = 27;
278+
const { warns, logger } = capture();
279+
for (let i = 0; i < N; i++) {
280+
evaluateValidationRules(schema, { tier: 'admin_only' }, 'insert', { logger });
281+
}
282+
expect(warns).toHaveLength(N); // the record of each admission is kept (option (c), not (a))
283+
expect(warns.filter((w) => w.msg.includes('failed to evaluate'))).toHaveLength(0);
284+
expect(warns.filter((w) => w.meta?.reason === 'no-acting-user')).toHaveLength(N);
285+
});
286+
});
287+
105288
describe('per-option visibleWhen — multi-select element-wise', () => {
106289
const multi = {
107290
fields: {

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

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,6 +1763,58 @@ function toExpression(cond: string | Expression): Expression {
17631763
return typeof cond === 'string' ? { dialect: 'cel', source: cond } : cond;
17641764
}
17651765

1766+
/**
1767+
* The CEL scope roots the acting user is mounted under, so a predicate that
1768+
* NEEDS a user can be told apart from one that does not.
1769+
*
1770+
* ADR-0068 D1's canonical root is `current_user`; `@objectstack/formula`'s
1771+
* `buildScope` mounts the SAME `EvalUser` object under `user`, `ctx.user` and
1772+
* `os.user` as aliases, and mounts none of them when the evaluation carries no
1773+
* user. `ctx` and `os` are listed at bare-root granularity because that is what
1774+
* {@link collectCelRootIdentifiers} reports — and that is exact at THIS call
1775+
* site rather than merely conservative: {@link evaluateOptionVisibility}
1776+
* evaluates with `{ record, previous, user }` and nothing else, so `buildScope`
1777+
* has no `org`/`env` to mount `os` from and no other source for `ctx`. Both
1778+
* roots are therefore bound here if and only if a user is.
1779+
*/
1780+
const USER_SCOPE_ROOTS: readonly string[] = ['current_user', 'user', 'ctx', 'os'];
1781+
1782+
/** Parsed-root memo — the twin of {@link parentRootCache}, same fixed sources. */
1783+
const userRootCache = new Map<string, boolean>();
1784+
1785+
/**
1786+
* Does this predicate reference the acting user under any root
1787+
* {@link USER_SCOPE_ROOTS} names?
1788+
*
1789+
* Read off the parsed CEL AST ({@link collectCelRootIdentifiers}), the same
1790+
* reader {@link readsParentRoot} uses — **never** off the evaluation fault's
1791+
* text, which was measured on this tree to be an unreliable key for the same
1792+
* question: with no acting user, `'admin' in current_user.positions` reports
1793+
* `Unknown variable: current_user`, but `'admin' in current_user.positions &&
1794+
* record.typo == 1` reports `No such key: typo` instead. A message-matching key
1795+
* would file that second predicate as a live gate failure on every system
1796+
* write — the exact noise this branch exists to end.
1797+
*
1798+
* A non-CEL dialect, an AST-only expression, or source that does not parse
1799+
* answers `false`, i.e. the loud branch. That is the safe direction and it is
1800+
* not a lost case: `celEngine.evaluate` itself refuses an expression with no
1801+
* `source` (`AST-only evaluation not yet supported; persist \`source\``), so
1802+
* anything this reader cannot see is a predicate the evaluator could not have
1803+
* run either, on any write, and it should be said loudly.
1804+
*/
1805+
function readsUserRoot(cond: string | Expression): boolean {
1806+
const expr = toExpression(cond);
1807+
if (expr.dialect !== 'cel') return false;
1808+
const source = typeof expr.source === 'string' ? expr.source : '';
1809+
if (!source) return false;
1810+
const cached = userRootCache.get(source);
1811+
if (cached !== undefined) return cached;
1812+
const roots = collectCelRootIdentifiers(source);
1813+
const answer = roots.ok && roots.roots.some((r) => USER_SCOPE_ROOTS.includes(r));
1814+
userRootCache.set(source, answer);
1815+
return answer;
1816+
}
1817+
17661818
/**
17671819
* Per-option authorization / cascade enforcement (objectui#2284).
17681820
*
@@ -1781,6 +1833,20 @@ function toExpression(cond: string | Expression): Expression {
17811833
* every other field rule here: a broken cascade predicate must never brick a
17821834
* write. Authorization gating therefore depends on the engine binding
17831835
* `current_user` on authenticated writes.
1836+
*
1837+
* The admission is deliberate and unchanged. What the fail-open branch does NOT
1838+
* do any more is describe two different facts with one sentence. A system write
1839+
* — a declarative seed, an in-process job, anything with no acting user — can
1840+
* never bind `current_user`, so every gated option it carries took that branch
1841+
* and logged `failed to evaluate — allowed through`: measured at 27 identical
1842+
* lines on one ordinary boot of a seeded app, one per seeded row, on the
1843+
* correct path. An authenticated caller whose predicate genuinely faults (a
1844+
* typo'd field, a missing root) produced the *identical* line, and that one is
1845+
* a gate that is not being enforced. A signal that fires this often on the
1846+
* expected path stops being read, and takes the real case with it. The branch
1847+
* below states which of the two happened, in the message and in structured
1848+
* `meta.reason`; both stay at `warn` — the authenticated fault must not get
1849+
* quieter, and the sink offers no other level.
17841850
*/
17851851
function evaluateOptionVisibility(
17861852
fields: Record<string, ConditionalFieldDef> | undefined,
@@ -1813,9 +1879,25 @@ function evaluateOptionVisibility(
18131879
user,
18141880
});
18151881
if (!res.ok) {
1816-
logger?.warn?.(
1817-
`option visibleWhen for '${name}=${String(value)}' failed to evaluate — allowed through`,
1818-
);
1882+
// Which of the two fail-open cases is this? "No acting user to bind"
1883+
// needs BOTH facts — the write carries no user AND the predicate asks
1884+
// for one. Either alone misfiles: a system write whose predicate names
1885+
// no user root and faults on a typo'd field is a real broken gate, and
1886+
// an authenticated caller's fault is the case this log exists for.
1887+
const noActingUser = user === undefined;
1888+
if (noActingUser && readsUserRoot(opt.visibleWhen)) {
1889+
logger?.warn?.(
1890+
`option visibleWhen for '${name}=${String(value)}' not evaluated: no acting user to bind current_user (system write) — allowed through`,
1891+
{ field: name, value: String(value), reason: 'no-acting-user', error: res.error },
1892+
);
1893+
} else {
1894+
logger?.warn?.(
1895+
`option visibleWhen for '${name}=${String(value)}' failed to evaluate `
1896+
+ `(${noActingUser ? 'system write' : 'authenticated caller'}) — allowed through; `
1897+
+ `the option's gate was NOT enforced on this write. Check the predicate.`,
1898+
{ field: name, value: String(value), reason: 'predicate-fault', error: res.error },
1899+
);
1900+
}
18191901
continue; // fail-open
18201902
}
18211903
if (res.value === false) {

0 commit comments

Comments
 (0)