Skip to content

Commit 7fbc7e5

Browse files
committed
feat(spec): declare the settings visible grammar the evaluator actually implements (#7327)
Both settings-manifest `visible` slots — specifier-level and manifest-level — were typed `ExpressionInputSchema`, whose bare-string arm normalises to `dialect: 'cel'`. Nothing has ever evaluated them as CEL: their only readers are the console's client-side `new Function(...)` and, since #7310, the server-side `evaluateVisibility`, which implements a small closed grammar. `===` / `!==` — used throughout the bundled manifests — are not CEL at all. #7169 measured which side should move: routing the declared CEL into evaluation breaks 93 of the 94 bundled predicates, narrowing the declaration breaks 1, and #7310's relational-operator extension had already taken that 1 to 0. Per the maintainer's 2026-08-10 ruling (and #7071's "each protocol keeps its own spelling"), the declaration moves. Both slots now accept exactly the evaluated grammar: single root `data`, one level of member access, `|| && !`, `=== !== == != >= <= > <`, parentheses and string/number/bool/null literals, optionally `${...}`-wrapped. Bare string and `{ dialect, source }` envelope are both still accepted and a bare string still normalises to the canonical envelope, so the wire shape does not move — only the accepted `source` strings narrow. Real CEL (`data.x in [...]`, `size(data.y) > 0`, `data.a.b == 1`) is refused at publish/parse with a message naming the offending source, the reason, and the grammar that would work. #7310's save-time refusal stays as defense in depth. A second statement of one grammar is the drift that caused #7169, so the two are pinned to each other: `settings-visibility-declaration.pin.test.ts` asserts "the schema accepts it" and "the evaluator can parse it" are the same bit, over an in/out-of-grammar table and over the real corpus — re-measured at 10 manifests / 94 predicates, 0 refused. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdPj3S347aPWapzTuHCb4N
1 parent f188ed6 commit 7fbc7e5

5 files changed

