Skip to content

settings-manifest visible is declared ExpressionInputSchema (CEL) but evaluated by a non-CEL grammar — a CEL predicate there silently skips the save-time required gate #7169

Description

@os-zhuang

Found while measuring the premise of #7071 (the ${…} predicate spelling). Out of scope there — that card is about the SPELLING's status in the shared vocabulary; this is a producer/consumer dialect mismatch on one slot, and it survives whichever way #7071 is decided.

The mismatch

packages/spec/src/system/settings-manifest.zod.ts types both visibility slots as ExpressionInputSchema:

234:  visible: ExpressionInputSchema.optional().describe('Visibility expression'),
491:  visible: ExpressionInputSchema.optional().describe('Whole-manifest visibility'),

ExpressionInputSchema normalizes a bare string to { dialect: 'cel', source } — so the spec labels these values CEL.

The consumer is not CEL. packages/services/service-settings/src/visibility-eval.ts implements a hand-rolled JS-ish subset, documented in its own header:

orExpr   := andExpr ('||' andExpr)*
andExpr  := unary ('&&' unary)*
unary    := '!' unary | comparison
compare  := primary (('===' | '!==' | '==' | '!=') primary)?
primary  := '(' orExpr ')' | string | number | true | false | null | data.<ident>

Root is data only; no CEL stdlib, no macros, no other root.

Measured (tsx, origin/main @ 3e8e669c0)

[ok]    in-force template spelling             ${data.provider === 'openai'}  =>  true
[ok]    CEL, as ExpressionInputSchema declares data.provider == 'openai'  =>  true
[THROW] CEL with the record root               record.status == 'open'  =>  Cannot parse visibility expression "record.status == 'open'": unsupported identifier "record.status"
[THROW] CEL membership (stdlib)                data.provider in ['openai','gateway']  =>  Cannot parse visibility expression "data.provider in ['openai','gateway']": unsupported identifier "in"

Why the throw is not loud

packages/services/service-settings/src/settings-service.ts:1460:

if (typeof spec.visible !== 'undefined') {
  try {
    visible = evaluateVisibility(spec.visible, data);
    deps = referencedKeys(spec.visible);
  } catch {
    continue; // can't determine visibility — stay lenient
  }
}

continue skips the whole specifier, so the spec.required === true && empty check below it never runs. An author who writes the dialect the spec declares (CEL) therefore silently disables the save-time required gate for that field — a half-filled provider form saves clean. Fail-open, no diagnostic anywhere.

Why nothing catches it

No lint rule walks settings manifests — grep -rln "settingsManifest|settings-manifest|SettingsManifest" packages/lint/src/ is empty. validate-expressions.ts walks objects / flows / actions / sharingRules / hooks; validate-visibility-predicates.ts walks views / pages. Neither reaches this slot, which is why all 94 in-repo ${…} strings live here and nowhere else.

Shape of the fix (not prescribing — this needs a spec ruling)

Options, roughly: give the slot its own declared input schema naming the grammar it really has; or make the evaluator CEL (@objectstack/formula) and migrate the 94 manifest strings; or keep the grammar and make the parse failure loud instead of lenient. The third is separable and is the only one that closes the fail-open on its own.

Related: #5149 is the same fail-open SHAPE one layer over (view/page visibleWhen), closed there by the visibility-predicate-syntax gate (#6253). This slot never got an equivalent.

Filed unassigned for triage.


Generated by Claude Code

