Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .changeset/settings-visible-grammar-declared.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
"@objectstack/spec": minor
---

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)

Both `visible` slots on a settings manifest — specifier-level and
manifest-level — were typed `ExpressionInputSchema`, the shared expression
input whose bare-string arm normalises to `dialect: 'cel'`. Nothing has ever
evaluated them as CEL. Their only two readers are the console's client-side
`new Function(...)` over the raw string and, since #7310, the server-side
`evaluateVisibility` in `@objectstack/service-settings`, which implements a
deliberately tiny closed grammar. So the declared dialect and the evaluated
dialect disagreed, and the disagreement was **not** cosmetic: `===` and `!==`,
which the bundled manifests use throughout, are not CEL operators at all.

**The measurement decided which side moves.** #7169 counted the corpus — 94
`visible` predicates across the 10 bundled manifests, 27 distinct sources.
Wiring the *declared* CEL into evaluation breaks **93 of 94**, syntactically
and totally, plus every manifest stored outside this repo. Narrowing the
*declaration* to the grammar already evaluated breaks **1**, and #7310's
relational-operator extension had already absorbed that one, taking it to
**0**. The maintainer's 2026-08-10 ruling took the second direction, and
#7071's ruling on `ExpressionInput` ("each protocol keeps its own spelling")
named this narrowing as the follow-up.