Lines changed: 549 additions & 5 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the settings-manifest `visible` slots declare the grammar they are actually evaluated with, instead of claiming CEL (#7327, the alignment half of #7169)
6+
7+
Both `visible` slots on a settings manifest — specifier-level and
8+
manifest-level — were typed `ExpressionInputSchema`, the shared expression
9+
input whose bare-string arm normalises to `dialect: 'cel'`. Nothing has ever
10+
evaluated them as CEL. Their only two readers are the console's client-side
11+
`new Function(...)` over the raw string and, since #7310, the server-side
12+
`evaluateVisibility` in `@objectstack/service-settings`, which implements a
13+
deliberately tiny closed grammar. So the declared dialect and the evaluated
14+
dialect disagreed, and the disagreement was **not** cosmetic: `===` and `!==`,
15+
which the bundled manifests use throughout, are not CEL operators at all.
16+
17+
**The measurement decided which side moves.** #7169 counted the corpus — 94
18+
`visible` predicates across the 10 bundled manifests, 27 distinct sources.
19+
Wiring the *declared* CEL into evaluation breaks **93 of 94**, syntactically
20+
and totally, plus every manifest stored outside this repo. Narrowing the
21+
*declaration* to the grammar already evaluated breaks **1**, and #7310's
22+
relational-operator extension had already absorbed that one, taking it to
23+
**0**. The maintainer's 2026-08-10 ruling took the second direction, and
24+
#7071's ruling on `ExpressionInput` ("each protocol keeps its own spelling")
25+
named this narrowing as the follow-up.
26+
27+
**After:** both slots accept the grammar the evaluator implements and nothing
28+
else — a single root `data` with one level of member access, the operators
29+
`||` `&&` `!` and `===` `!==` `==` `!=` `>=` `<=` `>` `<`, parentheses, and
30+
string / number / `true` / `false` / `null` literals, optionally wrapped in
31+
`${…}`. A bare string and a `{ dialect, source }` envelope are both still
32+
accepted, and a bare string still normalises to the canonical envelope, so
33+
**the wire shape does not move** — only the set of accepted `source` strings
34+
narrows.
35+
36+
An author who reaches for real CEL is now told so where it is cheap to fix:
37+
38+
```
39+
Unsupported `visible` predicate "data.provider in ['smtp', 'resend']":
40+
unsupported identifier "in" — the only root is `data`. A settings `visible`
41+
predicate is not CEL: … Rewrite CEL membership as an `||` chain
42+
(`${data.x === 'a' || data.x === 'b'}`); function calls, macros and member
43+
paths deeper than one level have no equivalent here.
44+
```
45+
46+
Previously that predicate passed every publish-time gate and then failed the
47+
tenant's next save — and before #7310, did not even fail: it silently switched
48+
off `required`, `options`, `pattern`, `valueDomain` and the value window on its
49+
key. #7310's save-time refusal stays exactly where it is, as defense in depth:
50+
this is the producer-side check, that is the consumer-side check.
51+
52+
**The two sides are pinned to each other**, because a second statement of one
53+
grammar is exactly the drift that caused #7169 in the first place.
54+
`service-settings/src/settings-visibility-declaration.pin.test.ts` asserts that
55+
"the schema accepts it" and "the evaluator can parse it" are the same bit, over
56+
an in-grammar / out-of-grammar table *and* over the real bundled corpus — which
57+
it re-measures at 10 manifests / 94 predicates, 0 refused.
58+
59+
**Upgrading:** every bundled manifest is unaffected (measured, 0 refusals). A
60+
third-party manifest is affected only if it carries a `visible` predicate the
61+
save path already could not evaluate; the refusal names the predicate, the
62+
reason and the supported grammar. `minor` rather than `major` follows the
63+
repo's precedent for narrowing acceptance on one authorable key
64+
(`action-param-strict-unknown-keys`, `chart-aggregate-groupby-strict`) — this
65+
removes no authorable surface with reachable behaviour, so it is not the
66+
`major` class of #6188 / #6815.

content/docs/references/system/settings-manifest.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ const result = ResolvedSettingValueSchema.parse(data);
8787
| **category** | `string` | optional | Settings hub category |
8888
| **order** | `number` | optional | Display order |
8989
| **specifiers** | `{ type: Enum<'group' \| 'child_pane' \| 'info_banner' \| 'title_value' \| 'text' \| 'textarea' \| … +13 more>; id?: string; key?: string; label: string \| Record<string, string>; … }[]` || Page contents (ordered) |
90-
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Whole-manifest visibility |
90+
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Whole-manifest visibility. Grammar is NOT CEL: root `data` with one-level member access, `\|\|` `&&` `!`, `===` `!==` `==` `!=` `>=` `<=` `>` `<`, parentheses and string/number/bool/null literals, optionally wrapped in `${...}`; bare string or `{ dialect, source }` envelope. |
9191
| **featureFlag** | `string` | optional | Gate manifest visibility on a feature flag |
9292
| **beta** | `boolean` | optional | Show a Beta chip on the page |
9393

@@ -119,7 +119,7 @@ const result = ResolvedSettingValueSchema.parse(data);
119119
| **description** | `string` | optional | Help text |
120120
| **icon** | `string` | optional | Icon name (Lucide) |
121121
| **default** | `any` | optional | Default value |
122-
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility expression |
122+
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility expression evaluated against the namespace value map, e.g. `${data.provider === 'smtp'}`. Hidden specifiers are not rendered and their values are not validated. Grammar is NOT CEL: root `data` with one-level member access, `\|\|` `&&` `!`, `===` `!==` `==` `!=` `>=` `<=` `>` `<`, parentheses and string/number/bool/null literals, optionally wrapped in `${...}`; bare string or `{ dialect, source }` envelope. |
123123
| **required** | `boolean` | optional | Required field |
124124
| **encrypted** | `boolean` | optional | Encrypt value at rest (forced true for password) |
125125
| **scope** | `Enum<'global' \| 'tenant' \| 'user'>` | optional | Override manifest scope for this key |
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The producer/consumer pin for settings `visible` (#7327, the alignment half
4+
// of #7169).
5+
//
6+
// There are two checks on a `visible` predicate and they live in different
7+
// packages. The DECLARATION — `SettingsManifestSchema` in `@objectstack/spec`
8+
// — refuses an unsupported predicate at publish/parse. The EVALUATOR —
9+
// `evaluateVisibility` in this package — refuses it at save (#7310, fail
10+
// closed). Those two agreeing is not a nice-to-have: #7169 was caused by a
11+
// producer and a consumer disagreeing about what parses, and answering it by
12+
// adding a SECOND grammar statement would have reproduced the bug class if the
13+
// two were left free to drift.
14+
//
15+
// So this file asserts the pairing directly, from the consumer side (spec
16+
// cannot import a service — Prime Directive #2, the spec declares, it does not
17+
// execute). Two facts:
18+
//
19+
// 1. The corpus. Every bundled manifest still parses. This is the #7169
20+
// measurement (94 predicates / 10 manifests) carried onto the declaration
21+
// side: narrowing that refused even one of them would brick publishing of
22+
// a builtin namespace, and the whole reason direction (b) was chosen over
23+
// direction (a) is that this number is 0 rather than 93.
24+
// 2. The agreement table. For a spread of in-grammar and out-of-grammar
25+
// sources, "the schema accepts it" and "the evaluator can parse it" are
26+
// the same bit.
27+
28+
import { describe, it, expect } from 'vitest';
29+
import { SettingsManifestSchema, SpecifierSchema } from '@objectstack/spec/system';
30+
import { builtinSettingsManifests } from './manifests/index.js';
31+
import { evaluateVisibility, visibilitySource } from './visibility-eval.js';
32+
33+
/** Wrap a predicate in the smallest specifier that carries one. */
34+
const specifierWith = (visible: unknown) => ({
35+
type: 'text' as const,
36+
key: 'some_key',
37+
label: 'Some key',
38+
visible,
39+
});
40+
41+
const declarationAccepts = (visible: unknown): boolean =>
42+
SpecifierSchema.safeParse(specifierWith(visible)).success;
43+
44+
const evaluatorParses = (visible: unknown): boolean => {
45+
try {
46+
evaluateVisibility(visible, {});
47+
return true;
48+
} catch {
49+
return false;
50+
}
51+
};
52+
53+
describe('settings `visible` — declaration ⇄ evaluator', () => {
54+
it('the corpus: every bundled manifest parses against the narrowed declaration', () => {
55+
// The #7169 corpus, on the declaration side. Expected refusals: 0.
56+
const refusals: string[] = [];
57+
for (const manifest of builtinSettingsManifests as Array<Record<string, any>>) {
58+
const parsed = SettingsManifestSchema.safeParse(manifest);
59+
if (!parsed.success) {
60+
refusals.push(`${manifest.namespace}: ${parsed.error.issues.map((i) => `${i.path.join('.')}${i.message}`).join(' | ')}`);
61+
}
62+
}
63+
expect(refusals).toEqual([]);
64+
});
65+
66+
it('the corpus is still the size the ruling measured — 10 manifests, 94 predicates', () => {
67+
// If a bundled manifest gains or loses a `visible`, this number moves and
68+
// the reader is sent back to the measurement rather than trusting a stale
69+
// one. It is a tripwire on the premise, not a rule about how many
70+
// predicates a manifest may have — move it deliberately.
71+
const predicates = (builtinSettingsManifests as Array<Record<string, any>>).flatMap((m) => [
72+
...(typeof m.visible === 'undefined' ? [] : [m.visible]),
73+
...(m.specifiers ?? []).filter((s: any) => typeof s.visible !== 'undefined').map((s: any) => s.visible),
74+
]);
75+
expect(builtinSettingsManifests).toHaveLength(10);
76+
expect(predicates).toHaveLength(94);
77+
});
78+
79+
it('every bundled predicate is accepted by BOTH sides', () => {
80+
for (const manifest of builtinSettingsManifests as Array<Record<string, any>>) {
81+
for (const spec of manifest.specifiers ?? []) {
82+
if (typeof spec.visible === 'undefined') continue;
83+
const where = `${manifest.namespace}.${spec.key ?? '(layout)'}: ${visibilitySource(spec.visible)}`;
84+
expect(declarationAccepts(spec.visible), `declaration refused ${where}`).toBe(true);
85+
expect(evaluatorParses(spec.visible), `evaluator refused ${where}`).toBe(true);
86+
}
87+
}
88+
});
89+
90+
describe('the two sides answer the same bit', () => {
91+
const IN_GRAMMAR = [
92+
// Every shape the bundled corpus actually uses.
93+
"${data.provider === 'smtp'}",
94+
"${data.provider !== 'memory'}",
95+
'${data.email_password_enabled !== false}',
96+
'${data.mfa_required === true}',
97+
'${data.lockout_threshold > 0}',
98+
"${data.provider === 'resend' || data.provider === 'postmark'}",
99+
"${data.embedder_provider && data.embedder_provider !== 'none'}",
100+
"${data.provider !== 'memory' && data.title_generation_enabled !== false}",
101+
// Reachable but unused by the bundled set — declared is declared.
102+
'${!data.disabled}',
103+
'${data.count <= 10 && (data.a == 1 || data.b != null)}',
104+
'${data.ratio >= 0.5}',
105+
"${data.mode < 'z'}",
106+
// Both accepted envelopes, and the unwrapped spelling.
107+
"data.provider === 'smtp'",
108+
{ dialect: 'cel' as const, source: "${data.provider === 'smtp'}" },
109+
{ dialect: 'cel' as const, source: "data.provider === 'smtp'" },
110+
];
111+
112+
const OUT_OF_GRAMMAR = [
113+
// Real CEL an author would reasonably reach for — the case #7327 exists
114+
// to move from a save-time failure to a publish-time one.
115+
"${data.provider in ['smtp', 'resend']}",
116+
'${size(data.recipients) > 0}',
117+
"${data.provider.startsWith('s')}",
118+
'${data.nested.field === 1}',
119+
"${current_user.role === 'admin'}",
120+
"${has(data.provider) ? 'a' : 'b'}",
121+
// Malformed rather than mis-dialected.
122+
"${data.provider === 'smtp'",
123+
'${data.provider ===}',
124+
"${data.a === 'x'} && ${data.b === 'y'}",
125+
'${data.provider === }{',
126+
'${-5 > data.x}',
127+
];
128+
129+
const labelled = (xs: unknown[]) => xs.map((v) => [JSON.stringify(v), v] as const);
130+
131+
it.each(labelled(IN_GRAMMAR))('accepts %s on both sides', (_label, visible) => {
132+
expect(evaluatorParses(visible)).toBe(true);
133+
expect(declarationAccepts(visible)).toBe(true);
134+
});
135+
136+
it.each(labelled(OUT_OF_GRAMMAR))('refuses %s on both sides', (_label, visible) => {
137+
expect(evaluatorParses(visible)).toBe(false);
138+
expect(declarationAccepts(visible)).toBe(false);
139+
});
140+
});
141+
142+
it('the publish-time refusal prescribes the grammar instead of just naming a violation', () => {
143+
const result = SpecifierSchema.safeParse(specifierWith("${data.provider in ['smtp', 'resend']}"));
144+
expect(result.success).toBe(false);
145+
const message = result.success ? '' : result.error.issues.map((i) => i.message).join('\n');
146+
// The offending source, why, and — the point of the card — what to write.
147+
expect(message).toContain("data.provider in ['smtp', 'resend']");
148+
expect(message).toContain('unsupported identifier "in"');
149+
expect(message).toContain('not CEL');
150+
expect(message).toContain('`===`');
151+
expect(message).toContain('one-level member access');
152+
});
153+
154+
it('the manifest-level slot carries the same grammar as the specifier-level one', () => {
155+
const base = {
156+
namespace: 'demo',
157+
label: 'Demo',
158+
specifiers: [{ type: 'text' as const, key: 'some_key', label: 'Some key' }],
159+
};
160+
expect(SettingsManifestSchema.safeParse({ ...base, visible: "${data.tier === 'pro'}" }).success).toBe(true);
161+
expect(SettingsManifestSchema.safeParse({ ...base, visible: "${data.tier in ['pro']}" }).success).toBe(false);
162+
});
163+
});

packages/spec/src/system/settings-manifest.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,96 @@ describe('Specifier.valueDomain (#5933)', () => {
302302
});
303303
});
304304

