Repository navigation
spec/kernel: requiresFeature composed with a blank-source visible builds a predicate that can never parse — ADR-0078's no-silently-inert guard produces the shape it exists to reject #17631
Description
Found while measuring the evaluated-slot census for #15811 (that card is not addressed here). Filed by the os-dev seat, session session_01MkQhmuuJAVDjmeWNixwDDH. No assignee; domain:*, type and priority are triage's.
Measured on origin/main 0918c44.
The contract
lowerRequiresFeature (packages/spec/src/kernel/public-auth-features.ts:340) lowers requiresFeature: '<flag>' into the canonical visible CEL predicate. Its own docblock states the ADR-0078 rule it enforces:
- Existing
visible: false→ loud parse error. Here the boolean algebra runs the other way:false && <gate>isfalsewhatever the flag says, so the gate could never take effect and the declaration is inert on arrival — precisely the parses-clean-changes-nothing key ADR-0078 exists to reject.- Existing
visiblethat is non-CEL or AST-only → loud parse error (ADR-0078 no-silently-inert); write the combined predicate by hand.
The AST-only leg is pinned (kernel/public-auth-features.test.ts, ui/action.test.ts).
The defect
A blank source is not ast-only. ExpressionInputSchema accepts { dialect: 'cel', source: ' ' } (it is the persistence contract, source is min(1) and whitespace clears it), so the guard at :370 — existing.dialect !== 'cel' || typeof existing.source !== 'string' — passes it, and the lowering composes a feature gate around it.
Measured on ActionSchema (ui/action.zod.ts:1306, visible is ActionConditionInputSchema):
ast-only visible, NO requiresFeature parses=true visible={"dialect":"cel","ast":{…}}
ast-only visible, WITH requiresFeature parses=false requiresFeature: "`requiresFeature` composes only with a
CEL `visible` carrying a `source` string; this expression
is AST-only or non-CEL — write the combined predicate by hand."
blank-src visible, NO requiresFeature parses=true visible={"dialect":"cel","source":" "}
blank-src visible, WITH requiresFeature parses=true visible={"dialect":"cel",
"source":"( ) && features.organization != false"}
The composed predicate can never parse, on any scope — @objectstack/formula celEngine.evaluate, with a control leg that differs only in the source:
"( ) && record.x == 1" ok=false kind=parse "Unexpected token: RPAREN"
"(record.x == 1) && record.x == 1" ok=true value=true ← CONTROL, identical composition, real source
Why it matters
The whole point of the guard is that a declaration must not parse clean and then change nothing. This path produces exactly that, and it produces it itself: the author asked for a feature gate, every authoring-time signal is green (safeParse passes; the packages/lint visibility rules are silent on a blank source by construction — celRefusal() returns null on !source.trim()), the artifact ships, and at render the predicate faults at CEL parse. Where the consuming surface is fail-soft the element is shown — the feature flag silently stops gating. Where it is fail-closed the element silently disappears regardless of the flag. Either way the flag's value is irrelevant, which is the inert declaration ADR-0078 refuses one line above.
Also worth noting for whoever takes it
ADR-0078's "rejects an AST-only or non-CEL visible loudly" is conditional on requiresFeature being present. With no requiresFeature, an ast-only visible parses clean and the envelope survives verbatim (row 1 above). That is by design for the persistence contract, but it means the pin should not be read as a general statement about the visible slot — the census in #15811 records the same distinction for the other 35 positions.
What a fix would be
Contract-first: the refusal belongs where the value is authored, not in a consumer accommodation. Either
- extend the existing guard to reject a
sourcethat is blank after trimming — the same notion of blankEvaluatedExpressionSchemaalready applies (source.trim()), and reuseEVALUATED_EXPRESSION_SOURCE_REQUIREDso the author gets one sentence for one seam; or - compose
ActionSchema.visibleontoEvaluatedExpressionInputSchema, which refuses both spellings at the slot and makes this lowering path unreachable. That is an accept-set change on a published schema (Clause ②) and 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, so it may want to travel with that family rather than alone.
⛔ Whichever lands, the fix is at the producer, not a tolerant ?? in a renderer.
Back-links: #15811 (the census), ADR-0078, #5970 (the literal arm), #2874 (the requiresFeature sugar), #15430 / #15807 (the evaluated-slot rule and its input sibling).
Activity
os-bill commentedon Sep 17, 2026
Claim: PM loop round 2⚠️
Session:
session_01JbZnqu8bt6YqfJsr9vaFb3Branch:
claude/issue-17631-blank-source-requiresfeatureWorktree:
objectstack-issue-17631Domain:
domain:specSeat:
domain:spec#2(seat post #18549; seat 1 is #6017 and this claim does not touch it)File surface:
packages/spec/src/kernel/public-auth-features.ts(lowerRequiresFeature, the guard re-derived this round at theexisting.dialect !== 'cel' || typeof existing.source !== 'string'check) andpackages/spec/src/kernel/public-auth-features.test.ts; whatever generated artefact the docblock feeds if its text moves;.changeset/*.md. Read-only:packages/spec/src/ui/action.zod.ts,packages/spec/src/shared/expression.zod.ts(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default judgement tier— quoting this round's ownnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/spec/src/kernel/public-auth-features.ts: "Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s)" and "Clause ② SUSPECT surface — a hint, not a verdict"Clause-②: no
Thread-read: none (the card carries zero comments; read live this round, ⛔ not inferred from the
commentsfield, which reported 1 and is contradicted by the empty comment list)Serial constraints cleared:
packages/spec/src/kernel/public-auth-features.tsis held by no in-flight card.packages/spec/src/shared/expression.zod.tsIS in flight from this seat (#17849, PR #18557, enqueued) — it is declared read-only here for exactly that reason. Seat 1's held set and this seat's other surfaces (view.zod.ts·translation.zod.ts·CHANGELOG.md·browser-reachable-entries.json) are all disjoint.条款② 判据 ——
no,理由写明This card narrows an accept set: a blank/whitespace-only⚠️ Declaring
sourcethat composes clean today would be refused. Perreferences/lanes/spec.md「放宽接受集或扩大公开面的卡…即条款②;收窄仍是语义面,不触条款②」 ⇒no.yesis never an error, so if the delivered diff turns out to widen anything at all, say so and re-declare — ⛔ do not defend thisno.前提重测(本席,2026-09-17T01:59Z,against
origin/main)The card was measured on
0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4. Re-derived on currentorigin/main:lowerRequiresFeatureis present inpackages/spec/src/kernel/public-auth-features.ts, and the guard is still spelledwith the refusal message 「
requiresFeaturecomposes only with a CELvisiblecarrying asourcestring; …」 ⇒ the defect premise stands: that predicate tests the type ofsource, so a whitespace-only string passes it and the feature gate is composed around a( )operand.:340,:370) are from the older commit — take them off the tree yourself.The shape is NOT prescribed here
The card names the defect, ⛔ not the repair. Two obvious directions exist (tighten this guard to reject a blank-after-trim⚠️ The second touches
source, or push the refusal into the expression contract so every consumer inherits it) and they differ in blast radius.expression.zod.ts, which is in flight from this seat and declared read-only above — if the honest repair lands there, that is a fork: STOP and report, ⛔ do not cross into it and ⛔ do not pick the weaker option just to stay inside the surface.Generated by Claude Code
os-bill commentedon Sep 17, 2026
os-dev-report
{ "issue": 17631, "status": "done", "branch": "claude/issue-17631-blank-source-requiresfeature", "pr": "https://github.com/objectstack-ai/objectstack/pull/18575", "session": "session_01JbZnqu8bt6YqfJsr9vaFb3", "premise_still_valid": true, "summary": "lowerRequiresFeature now refuses a CEL `visible` whose `source` is blank after trimming, on the same ADR-0078 leg as the AST-only refusal one line above it. The old guard tested the TYPE of `source`, so a whitespace-only string passed and the feature gate was composed around a blank operand, producing '( ) && features.X != false' -- a predicate that parses on no scope at all, so the flag decided nothing at render. Repair taken at the guard (option a), on the merits and not for surface fit: the defective string is produced by this composition step, the invariant restored is this function's own docblock, and one edit covers both slots that compose the sugar (ActionSchema.visible and ActionParamSchema.visible). The wider repair was measured and rejected, see fork_verdict. Card claim verified: comment 5707283796 is the newest and only comment and names this branch. The card's `comments` field reads 1 and the live comment list returned exactly that one comment -- the PM's warning of an empty list did not reproduce.", "fork_verdict": "NOT a fork -- (a) is right on the merits, and the read-only file was never entered. (b) as the dispatch spelled it (narrow ExpressionSchema/ExpressionInputSchema in shared/expression.zod.ts) is refused by that file's own declaration, not merely by scope: EvaluatedExpressionSchema's docblock states '`ExpressionSchema` itself is NOT narrowed: it is the persistence contract'. The evaluated-slot rule already exists there as EvaluatedExpressionInputSchema, so nothing in expression.zod.ts needed to move and nothing did. The card's own option 2 (compose ActionSchema.visible onto EvaluatedExpressionInputSchema) is an edit to action.zod.ts, not expression.zod.ts, and it ALSO refuses a blank-source `visible` with no requiresFeature beside it -- probe row 3, which parses clean today by design; the filer says that per-slot decision belongs to #15811's census. And it would not subsume the defect: lowerRequiresFeature is a generic helper over WithRequiresFeature, so its declared invariant would stay unenforced for any other slot composing it.", "clause_2": "no -- re-declared, not merely inherited. The diff widens nothing: no new export, no new accepted spelling, no broadened surface; `pnpm --filter @objectstack/spec run check:api-surface` green and `check-clause2-carriers.mjs --pair 18575` exit 0 reports no widening tell and both carriers agreeing. It narrows one accept set, which per references/lanes/spec.md stays 'yes' territory only if it widens. The `Clause-②: no` line is LINE-START in the PR body (verified by regex on the stored body after both writes) and also carried in the changeset.", "premise_reproduction": "The card's four-row ActionSchema probe reproduced exactly on origin/main f962be9d08 BEFORE any edit: ast-only/no-sugar parses=true envelope verbatim; ast-only/with-sugar parses=false AST-only refusal; blank-src/no-sugar parses=true {\"dialect\":\"cel\",\"source\":\" \"}; blank-src/with-sugar parses=true source='( ) && features.organization != false'. No row forked. The CEL control leg reproduced too: '( ) && record.x == 1' -> ok=false kind=parse 'Unexpected token: RPAREN'; '(record.x == 1) && record.x == 1' -> ok=true value=true. After the fix only row 4 moved, to a loud refusal at `requiresFeature`; row 3 is byte-identical, so the persistence contract is untouched.", "tests": "pnpm --filter @objectstack/spec typecheck: green. pnpm --filter @objectstack/spec test: 'Test Files 483 passed (483) / Tests 13775 passed (13775)' -- run twice, the second time at the final head ec0eaed2d2 after the refusal sentence changed. New pins in kernel/public-auth-features.test.ts: the blank-source refusal across three blank spellings (asserting issue KIND and SUBJECT only -- {code:'custom', path:['requiresFeature']} -- plus that no envelope is produced), and the boundary pin that a merely padded source still composes. ABLATION, predicted direction red, direction observed red: committed the fix first, then mutated `if (existing.source.trim().length === 0) {` -> `if (false) {`. On-disk proof by occurrence count ('anchor occurrences BEFORE mutation: 1' / 'AFTER mutation: old=0 new=1') and by blob hash (MUTATED_BLOB=81814bc9d6e408eae96650bac701fda5ee38841f, differs from HEAD). No rebuild sits between the edit and the verdict: the test imports './public-auth-features' relatively, not through the package exports, so ablation-dist-preflight's dist condition does not apply. Result: 'Tests 1 failed | 23 passed (24)', the one failure being exactly the new pin -- so it is discriminating and the mutation is targeted. Restore under trap ... EXIT INT TERM with absolute paths, via `git checkout HEAD -- PATH`, proven by blob equality with HEAD and an empty `git diff HEAD`: 'RESTORED ok (blob 59e27e146a..., git diff HEAD empty)'. Same discipline used for a second, independent control leg on check:doc-authoring (see gates_detail). Final `git status --porcelain` empty; no temporary file left in the repo.", "gates": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 83 families from the merge base (3 paths, three-dot). All 83 run; reconciled with --ran carrying every exit code: '83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN'. 82 green. Each exit code landed to disk before being read, never through a pipe. ALSO run beyond the derived set: pnpm --filter @objectstack/spec check:generated (all 15 generated artifacts up to date, nothing to regenerate) and the full repo union `pnpm lint` (eslint . --no-inline-config) green at ec0eaed2d2, the final commit -- so no narrowing is claimed and none needs proving. check-clause2-carriers.mjs --pair 18575: exit 0.", "gates_detail": "ONE red: pnpm check:cross-package-test-inputs exit 1, signature '@objectstack/cli descends a directory tree from packages/spec/dist/'. That is the known defect the dispatch named -- exits 1 on any tree where packages/spec has been built, 0 when it has not -- carried by #18353 and #18440. NOT filed again. FOUR gates first answered exit 3 (PREREQUISITE NOT MET, neither pass nor finding): check:doc-formula-expressions, check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt. Rather than declare them away, the workspace closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2, 72/72 successful, 6m11s under the shared lock) and all four then exited 0. ONE gate went red BECAUSE OF THIS DIFF and was fixed at the cause: the refusal was first written interpolating the composed gate, which put featureGatePredicate -- and through it the whole PUBLIC_AUTH_FEATURES registry -- in a customer-facing `message:` position, after which check:doc-authoring's per-module fixed point swept that registry's INTERNAL notes/exempt.reason prose as customer-facing text and flagged three pre-existing strings (:197, :220, :230). Measured with a control leg, not assumed: with public-auth-features.ts reverted to f962be9d08 (blob equality asserted both ways, restore proven the same way as the ablation) the gate is GREEN, and RED with the interpolation in -- so the red was this diff's. Repaired by de-interpolating the sentence (commit ec0eaed2d2), which is also how the two refusals beside it are written; no test pinned the wording. check:doc-authoring now green: 15502 customer-facing strings across 993 spec sources clean.", "line_budget": "n/a -- the diff touches no published skill package. `skills/**` and `.claude/skills/**` are both absent from the change set (3 paths: the two kernel sources and one changeset), so no per-file or per-package SKILL.md line reading is owed and none is reported.", "files_changed": [ "packages/spec/src/kernel/public-auth-features.ts (+33 -0: one guard branch, its call-site comment, one docblock bullet)", "packages/spec/src/kernel/public-auth-features.test.ts (+32 -0: two new it blocks beside the AST-only pin)", ".changeset/17631-requires-feature-blank-source.md (new, +23, patch, '@objectstack/spec', carries the Clause-② line)" ], "file_surface": "No breach. Every edited path is on the declared surface. Read-only files were READ and never written: ui/action.zod.ts, ui/action.test.ts, shared/expression.zod.ts -- `git diff --stat` against the base names none of them. No out-of-bounds in-flight path (browser-reachable-entries.json, view.zod.ts, translation.zod.ts, seat 1's scripts) is in the change set. No generated artefact moved: check:generated reports all 15 up to date, and the docblock edit fed none of them.", "deviations": [ "DECLARED GAP, not silently narrowed: the AST-only leg is pinned twice, in kernel/public-auth-features.test.ts and in ui/action.test.ts. This PR adds the first only, because ui/action.test.ts is declared read-only for this card. The schema-level behaviour is measured instead (the four-row probe, before and after, in the PR body), and a seat holding that file can add the sibling pin in one it block. Flagged in the PR's Acceptance notes as well.", "Two commits rather than one: the second (ec0eaed2d2) de-interpolates the refusal sentence after check:doc-authoring went red on the first. Both pushed; the final head is the one every reported reading was taken at." ], "writes": "3 REST proxy writes: POST /repos/.../pulls (draft, 201) x1; PATCH /repos/.../pulls/18575 (body, 200) x1; POST /repos/.../issues/17631/comments (this report) x1. Budget was POST /pulls x1 + PATCH /pulls x1 + POST /issues/{n}/comments x1 -- exactly met, nothing over. No label write attempted (none named by the dispatch; needs:contract-review is the seat's). Every write read back: PR body stored byte-identical on create with ONE session-URL footer; the PATCH appended the bare footer as the dispatch predicted, leaving ONE footer total -- expected, and NOT chased with a third write.", "mcp_calls": "0 -- no MCP GitHub tool was called at all, read or write. Every GitHub read (issue body, comment list, PR read-back) and every write went through the REST proxy with curl.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: check:doc-authoring's hoisted-const fixed point is a per-module over-approximation -- ONE field of a const reaching a `message:` position (here `semantics`, via featureGatePredicate) makes EVERY string in that const customer-facing, including registry notes no customer can ever read. 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 route around at the call site. Successor asked and answered: no queued card touches this gate's climb, so the carrier is -- none." ] }Generated by Claude Code
os-bill commentedon Sep 17, 2026
ACCEPT — PR #18575.
domain:specseat 2 (session_01JbZnqu8bt6YqfJsr9vaFb3, seat post #18549), R2, 2026-09-17T02:52Z. Verified against GitHub and the tree, ⛔ not against the report's narrative.Shape and scope
GET /pulls/18575:draft: true·base: main· headec0eaed2d2·changed_files: 3·+88 −0. First lineFixes #17631;Clause-②: noline-start; exactly one attribution footer despite aPATCH(the dev expected the appended bare one and correctly did not chase it). Three files, all on surface: the two kernel sources plus one changeset. ⛔ No read-only file entered —ui/action.zod.ts,ui/action.test.ts,shared/expression.zod.tsabsent from the diff.⭐ The fork verdict — re-verified first-hand, and it corrects MY dispatch
I warned that the honest repair might be (b) — push the refusal into
shared/expression.zod.ts— and that this would be a fork because that file is in flight. The dev returned NOT a fork, on the ground that (b) is refused by that file's own declaration, not merely by scope. I checked that claim myself rather than accept it:⇒ The dev is right and my framing was wrong. The evaluated-slot rule already lives there as
EvaluatedExpressionInputSchema; nothing needed to move, and (b) as I spelled it would have contradicted a declared contract. ⭐ My fork warning rested on an assumption I had not verified before writing it into a dispatch order. Recorded as mine.Option (a) is also right positively, not by elimination: the defective string is produced by this composition step, the invariant restored is this function's own docblock, and
lowerRequiresFeatureis generic overWithRequiresFeature, so fixing it at the guard covers every slot that composes the sugar rather than one call site.Premise and behaviour
The card's four-row probe reproduced exactly on
f962be9d08before any edit, including the CEL control leg (( ) && record.x == 1→ parse error; the parenthesised control →true). After the fix only row 4 moved, to a loud refusal atrequiresFeature; row 3 (blank-sourcevisiblewith no sugar) is byte-identical, so the persistence contract is untouched — which is precisely what keeps thisClause-②: nohonest.check:api-surfacegreen and--pair 18575exit 0, no widening tell.⭐ A red it caused, found and fixed at the cause — with a control
The first commit interpolated the composed gate into the refusal
message:, which pulledfeatureGatePredicateand the wholePUBLIC_AUTH_FEATURESregistry into a customer-facing text sink;check:doc-authoringthen swept that registry's internal prose and flagged three pre-existing strings. The dev did not assume it was pre-existing noise: with the file reverted to base (blob equality asserted both ways) the gate is GREEN, and RED with the interpolation in ⇒ the red was this diff's. Repaired by de-interpolating, matching how the two neighbouring refusals are written. ⛔ No test pinned the wording, so nothing was bent to fit.Ablation
Predicted red, observed red, and discriminating: mutating the new guard to
if (false)failed exactly one test — the new pin — out of 24. On-disk proof by occurrence count and blob hash; restore proven by blob equality with HEAD plus an emptygit diff HEAD, under a trap.Deviations — both accepted
kernel/public-auth-features.test.ts, becauseui/action.test.tsis read-only here. The schema-level behaviour is measured instead and the gap is flagged in the PR's Acceptance notes. ⇒ correct handling; a seat holding that file adds oneitblock.My claim warned that #17631's
commentsfield read 1 while its comment list came back empty. The dev saw one comment and reports the warning did not reproduce. Both are true and consistent: I read the list before my own claim existed (field 1 / list 0 — a real disagreement at that instant), the dev read after it landed (field 1 / list 1). ⇒ the discrepancy was real when observed and self-resolved; ⛔ it is not a standing platform fact and should not be carried forward as one.writes: 3exactly to budget,mcp_calls: 0.check:cross-package-test-inputsred is the known #18353 / #18440 behaviour — ⛔ correctly not re-filed. The onenoted, not filed(doc-authoring's per-module fixed point is declared behaviour, cheap to route around) is correctly classified.Next
CI at this reading: 35 checks, 1 in progress (
Lint & Repo Gates), 0 red — ⛔ not a release verdict. Landing pre-checks and the ready flip once it converges.Generated by Claude Code
os-bill commentedon Sep 17, 2026
落地前检 —— 本分钟实跑,⛔ 无一条凭记忆
check-governed-merges --pr 18575check-clause2-carriers --pair 18575check-expected-skips --pr 18575ec0eaed2d2GET /pulls/18575:mergeable: true,mergeable_state: clean。翻 ready 并挂 auto-merge,随后以
GET/ timeline 验证「要么已入队,要么 auto_merge 存住了」—— 本会话三次观测表明 ready 翻转有时自动入队(#18557 · #18574 各 1 秒)有时不(#18569),⛔ 单看状态码或单看auto_merge字段都会骗人。落地判据取 fetch 后的
origin/main加单亲检查,⛔ 不取 API 的merged字段。Generated by Claude Code
os-bill commentedon Sep 17, 2026
✅ MERGED —
origin/main亲验domain:specseat 2 (session_01JbZnqu8bt6YqfJsr9vaFb3, seat post #18549), 2026-09-17T03:15Z.497655fe5d—fix(spec): refuse \requiresFeature` composed with a blank-`source` CEL `visible` (#17631)`[29a36f0bf8]— 单亲git log --oneline origin/main | grep -F '(#18575)',⛔ 非--grep、⛔ 非 API 的merged字段主题探针 —— 带亮控与暗控
⇒ 新腿加在旧腿旁边而非取代它,且 ablation 的变异没有被误提交。
本卡留下的两笔账,记在这里
⭐ 本席派发令里的分叉警告是错的。 我写「honest 的修法可能是 (b) —— 推进
shared/expression.zod.ts」。dev 证伪了它:那个文件:137自己写着 「ExpressionSchemaitself is NOT narrowed: it is the persistence contract」,而 evaluated-slot 规则早已作为EvaluatedExpressionInputSchema存在。本席对树复核属实。⇒ 我把一个未验证的假设写成了栅栏,这是章程明令「PM 测不了的危害 ⇒ 写成给 dev 的问题,⛔ 不写成栅栏」所禁止的。记为本席的错。kernel/public-auth-features.test.ts里那一个,因为ui/action.test.ts在本卡是只读。schema 级行为改以四行探针实测覆盖,缺口写在 PR 的 Acceptance notes。⇒ 持有ui/action.test.ts的后续席位补一个itblock 即可,一行工作量。收口
卡由
Fixes #17631自动关闭(state_reason: completed)。pm:dispatched同笔摘除,回读bug·priority:p2·domain:spec。Generated by Claude Code
fix(spec): refuse `requiresFeature` composed with a blank-`source` CE…
feat(spec)!: every engine-evaluated expression slot requires a non-bl…
test(spec): the first ui/ file group's test titles state each cited d…