Activity

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Triage: needs-user-decision appended (filer's domain:services left in place).

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling (2026-08-10, directed in session session_01BPWqbmEFU8gJepBJTHESXd): fail closed now, align later.

    Interim stop-the-bleed: save-time validation must refuse a visible predicate the actual evaluator cannot parse — a silent skip of the required gate is the worst failure class this repo names. Then close the declaration/implementation gap: either wire the declared CEL grammar into evaluation, or narrow the declared type to the grammar actually evaluated. The implementer measures which direction breaks fewer stored manifests and reports the count in the PR before choosing.

    needs-user-decision → pm:queue.


    Generated by Claude Code

  3. self-assigned this
    on Aug 10, 2026
  4. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    Claim: PM loop round 3 (domain:services seat #6021)
    Session: session_015fkdTyGmMD5s8ZtEifvuGy
    Branch: claude/issue-7169-visible-fail-closed
    Worktree: objectstack-issue-7169
    Domain: domain:services
    File surface: packages/services/service-settings/src/settings-service.ts, visibility-eval.ts, + tests in the same package. ⛔ NOT packages/spec/** — if the alignment measurement favors narrowing the DECLARED type (settings-manifest.zod.ts), that half stops and reports for the spec seat. (Stop on breach; explain in the report.)
    Container & model: M, mode:subagent, model: opus
    Serial constraints cleared: no in-flight claim touches service-settings; maintainer ruling recorded on this card 05:09Z supersedes the needs-user-decision hold (label already flipped back to pm:queue by triage). Anchors re-verified by triage @ 5d24f4b.


    Generated by Claude Code

  5. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    ACCEPT (services half) — PR #7310 marked ready and queueing. Card stays OPEN; claim released; now blocked on the spec half.

    Blocked-by: #7327

    Reviewed by the domain:services seat (#6021, session session_015fkdTyGmMD5s8ZtEifvuGy) against GitHub rather than the report: 8 files, all inside packages/services/service-settings (zero packages/spec touches — the boundary held); Part of #7169 on the first line, correctly, since the ruling's second half is outstanding. 25/25 check runs success/skipped (ESLint 06:27:02Z, TypeScript Type Check 06:38:36Z, read by job conclusion).

    What shipped (the ruling's first half, executed as ruled):

    • Fail-closed: an unparseable visible predicate now refuses the save — SETTINGS_VALIDATION/400, one FieldError per offending specifier (invalid_value, parse reason in message, predicate under constraint.visible), matching this surface's existing refusal shape, no new error code. Refusal is deliberately un-touch-gated (the console posts only dirty keys — a touch gate would never fire on the measured incident; pinned), and all-null resets stay as the escape hatch.
    • Load-bearing grammar extension (>, >=, <, <=, console JS semantics): the repo's own auth manifest ships data.lockout_threshold > 0, which the old grammar refused — measured on main, lockout_duration_minutes accepted -5/99999 against its declared min:1 max:1440. Reverse verification proves the extension is prerequisite, not scope creep: failing closed WITHOUT it turns 5 pre-existing unrelated auth tests red (the whole auth namespace becomes unwritable).
    • Render time measured: evaluateVisibility has no render-time call site; the console uses its own client evaluator — nothing bricks, the change is visible only at Save as a named 400.
    • The ruling's measurement, reported before the choice: CEL-into-evaluation breaks 93/94; narrow-the-declaration breaks 1/94 → 0 after the extension. Recommendation (b), consistent with spec:动作谓词的 ${…} 模板拼法要不要在发布期规范化/拒绝(objectui#3871 裁决的 B 案半边) #7071's same-day ruling.

    The alignment half is transferred: #7327 (filed by this seat, pm:queue, domain:* to triage — expected spec seat; it edits settings-manifest.zod.ts:234/:491). This card carries pm:blocked until #7327 lands; the unlock sweep re-verifies the file face on the merged ref and closes this card when both halves are in.


    Generated by Claude Code

  6. removed their assignment
    on Aug 10, 2026
  7. os-help commented on Aug 10, 2026

    @os-help
    Collaborator

    Unblocked, then verified dead — closing as completed. Blocked-by: #7327 cleared (PR #7387, 42cc219e5), and per the unlock discipline the card's own file surface was re-verified on the merged ref rather than inferred from the upstream closing. Both halves of this finding are fixed on origin/main.

    Half 1 — the declaration no longer says CEL. packages/spec/src/system/settings-manifest.zod.ts now carries a purpose-built SettingsVisibilityInputSchema, and says so in its own words:

    • :189 — // visible — the settings visibility grammar (NOT CEL)
    • :205 — 'A settings \visible` predicate is not CEL: it is read by the save-time evaluator in …'`
    • :347 — \visible` on a settings manifest is not CEL, and this schema says so.`
    • :453 / :715 — both slots (the specifier-level and the whole-manifest one) are SettingsVisibilityInputSchema.optional(), no longer ExpressionInputSchema.

    That is option 1 from this card's "shape of the fix" — give the slot a declared input schema naming the grammar it really has — plus a rejection path at :392 that refuses an unsupported predicate with a prescription instead of accepting it and letting the evaluator choke later.

    Half 2 — the fail-open is gone. The consumer this card was actually about, packages/services/service-settings/src/settings-service.ts:1504-1518, no longer does catch { continue }. It now fails closed and names this card in the code:

    } catch (err) {
      // Fail CLOSED (#7169) — see §Unparseable `visible` in the doc comment
      // above for the ruling, the asymmetry with the lenient branches, and
      // why there is no TOUCH gate on this one.
      …
      errors.push({ field: key, /* invalid_value — ADR-0114's declared slot */ … })
    

    So the exact defect — a parse failure skipping the specifier and taking the save-time required check with it, silently — cannot happen: the failure is now a reported error rather than a continue.

    Nothing remains in this lane. The measured evidence in the body (the four probe results, the missing lint coverage, the 94 ${…} strings) described the pre-fix world; with the grammar declared and the parse failure loud, there is no domain:services work left here. Closing as completed rather than not-planned, since the outcome is the fix this card asked for, delivered elsewhere.

    ⚠️ One attribution caveat, stated rather than smoothed over: git log --grep="#7169" attributes only the spec half (#7327/#7387). Line archaeology (git log -S "Fail CLOSED (#7169)") points at squash commit 4d9430822, whose subject names #6719/#7364 — a broader envelope PR that evidently carried the services-side change without naming this issue in its title. The state is what this verdict rests on (the code quoted above, read at origin/main), not the attribution.

    Veto window as usual: if either half is judged incomplete, reopen and this seat will re-scope rather than re-argue.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions