feat(spec): declare the settings visible grammar the evaluator actually implements (#7327) - #7387
Conversation
…ally 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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
…ratchet, classified honestly (#7327) The expression-surface conformance ratchet discovers surfaces by matching `<key>: ExpressionInputSchema` textually, so narrowing the two settings `visible` slots onto their own schema dropped them out of the scan and turned their ledger entry stale — a live predicate surface silently leaving the ledger, which is the #1887 class the ledger exists to catch. Discovery now reads a registered list of expression-declaring schema names rather than one hardcoded name, with the failure mode written down: a slot narrowed onto its own schema must register that schema on the same commit. The classification is corrected while it is being moved. `settings-manifest visible` sat under `cel-ui` — `dialect: 'cel'`, enforced by the SchemaRenderer and celEngine — and is evaluated by neither. It gets its own `settings-visibility` row naming `evaluateVisibility`, its closed grammar, and its fail-closed policy (#7310), proved by the producer/consumer pin. `ExprDialect` gains a member for it: the ledger records what a surface IS, and spelling this one `cel` would restate in the ledger the exact claim #7327 removes from the schema. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdPj3S347aPWapzTuHCb4N
Closes #7327. Part of #7169 — the alignment half, direction (b) as ruled by the maintainer on 2026-08-10 and dispatched by the spec-lane PM.
Both
visibleslots on a settings manifest were typedExpressionInputSchema, whose bare-string arm normalises todialect: 'cel'. Nothing has ever evaluated them as CEL. This narrows the declaration to the grammar that is actually evaluated; it does not touch the evaluator, and it does not touchExpressionInputSchema.Premise check (verified before writing code)
maingit log --grep "#7310"onorigin/maind538647—fix(service-settings): fail closed on an unevaluable visible predicate (#7169) (#7310)grepinservice-settingssettings-service.test.ts:2568packages/services/service-settings/src/visibility-eval.tspost-#7310:234,:491) still point at the two slotsgrep -n visibleon the target filevisiblepredicatepackages/,apps/,examples/forSettingsManifestproducersobjectql'slifecycleSettingsManifest, which declares nonenode scripts/check-adr-0087-registration.mjs --base origin/main→ nodocs/adr/**edit demanded (0 declared-breaking changesets). No ADR or release-notes file is touched by this PR.Corpus verification — the guard that decided the direction
The #7169 measurement re-run against the narrowed declaration (not just the evaluator):
===/!==are not CEL)Measured, not asserted:
settings-visibility-declaration.pin.test.tsparses all 10 bundled manifests throughSettingsManifestSchemaand collects refusals — the array is empty. It also re-measures the corpus size (10 manifests / 94 predicates) so a manifest gaining or losing a predicate sends the next reader back to the measurement instead of trusting a stale number.Grammar as declared vs grammar as evaluated
Left:
visibility-eval.ts(save-time, #7310). Right:settings-manifest.zod.ts(publish-time, this PR). Every row is asserted equal by the pin test, not by inspection.dataonlydataonlycurrent_user, bare identifiers → refused[A-Za-z_][A-Za-z0-9_]*data.a.b→ refused||&&!===!====!=>=<=><!==beats!'/", backslash escapes), number[0-9]+(.[0-9]+)?,true,false,null-5is refused on both sides( … )${…}stripped byvisibilitySource()"${a} && ${b}"refuses on both sides rather than one side repairing it{ dialect, source }ast-only envelope passes through (opaque at this layer)"${}"→ evaluatestrue, no parseThe wire shape does not move. Bare string still normalises to
{ dialect: 'cel', source }; the reference table's type column is byte-identical. Only the acceptedsourcestrings narrow — the description column now carries the grammar, because a type cell cannot express it.The refusal is self-prescribing:
Two statements of one grammar is the drift that caused #7169, so they are pinned to each other rather than left free.
settings-visibility-declaration.pin.test.tslives on the consumer side (spec cannot import a service — Prime Directive #2) and asserts "the schema accepts it" and "the evaluator can parse it" are the same bit, over a 15-case in-grammar table, an 11-case out-of-grammar table, and the real corpus.Found in CI: the surface was about to leave the ADR-0058 ratchet
The expression-surface conformance ledger (ADR-0058 D7 / ADR-0060) re-discovers its surfaces by matching
<key>: ExpressionInputSchematextually. Narrowing the two slots onto their own schema therefore dropped them out of discovery and turned their ledger entry stale — a live predicate surface silently leaving the ledger, which is precisely the #1887 class the ledger exists to catch. Caught byDogfood Regression Gate (3/3)on the first push, fixed in60b3120:system/settings-manifest.zod.ts:visiblesat undercel-ui—dialect: 'cel', enforced by "SchemaRenderer + celEngine" — and is evaluated by neither. It gets its ownsettings-visibilityrow namingevaluateVisibility, its closed grammar and its fail-closed policy, proved by the pin test.ExprDialectgains a member for it: that type is the ledger's own vocabulary (it already carriesjs, retired from the spec enum in formula: retire thejsexpression dialect — redundant with L2 ScriptBody;hasDialectalso mis-reports the stub as real #3278), and spelling this surfacecelwould restate in the ledger the exact claim this PR removes from the schema.Reverse-checked: dropping
SettingsVisibilityInputSchemaback out of the discovery list reproduces the identicalSTALE coversfailure, so the registration — not a deleted assertion — is what keeps the surface watched.Reverse verification — predictions written before running
Both mutations were applied alone, measured, and reverted. Predictions were committed to a scratch file before either run.
ExpressionInputSchemaback on both slotsspec/settings-manifest.test.ts(5refuses CEL, 5refuses malformed, the prescription case, the manifest-level case) + 13 in the service pin (11refuses … on both sides, prescription, manifest-level) = 25>= <= > <from the spec-side operator list only> 0,>= 0.5,<= 10 && …) + 6 in the service pin (corpus, both-sides, and 4accepts) = 9; the corpus failure names exactly one refusal, namespaceauthauth: specifiers.16.visible — … "data.lockout_threshold > 0": unexpected character ">"STALE covers — surface no longer in source: system/settings-manifest.zod.ts:visibleR2 is the load-bearing one: it is #7169's failure mode with the sides swapped, and it reproduces on the exact predicate PR #7310 extended the evaluator for.
${-5 > data.x}was predicted to keep passing under R2 (the tokenizer hits-before it ever reaches>) and did.Gates
pnpm --filter @objectstack/spec buildcheck:generated(all 11)check:docs.--fixregenerated only that onecheck:authorable-surfacesystem/SettingsManifest:visible,system/Specifier:visible)check:api-surface/check:export-originspnpm --filter @objectstack/spec testpnpm --filter @objectstack/service-settings testdogfood expression-conformance(ADR-0058 ledger + ratchet)60b3120pnpm --filter @objectstack/spec typecheck(+check:scripts-typecheck,check:test-typecheck)eslint --no-inline-configon all touched filescheck:adr-0087-registration --base origin/maincheck:empty-changeset,check:changeset-no-major,check:doc-authoringChangeset level:
'@objectstack/spec': minor, no!/**BREAKING**Judged from precedent, not by feel:
major. In this repo a specmajormeans removing an authorable surface with reachable, divergent runtime behaviour —array_agg/string_agg([spec] AggregationFunction 声明 8 个,SQL 族只实现 5 个 —— count_distinct / array_agg / string_agg 按 ADR-0049 enforce-or-remove 定去留 #6188) andAggregationNode.distinct([spec/drivers]AggregationNode.distinctis honoured by the in-memory fallback and ignored by every SQL face — one query, two numbers (ADR-0049) #6815) both broke configs that genuinely worked. This removes acceptance of inputs that never worked on any path. (Theno-majorguard is dormant during the RC, so amajorwould have passed CI silently and added to the release curator's stock — the guard being off is not permission.)minor, matching the narrowing precedents.action-param-strict-unknown-keys(动作参数弹窗:内联 lookup 参数无法声明引用目标(配置被静默剥离 + 文案谎报「即将上线」) #3405) andchart-aggregate-groupby-strict(spec:ChartAggregateSchema与ChartGroupBySchemaobject 分支转 strictObject —— #5020 parse 接通后的收紧半边(#4001 后续批) #5583) are the structural twins — acceptance narrowed on an authorable spec surface — and both tookminorwith no breaking marker.reject-body-on-non-script-action(Console: modal-typed actions resolvetargetas an object — submit fails with GET /meta/object/<target> 400 #3530) tookpatchfor a single-key refusal;minoris the better fit here because a third-party manifest can now fail at publish where it previously failed only at save.!and no**BREAKING**, per those same precedents. Adding either would pull in the ADR-0087 disposition gate for a change with zero measured migrations.@objectstack/service-settingsand@objectstack/dogfoodget no entry — their only changes here are test files.Files
packages/spec/src/system/settings-manifest.zod.tspackages/spec/src/system/settings-manifest.test.tspackages/services/service-settings/src/settings-visibility-declaration.pin.test.tspackages/qa/dogfood/test/expression-conformance.test.tspackages/qa/dogfood/test/expression-conformance.ledger.tssettings-visibilityrow; the surface leavescel-ui, which never described itcontent/docs/references/system/settings-manifest.mdxgen:docs).changeset/settings-visible-grammar-declared.mdNo
content/docs/releases/ordocs/adr/**file is touched.