305+
describe('`visible` — the settings visibility grammar (#7327)', () => {
306+
const withVisible = (visible: unknown) =>
307+
SpecifierSchema.safeParse({ type: 'text', key: 'smtp_host', label: 'Host', visible });
308+
const firstMessage = (visible: unknown): string => {
309+
const result = withVisible(visible);
310+
return result.success ? '' : result.error.issues[0].message;
311+
};
312+
313+
// The shapes the ten bundled manifests are actually written in. The
314+
// cross-package half — that this schema and `evaluateVisibility` accept the
315+
// same set, measured against the real corpus — is pinned from the consumer
316+
// side, in `service-settings/src/settings-visibility-declaration.pin.test.ts`.
317+
it.each([
318+
"${data.provider === 'smtp'}",
319+
"${data.provider !== 'memory'}",
320+
'${data.email_password_enabled !== false}',
321+
'${data.mfa_required === true}',
322+
'${data.lockout_threshold > 0}',
323+
"${data.provider === 'resend' || data.provider === 'postmark'}",
324+
"${data.embedder_provider && data.embedder_provider !== 'none'}",
325+
'${!data.disabled}',
326+
'${data.count <= 10 && (data.a == 1 || data.b != null)}',
327+
'${data.ratio >= 0.5}',
328+
])('accepts %s', (visible) => {
329+
expect(withVisible(visible).success).toBe(true);
330+
});
331+
332+
it('accepts the unwrapped spelling and the `{ dialect, source }` envelope', () => {
333+
expect(withVisible("data.provider === 'smtp'").success).toBe(true);
334+
expect(withVisible({ dialect: 'cel', source: "${data.provider === 'smtp'}" }).success).toBe(true);
335+
// An `ast`-only envelope is opaque at this layer — nothing to walk.
336+
expect(withVisible({ dialect: 'cel', ast: { kind: 'opaque' } }).success).toBe(true);
337+
});
338+
339+
it('normalises a bare string to the canonical envelope, unchanged by the narrowing', () => {
340+
const parsed = SpecifierSchema.parse({
341+
type: 'text', key: 'smtp_host', label: 'Host', visible: "${data.provider === 'smtp'}",
342+
});
343+
expect(parsed.visible).toEqual({ dialect: 'cel', source: "${data.provider === 'smtp'}" });
344+
});
345+
346+
// The point of #7327: this slot never was CEL, and an author who wrote CEL
347+
// here used to be told so only by a failing tenant save (#7169 / PR #7310).
348+
it.each([
349+
["${data.provider in ['smtp', 'resend']}", 'unsupported identifier "in"'],
350+
['${size(data.recipients) > 0}', 'unsupported identifier "size"'],
351+
["${current_user.role === 'admin'}", 'unsupported identifier "current_user.role"'],
352+
['${data.nested.field === 1}', 'unsupported reference "data.nested.field"'],
353+
["${data.provider.startsWith('s')}", 'unsupported reference "data.provider.startsWith"'],
354+
])('refuses CEL %s', (visible, detail) => {
355+
expect(withVisible(visible).success).toBe(false);
356+
expect(firstMessage(visible)).toContain(detail);
357+
});
358+
359+
it.each([
360+
['${data.provider ===}', 'unexpected end of expression'],
361+
["${data.a === 'x'} && ${data.b === 'y'}", 'unexpected character "}"'],
362+
['${-5 > data.x}', 'unexpected character "-"'],
363+
["${(data.a === 'x'}", 'missing closing parenthesis'],
364+
["${data.a 'x'}", 'trailing tokens'],
365+
])('refuses malformed %s', (visible, detail) => {
366+
expect(withVisible(visible).success).toBe(false);
367+
expect(firstMessage(visible)).toContain(detail);
368+
});
369+
370+
it('prescribes the grammar rather than only naming the violation', () => {
371+
// Every part an author needs to rewrite the predicate without leaving the
372+
// error: the offending source, the reason, and the grammar itself.
373+
const message = firstMessage("${data.provider in ['smtp']}");
374+
expect(message).toContain("data.provider in ['smtp']");
375+
expect(message).toContain('unsupported identifier "in"');
376+
expect(message).toContain('not CEL');
377+
expect(message).toContain('one-level member access');
378+
expect(message).toContain('`===` `!==` `==` `!=` `>=` `<=` `>` `<`');
379+
expect(message).toContain("`${data.x === 'a' || data.x === 'b'}`");
380+
});
381+
382+
it('applies to the manifest-level slot too', () => {
383+
const base = {
384+
namespace: 'demo',
385+
label: 'Demo',
386+
specifiers: [{ type: 'text', key: 'smtp_host', label: 'Host' }],
387+
};
388+
expect(SettingsManifestSchema.safeParse({ ...base, visible: "${data.tier === 'pro'}" }).success).toBe(true);
389+
const refused = SettingsManifestSchema.safeParse({ ...base, visible: "${data.tier in ['pro']}" });
390+
expect(refused.success).toBe(false);
391+
expect(refused.success ? [] : refused.error.issues[0].path).toEqual(['visible']);
392+
});
393+
});
394+
305395
describe('valueDomain membership definitions — the measurements service-settings must implement', () => {
306396
// These pin the TSDoc on `SpecifierValueDomainSchema`. `packages/spec` does
307397
// not enforce a domain (Prime Directive #2) — but the two halves have to agree

0 commit comments

Comments
 (0)