**After:** both slots accept the grammar the evaluator implements and nothing
else — a single root `data` with one level of member access, the operators
`||` `&&` `!` and `===` `!==` `==` `!=` `>=` `<=` `>` `<`, parentheses, and
string / number / `true` / `false` / `null` literals, optionally wrapped in
`${…}`. A bare string and a `{ 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 set of accepted `source` strings
narrows.

An author who reaches for real CEL is now told so where it is cheap to fix:

```
Unsupported `visible` predicate "data.provider in ['smtp', 'resend']":
unsupported identifier "in" — the only root is `data`. A settings `visible`
predicate is not CEL: … Rewrite CEL membership as an `||` chain
(`${data.x === 'a' || data.x === 'b'}`); function calls, macros and member
paths deeper than one level have no equivalent here.
```

Previously that predicate passed every publish-time gate and then failed the
tenant's next save — and before #7310, did not even fail: it silently switched
off `required`, `options`, `pattern`, `valueDomain` and the value window on its
key. #7310's save-time refusal stays exactly where it is, as defense in depth:
this is the producer-side check, that is the consumer-side check.

**The two sides are pinned to each other**, because a second statement of one
grammar is exactly the drift that caused #7169 in the first place.
`service-settings/src/settings-visibility-declaration.pin.test.ts` asserts that
"the schema accepts it" and "the evaluator can parse it" are the same bit, over
an in-grammar / out-of-grammar table *and* over the real bundled corpus — which
it re-measures at 10 manifests / 94 predicates, 0 refused.

**Upgrading:** every bundled manifest is unaffected (measured, 0 refusals). A
third-party manifest is affected only if it carries a `visible` predicate the
save path already could not evaluate; the refusal names the predicate, the
reason and the supported grammar. `minor` rather than `major` follows the
repo's precedent for narrowing acceptance on one authorable key
(`action-param-strict-unknown-keys`, `chart-aggregate-groupby-strict`) — this
removes no authorable surface with reachable behaviour, so it is not the
`major` class of #6188 / #6815.
4 changes: 2 additions & 2 deletions content/docs/references/system/settings-manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const result = ResolvedSettingValueSchema.parse(data);
| **category** | `string` | optional | Settings hub category |
| **order** | `number` | optional | Display order |
| **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) |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Whole-manifest visibility |
| **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. |
| **featureFlag** | `string` | optional | Gate manifest visibility on a feature flag |
| **beta** | `boolean` | optional | Show a Beta chip on the page |

Expand Down Expand Up @@ -119,7 +119,7 @@ const result = ResolvedSettingValueSchema.parse(data);
| **description** | `string` | optional | Help text |
| **icon** | `string` | optional | Icon name (Lucide) |
| **default** | `any` | optional | Default value |
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility expression |
| **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. |
| **required** | `boolean` | optional | Required field |
| **encrypted** | `boolean` | optional | Encrypt value at rest (forced true for password) |
| **scope** | `Enum<'global' \| 'tenant' \| 'user'>` | optional | Override manifest scope for this key |
Expand Down
44 changes: 40 additions & 4 deletions packages/qa/dogfood/test/expression-conformance.ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,24 @@ import type { ConformanceRow } from '@objectstack/verify';
// the celEngine). There is NO silent fallback from compile to interpret.
//
// The companion test (`expression-conformance.test.ts`) RE-DISCOVERS every
// `ExpressionInputSchema` field declaration in `packages/spec/src` (plus the RLS
// expression-declaring field in `packages/spec/src` (plus the RLS
// `using`/`check` string predicates) and asserts each is `covers`-ed by exactly
// one row. A NEW expression surface that nobody classified — the #1887 class of
// "declared-but-unwired predicate" — breaks the build.
// "declared-but-unwired predicate" — breaks the build. Discovery is by SCHEMA
// NAME (`EXPRESSION_INPUT_SCHEMAS` in that file), so a slot narrowed onto its
// own schema must register that schema there or it drops out of the scan.

export type ExprMode = 'compile' | 'interpret';
export type ExprDialect = 'cel' | 'cron' | 'template' | 'js';
/**
* What a surface is ACTUALLY evaluated as — deliberately not the spec's
* `ExpressionDialect` enum. `js` outlives its retirement from that enum
* (#3278), and `settings-visibility` never was in it: the settings manifest
* `visible` slots carry a closed hand-rolled grammar with its own evaluator
* (#7169 / #7327). A ledger that could only spell the three declared dialects
* would have to record the settings slot as `cel`, which is the exact
* misclassification it exists to surface.
*/
export type ExprDialect = 'cel' | 'cron' | 'template' | 'js' | 'settings-visibility';
export type ExprState = 'enforced' | 'experimental' | 'removed';
/** ADR-0058 D5 fail-policy tiers. */
export type FailPolicy = 'compile-error' | 'fail-closed' | 'fail-soft-log' | 'throw';
Expand Down Expand Up @@ -143,9 +154,34 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
// this new surface). Interpreted by the objectui page:tabs renderer to
// omit the whole tab (header + panel) when FALSE.
'ui/component.zod.ts:visibleWhen',
'system/settings-manifest.zod.ts:visible',
// `system/settings-manifest.zod.ts:visible` used to sit here. It never
// belonged: this row's dialect is `cel` and its enforcement is the
// SchemaRenderer + celEngine, and the settings slot is evaluated by
// neither. Split out as `settings-visibility` in #7327.
],
},
{
id: 'settings-visibility',
summary: 'settings-manifest `visible` — specifier + whole-manifest gating (a closed non-CEL grammar)',
// The one row in this ledger whose dialect is NOT one of the spec's three.
// That is the finding, not an oversight: the slot was typed
// `ExpressionInputSchema` — which labels its contents CEL — while its only
// two evaluators read a small hand-rolled grammar. Measured in #7169 over
// the 94 bundled predicates: routing them through CEL breaks 93 (`===` and
// `!==` are not CEL operators at all), so the maintainer's 2026-08-10
// ruling moved the DECLARATION rather than the evaluator. Classifying this
// `cel` under `cel-ui` would restate here the exact claim #7327 removed
// from the schema.
dialect: 'settings-visibility', mode: 'interpret', state: 'enforced', failPolicy: 'fail-closed',
enforcement:
'service-settings `evaluateVisibility` (visibility-eval.ts), called from `SettingsService.validatePatch` — a closed grammar: single root `data`, one-level member access, `|| && !`, `=== !== == != >= <= > <`, parens, and string/number/bool/null literals, optionally `${…}`-wrapped, as a bare string or a `{dialect, source}` envelope. Fail-closed since #7310: a predicate outside the grammar REFUSES the save (SettingsValidationError, HTTP 400) instead of skipping the specifier — `visible` gates every other check on the key (`required`, `options`, `pattern`, `valueDomain`, the value window), so skipping it switched all of them off at once. The console evaluates the same string client-side through `new Function(...)`. Since #7327 the spec DECLARES that same grammar (`SettingsVisibilityInputSchema`), so it is refused at publish/parse too',
covers: ['system/settings-manifest.zod.ts:visible'],
// Proof is the producer/consumer pin rather than a runtime fixture: the
// failure mode this surface actually has is the two sides disagreeing about
// what parses, which is what that file measures — over the real bundled
// corpus and an in/out-of-grammar table.
proof: 'packages/services/service-settings/src/settings-visibility-declaration.pin.test.ts',
},
{
id: 'cel-action-param-option-visible',
summary: "action param option-list per-option gating (params[].options[].visibleWhen, #5016)",
Expand Down
32 changes: 27 additions & 5 deletions packages/qa/dogfood/test/expression-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
//
// ADR-0058 D7 — the Expression Surface Conformance ledger is a CHECKED artifact.
// Refactored onto the reusable ADR-0060 `checkLedger` helper: one call asserts
// the shared invariants AND the ratchet (re-discover every ExpressionInputSchema
// field in packages/spec/src + the RLS using/check predicates; fail if any is
// unclassified). The expression-specific invariants (mode/dialect/fail-policy,
// the shared invariants AND the ratchet (re-discover every expression-declaring
// field in packages/spec/src — see EXPRESSION_INPUT_SCHEMAS — plus the RLS
// using/check predicates; fail if any is unclassified). Discovery is by SCHEMA
// NAME, so a slot that moves to a narrower schema leaves the scan unless that
// schema is registered: #7327 is the worked example. The
// expression-specific invariants (mode/dialect/fail-policy,
// compile rows name the canonical compiler) stay here.

import { describe, expect, it } from 'vitest';
Expand All @@ -20,7 +23,26 @@ const SPEC_SRC = join(REPO_ROOT, 'packages/spec/src');

const MODES = new Set(['compile', 'interpret']);
const FAIL_POLICIES = new Set(['compile-error', 'fail-closed', 'fail-soft-log', 'throw']);
const DIALECTS = new Set(['cel', 'cron', 'template', 'js']);
// `settings-visibility` is not one of the spec's `ExpressionDialect` members on
// purpose (#7327): it is a closed non-CEL grammar with its own evaluator, and
// the ledger's job is to say what a surface IS, not what its schema used to
// claim. See the `settings-visibility` row.
const DIALECTS = new Set(['cel', 'cron', 'template', 'js', 'settings-visibility']);

/**
* Schemas that DECLARE an expression surface. `ExpressionInputSchema` is the
* shared one; a slot whose accepted grammar is narrower gets its own schema and
* must be listed here too, or the ratchet silently stops watching it.
*
* That is not hypothetical — it is how this scan behaves by construction, and
* #7327 hit it: narrowing the settings `visible` slots off `ExpressionInputSchema`
* dropped them out of discovery and turned their ledger entry stale. A new
* narrowed alias belongs in this list on the same commit that introduces it.
*/
const EXPRESSION_INPUT_SCHEMAS = ['ExpressionInputSchema', 'SettingsVisibilityInputSchema'];
const DECLARES_EXPRESSION = new RegExp(
String.raw`^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(?:${EXPRESSION_INPUT_SCHEMAS.join('|')})\b`,
);

/** Re-discover every expression surface in the spec — the SAME scan the ledger encodes. */
function discoverSurfaces(): Set<string> {
Expand All @@ -34,7 +56,7 @@ function discoverSurfaces(): Set<string> {
else if (ent.isFile() && ent.name.endsWith('.zod.ts')) {
const rel = relative(SPEC_SRC, p);
for (const line of readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*ExpressionInputSchema\b/);
const m = line.match(DECLARES_EXPRESSION);
if (m) found.add(`${rel}:${m[1]}`);
}
}
Expand Down
Loading
Loading