fix(spec): refuse requiresFeature composed with a blank-source CEL visible (#17631) - #18575
Conversation
…L `visible` `lowerRequiresFeature`'s guard tested the TYPE of `existing.source`, so a whitespace-only source — legal on the persistence contract, whose `min(1)` whitespace clears — passed it and the feature gate was composed around a blank operand, yielding `( ) && features.X != false`. That predicate parses on no scope at all, so the gate faults at evaluation instead of gating: the flag decides nothing whichever way the consuming surface fails. That is the parses-clean-changes-nothing arrival the guard's own docblock cites ADR-0078 to reject, produced by the composition step itself. The lowering now refuses a `source` that is blank after trimming, on the same leg as the AST-only refusal, with a refusal naming the composition it would have produced and both exits. The notion of blank is `source.trim()`, the one the engine's own helpers apply, so a merely padded source still composes. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…g`'s sink closure Interpolating the composed gate into the refusal put `featureGatePredicate` — and through it the whole `PUBLIC_AUTH_FEATURES` registry — in a customer-facing `message:` position, and that gate's per-module fixed point then swept the registry's internal `notes` / `exempt.reason` prose as customer-facing text. Measured: `pnpm check:doc-authoring` is green at the base commit and flagged three pre-existing strings with the interpolation in. The sentence now names the composed shape without interpolating the gate, which is also how the two refusals beside it are written. No test pinned the wording. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f5553b5de5a9e0debb0c0c3645b07f984da4a8d3 && git checkout f5553b5de5a9e0debb0c0c3645b07f984da4a8d3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 298e9dde15e4e7c3b340323215e084fd60107cc3 ec0eaed2d2053cd63da2db9c5efd0a17ba9e5e9c && git checkout -B drift-repro 298e9dde15e4e7c3b340323215e084fd60107cc3 && git merge --no-ff ec0eaed2d2053cd63da2db9c5efd0a17ba9e5e9c
node scripts/docs-audit/affected-docs.mjs --json 298e9dde15e4e7c3b340323215e084fd60107cc3
|
Fixes #17631
Clause-②: no
lowerRequiresFeaturelowers therequiresFeature: 'FLAG_NAME'sugar into the canonicalvisibleCEL predicate, and its own docblock states the ADR-0078 rule it enforces: a composition that could never take effect is a loud parse error, not a silent one. The guard enforcing that rule tested the type ofsource—typeof existing.source !== 'string'— so a whitespace-onlysource, legal onExpressionSchema(the persistence contract, whosemin(1)whitespace clears), passed it and the feature gate was composed around a blank operand. The guard produced exactly the shape it exists to reject.Measured, this branch, on
f962be9d08The card's four-row probe on
ActionSchema, reproduced before touching anything — all four rows came out as filed:f962be9d08)visible, NOrequiresFeatureparses=true, envelope verbatimvisible, WITHrequiresFeatureparses=false(AST-only refusal)visible, NOrequiresFeatureparses=true,{"dialect":"cel","source":" "}visible, WITHrequiresFeatureparses=true,source: "( ) && features.organization != false"parses=false, loud refusal atrequiresFeatureAnd the composed predicate, through
@objectstack/formulacelEngine.evaluate, with a control leg differing only in the source:So at render the gate faults instead of gating: fail-soft surfaces show the element regardless of the flag, fail-closed surfaces hide it regardless of the flag. Either way the flag decides nothing.
The repair, and why it is this one and not the wider one
Two repairs were on the table and they differ in blast radius. This PR takes the guard, on the merits, not because it is the smaller diff:
( ) && features.X != falseis built by the composition step inlowerRequiresFeature. Contract-first says refuse at the producer; this is the producer.ActionSchema.visibleandActionParamSchema.visibletoday, and whatever composes it next — because the rule lives in the shared lowering rather than in either slot's declaration.visibleontoEvaluatedExpressionInputSchemaalso refuses a blank-sourcevisiblewith norequiresFeaturebeside it — row 3 above, which parses clean today by design. spec/kernel:requiresFeaturecomposed with a blank-sourcevisiblebuilds a predicate that can never parse — ADR-0078's no-silently-inert guard produces the shape it exists to reject #17631's own filing says that is one of the per-slot decisions spec: the evaluated-slot rule of #15430 reaches only the flow-node ledger — every otherExpressionInputSchemaslot an engine evaluates (formulaexpression, validation / hook / sharingcondition,visibleWhen…) still accepts anast-only or blank-sourceenvelope #15811's census enumerates and "may want to travel with that family rather than alone".expression.zod.tsspelling of it is refused by that file's own declaration, not merely by scope:EvaluatedExpressionSchema's docblock states "ExpressionSchemaitself is NOT narrowed: it is the persistence contract". The evaluated-slot rule already exists there asEvaluatedExpressionInputSchema; nothing in that file needed to move, and nothing in it did. It was declared read-only for this card (in flight as Drop the M9.1 / M9.2 phase language frompackages/specandpackages/formula(the spec-seat half of #17323, ruled B) #17849 / PR docs(spec,formula): state the Expression contract in the present tense — drop the M9.1 / M9.2 phase language #18557) and was read, never edited.What changed
packages/spec/src/kernel/public-auth-features.ts— the lowering refuses asourcethat is blank after trimming, on the same leg as the AST-only refusal above it. The notion of blank issource.trim(), the one the engine's own helpers apply, so asourcemerely padded around real text still composes verbatim. The refusal names the composition it would have produced and both exits (drop the blankvisibleand the sugar emits the gate alone; or put the predicate the gate should compose with insource).packages/spec/src/kernel/public-auth-features.test.ts— the new refusal is pinned in the same shape as the AST-only pin beside it (issue kind and subject, never the wording), across three blank spellings, plus the assertion that no envelope is produced. A second pin fixes the boundary the refusal must not cross: a padded-but-authored source still composes..changeset/17631-requires-feature-blank-source.md—patch.Reverse verification
Committed first, then mutated, then restored — the mutation proven on disk by occurrence count and by blob hash, the restore proven by blob hash equality with
HEADplus an emptygit diff HEAD, under atrap ... EXIT INT TERMwith absolute paths. The test resolves its subject through a relative./public-auth-featuresimport, not through the packageexports, so no rebuild sits between the edit and the verdict.Mutation:
if (existing.source.trim().length === 0) {->if (false) {(the pre-fix behaviour). Predicted direction: red.Exactly the new pin went red and the other 23 stayed green, so the mutation is targeted and the pin is discriminating.
One gate finding this change caused, and where it was fixed
The refusal was first written interpolating the composed gate. That put
featureGatePredicate— and through it the wholePUBLIC_AUTH_FEATURESregistry — in a customer-facingmessage:position, andcheck:doc-authoring's per-module fixed point then swept that registry's internalnotes/exempt.reasonprose as customer-facing text, flagging three pre-existing strings (:197,:220,:230).Measured with a control leg rather than assumed: the same gate is green with
packages/spec/src/kernel/public-auth-features.tsreverted tof962be9d08(blob equality asserted both ways), and red with the interpolation in. So the red was this diff's, not a pre-existing one.Fixed at the cause — the sentence now names the composed shape without interpolating the gate, which is also how the two refusals beside it are written — rather than by editing three unrelated strings in the registry.
pnpm check:doc-authoringis green again: 15502 customer-facing strings across 993 spec sources clean.Acceptance notes
check:doc-authoring's hoisted-const fixed point is a per-module over-approximation: one field of a const reaching amessage:position (semantics, here) makes every string in that const customer-facing, including registry notes no customer can ever see. The gate's own output declares this spelling ("hoisted text-sink consts (fixed-point, per module)"), so it is designed behaviour rather than a contract violation, and it is cheap to work around at the call site. Successor: none identified — no queued card touches this gate's climb.kernel/public-auth-features.test.tsand inui/action.test.ts. This PR adds the first of those two.ui/action.test.tsis declared read-only for this card, so the schema-level sibling pin is not in this diff; the schema-level behaviour is measured in the table above instead. A seat holding that file can add it in oneitblock.Gates
Derived for the actual changed paths with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(three-dot against the merge base; committed + working tree + untracked), then reconciled with--rancarrying every exit code. Each exit code landed to disk before being read — never through a pipe.check:api-surface,check:authorable-surface,check:docs,check:liveness,check:adr-0087-registration,check:changeset-no-major,check:nul-bytes,check:doc-authoring,check:type-check-coverage,check:type-check-debt,check:test-source-alias,check:dispatcher-error-vocabulary,check:where-matcher.check:doc-formula-expressions,check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt. The workspace closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 72/72 successful) and all four then exited 0. They are measured, not declared away.pnpm --filter @objectstack/spec typecheckgreen;pnpm --filter @objectstack/spec testgreen — 483 files, 13775 tests — both re-run at the final head, after the refusal sentence changed.pnpm --filter @objectstack/spec check:generated— all 15 generated artifacts up to date, nothing to regenerate.pnpm lint(eslint . --no-inline-config, the whole repo, no narrowing) green atec0eaed2d2, the final commit.node scripts/pm/check-clause2-carriers.mjs --pair 18575— exit 0: the clause-② declaration is readable in the fixed spelling, both carriers agree, and the diff carries no widening tell. Noneeds:contract-reviewlabel is on this PR; hanging or clearing it is the seat's, not this PR's.pnpm check:cross-package-test-inputsexit 1, signature@objectstack/cli descends a directory tree from packages/spec/dist/. That is the known defect already carried by [finding] check:cross-package-test-inputs passes in CI and fails on a built tree — its verdict is a function of gitignored build state #18353 and [finding]check:cross-package-test-inputsanswers 1 or 0 depending on whetherpackages/spechas been BUILT — the author who follows AGENTS.md is the only one who sees the red, and CI never does #18440 — it exits 1 on any tree wherepackages/spechas been built and 0 when it has not, ablated both ways by two devs this session. Not filed again, and unrelated to this diff.维护者速读(草稿)
改了什么 ——
requiresFeature这个语法糖在和一个source只有空白字符的visible组合时,会生成( ) && features.X != false这种任何作用域下都解析不了的谓词。现在它当场报错拒绝,而不是悄悄合成一个永远跑不起来的门。为什么改 —— 这正是
lowerRequiresFeature自己文档里援引 ADR-0078 要拒绝的形态:声明解析通过、却什么都不改变。作者以为加了特性开关,实际上渲染时谓词直接解析失败 —— 容错的界面把元素照常显示出来(开关形同虚设),严格的界面无论开关如何都把它藏起来。两种结局都是开关不起作用。风险与代价(含回滚) —— 这是一次接受集收窄:今天能解析通过的一种写法,以后会被拒绝。但被拒绝的那种写法在运行时本来就必然失败,所以没有任何能正常工作的写法被破坏,也就没有迁移负担 —— 拒绝信息本身就是处方。
ExpressionSchema/ExpressionInputSchema(持久化契约)一个字节没动,不带requiresFeature的空白source照旧解析通过。回滚 = 撤销这个 PR,没有数据迁移、没有生成物、没有已发布的注册表条目参与。席位意见 ——
你要做的 —— 无需操作。这是一个
patch级修复,不触及受管面,按常规队列落地即可。Generated by Claude Code