Skip to content

fix(lint)!: refuse an app nav visible that does not parse, and page variables that are duplicated, unnamed or wrong-typed - #28723

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-oqpu4s
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-oqpu4s

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28663

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: none — no file under skills/** teaches a navigation entry's visible grammar or the page variables list; the refusals carry their own fix and os explain entries.

维护者速读

  • 改了什么:应用导航项的 visible 写成解析不了的 CEL(如 'org_owner' in),现在和页面组件的 visibleWhen 一样被拒:保存、草稿诊断、发布、os validate 四处同一条结论。页面 variables 新增一条规则:重名、空名、默认值类型和声明类型不符(number 配 "abc")都拒。
  • 为什么改:以前这两类都能保存、草稿显示 valid、照常发布;导航项到了控制台解析失败,对所有人隐藏,作者毫不知情。变量重名时控制台只留最后一个,默认值类型错则所有读它的谓词比的都是错类型的值。
  • 风险与回滚:低到中。只收紧写入口,已存的应用和页面照常加载、照常服务,只有下次保存才被判。仓内语料(examples 和平台包)里 1 个导航 visible、5 个页面变量,零命中。回滚即撤回 @objectstack/lint 的这次改动。
  • 先看:packages/lint/src/validate-visibility-predicates.ts(新增的导航遍历和 syntaxFinding);packages/lint/src/validate-page-variables.ts(新规则);packages/lint/src/authoring-rules.ts(家族加 app、新规则注册)。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/runtime-gate.app-nav-visible-predicate-writes.test.ts src/runtime-gate.page-variable-writes.test.ts,预期 31/31 通过。
  • 需要你做的:无,席位落地。

What changes

  • packages/lint/src/validate-visibility-predicates.ts: validateVisibilityPredicates now also walks apps[].navigation, apps[].areas[].navigation and every entry's children, and runs the existing visibility-predicate-syntax judgment (celRefusal, formula's parseCelToAstWithReason, no second parser) on each entry's visible. A bare string and a { dialect: 'cel' } envelope are judged (the console evaluates both on the CEL engine since console: a bare-string CEL predicate (nav visible, page visibleWhen) runs on the legacy evaluator and fails, though the spec defines a bare string as CEL shorthand #27457); an envelope of another dialect is the console's legacy reading and a boolean is no predicate, so neither is judged. The finding is located at the key (apps[0].navigation[0].visible). The syntax finding is built by one helper, syntaxFinding, for page/view elements and nav entries alike; only two words move per surface: the consequence clause (a nav gate falls closed, so "the entry is hidden from every viewer") and the remedy example ('org_admin' in current_user.positions, the spec's own nav example, since a nav entry binds no record). Page and view wording is byte-identical (live probe below shows the same page message on base and head).
  • Only the syntax verdict runs on a nav entry, by measurement: the family's reference verdicts judge roots a view or page binds (record, current_user, page), and a nav entry binds current_user, user, ctx.user, os.user, features (plus data beside a dataSource), so features.cloudManaged != true would be refused as an unbound root.
  • packages/lint/src/validate-page-variables.ts (new) with three ids: page-variable-name-duplicate (at the second entry's name, naming the first index), page-variable-name-empty (empty or blank), page-variable-default-type (a defaultValue of another kind than type: string for string/record_id, finite number, boolean, plain object, array; null admitted for any type; no type = the schema's default string). No packages/spec/** edit: the rule needs no shape change.
  • packages/lint/src/authoring-rules.ts: the visibility family (validateVisibilityPredicates, validatePredicatePathRefs) declares runtimeTypes: ['view', 'page', 'app'] (the family stays on one type set, as authoring-rule-wiring.test.ts pins; the path rule judges nothing on an app). validatePageVariables is registered gating, CLI_AND_RUNTIME, runtimeTypes: ['page'], after validatePrintPageBlocks. No gate change in metadata-protocol: app already maps to apps, and save, draft _diagnostics and publish all read judgeRuntimeAuthoringRules.
  • packages/lint/src/index.ts: exports the rule and its three id constants. packages/lint/src/rule-explanations.ts: three os explain entries, and a paragraph on visibility-predicate-syntax for nav entries.
  • content/docs/** (4 CLI transcripts): author-time rules (57) → (58), the count the registry derives with the new rule (check:docs-transcript-drift).
  • .changeset/28663-nav-visible-syntax-page-variables.md: @objectstack/lint minor, BREAKING banner, both declaration lines, ADR-0087 not-required (no-migration-prescription).

Migration note. Nav visible values in the shipped corpus: 1 (examples/app-crm current_user.can('crm_opportunity', 'read')), 0 refused. Page variables: 5 on 2 showcase pages, 0 refused. os validate on example-crm, example-showcase, example-todo at head: exit 0, 0 findings of the new ids. Stored apps and pages keep loading and serving; only the next save of one with a finding is refused.

Tests

  • packages/lint/src/runtime-gate.app-nav-visible-predicate-writes.test.ts (new): the card's visible: "'org_owner' in" top-level, inside an area and inside a group's children is refused at the app door at the key, and os validate (runAuthoringRules('validate')) gives the same finding; the { dialect: 'cel' } envelope too. Controls admitted at both: 'org_owner' in current_user.positions (string and envelope), features.cloudManaged != true, current_user.can(...), user.isPlatformAdmin == true, a template envelope, true.
  • packages/lint/src/runtime-gate.page-variable-writes.test.ts (new): duplicate, empty, blank, number+"abc", boolean/array/object/record_id mismatches and an untyped number default, each refused at the page door with its path, with door/os validate parity; four declaration sets admitted (the showcase's two included, null defaults, []); three faults on one page are three findings.
  • packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts: five new rows in the Studio/metadata: a draft the editors, the draft _diagnostics and the Problems tab all call valid is refused only at publish — and one such draft rolls back the whole package release #26707 table (nav top-level, nav in an area, the three variable faults): the draft saves, reads valid: false with the publish's own words, and publish refuses 422 INVALID_METADATA; clean nav + variables read valid: true and publish; paths are item-relative (areas.0.navigation.0.visible, variables.0.defaultValue).
  • packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts: the active saveMetaItem refuses the app (door finding = build finding key by key, nothing stored) and each variable fault; clean controls land.
  • Pins updated for the app door's new rule list: validate-security-posture.runtime-surface.test.ts (two), validate-predicate-path-refs.test.ts (one), and the page door's ordered list in runtime-gate.page-visibility-predicate-writes.test.ts.
Red on base 2678600, green on head
base: Test Files 2 failed (2) · Tests 20 failed | 11 passed (31)   (the 11 = the controls)
head: Test Files 2 passed (2) · Tests 31 passed (31)
Ablations (scripts/ablation-replace.mjs, lint rebuilt, ablation-dist-preflight both legs, restore = blob equals HEAD)
navA  (nav syntax arm off)            lint 7 failed | 24 passed · protocol 2 failed
dupB  (duplicate check off)           lint 3 failed | 13 passed · protocol 2 failed
emptyC (empty-name check off)         lint 4 failed | 12 passed · protocol 2 failed
typeD (default-type check off)        lint 8 failed |  8 passed · protocol 3 failed
every leg: mutate x0 -> x1, preflight exit 0, restore ok, git diff HEAD empty, rebuild, preflight --absent exit 0

Record: pm-orders/server/evidence/28663/ablations.txt.

Live probe: card steps 2 and 4 on a stock showcase boot, base vs head

Commands: pnpm --filter @objectstack/example-showcase dev -- --fresh -p PORT; sign in POST /api/v1/auth/sign-in/email as the seeded admin; POST /api/v1/packages a scratch writable package; then PUT /api/v1/meta/app/qa_nav_app?mode=draft&package=PKG (nav visible: "'org_owner' in"), GET …?state=draft&package=PKG, POST /api/v1/packages/PKG/publish-drafts; the same for a page with visibleWhen: "record.status ==" and a page with variables [number "abc", "", duplicate]; and an active PUT /api/v1/meta/app/qa_nav_app2?package=PKG. Base = origin/main lint sources over the four edited files, lint rebuilt.

base  step 2  PUT draft 200 · _diagnostics {"valid":true} · publish-drafts "outcome":"published"
base  active PUT app 200 (state=active)
base  step 4  page visibleWhen: _diagnostics valid:false visibility-predicate-syntax · publish refused INVALID_METADATA
base  variables: _diagnostics {"valid":true}
head  step 2  PUT draft 200 · _diagnostics valid:false [navigation.0.visible visibility-predicate-syntax "…so it never evaluates and the entry is hidden from every viewer"] · publish-drafts "outcome":"refused" INVALID_METADATA apps[0].navigation[0].visible
head  active PUT app 422 INVALID_METADATA
head  step 4  page visibleWhen: same message as base ("…the element always renders")
head  variables: _diagnostics valid:false [variables.0.defaultValue page-variable-default-type, variables.1.name page-variable-name-empty, variables.2.name page-variable-name-duplicate]
os validate (app-crm, nav visible planted "'org_owner' in"): base exit 0 "Validation passed" · head exit 1 "rule: visibility-predicate-syntax at apps[0].navigation[1].children[0].visible"

Record: pm-orders/server/evidence/28663/live-probe.txt (with probe.sh).

Acceptance notes

Out of scope by the order, each measured at head on the same boot and still admitted (_diagnostics {"valid":true}), not fixed here:

  • A page variable source naming a component the page does not hold (source: "no_such_component"): admitted.
  • A visibleWhen reading an undeclared page.nosuch: admitted.
  • Seen beside the work: a nav visible that is flawless CEL but over the parse budget (a 90-clause || chain) is admitted at the draft read; the page-side twin is visibility-predicate-over-budget. Not wired here because the ruling names the syntax judgment only.

Also noted: when a nav visible uses an operator from another language (===), the remedy's example comes from the shared operator table and is record-rooted (record.country == 'USA'). The operator correction itself is right on any surface.

Declaration: the claim's line reads Clause-②: no; this body and the changeset carry Clause-②: no (narrowing), because check-changeset-no-major.mjs refuses Metadata-protocol: narrowed beside a line without the (narrowing) arm. The order's Declarations section names it a narrowing.

Verification

Derived gate loop (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, 89 families with the docs edit) at bb33ace7: 89 exit 0. dispatch-gates --ran: 89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED. Record: pm-orders/server/28663-gates-ran.txt.

  • An earlier loop at 9fcdbbc4 read check:docs-transcript-drift exit 1 (the four transcripts printed 57 rules; the registry derives 58 with the new rule), fixed in bb33ace7; and check-plugin-teardown-shape --self-test exit 3 (shallow clone), exit 0 after git fetch --depth=1 origin 621a4876.
  • check:type-check-debt under the lock: check-type-check-coverage --re-measure: OK — 0 raw tsc error(s).
  • Package suites at 9fcdbbc4 (merge of origin/main; bb33ace7 adds only the docs transcripts): @objectstack/lint Test Files 186 passed, Tests 7749 passed; @objectstack/metadata-protocol Test Files 291 passed / 4 skipped, Tests 21963 passed / 26 skipped; both typecheck exit 0. eslint packages/lint packages/metadata-protocol --no-inline-config exit 0.
  • Dogfood battery (the (narrowing) arm) at bb33ace7, closure built, pnpm --filter @objectstack/dogfood test under the lock: VERDICT command-exit 0 · Test Files 369 passed / 3 skipped (372) · Tests 2981 passed / 28 skipped (3009).
  • CI owns the repo-wide lint and every family outside the derived set.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5nHMwmgiBBzV1TsunS


Generated by Claude Code

…riables that are duplicated, unnamed or wrong-typed

Co-Authored-By: Claude <noreply@anthropic.com>
…usals at save, draft read and publish

Co-Authored-By: Claude <noreply@anthropic.com>
…able refusals

Co-Authored-By: Claude <noreply@anthropic.com>
…he page-variables rule

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 32 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))
  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), runtimeTypes (symbol, a field of const object AUTHORING_RULES))
  • content/docs/frontend/plugins/plugin-detail.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))
  • content/docs/kernel/contracts/storage-service.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))
  • content/docs/kernel/runtime-services/audit-service.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))
  • content/docs/permissions/permission-metadata.mdx (via org_admin (literal, a string literal in example), org_owner (literal, a string literal in a comment in AUTHORING_RULES))
  • content/docs/permissions/positions.mdx (via org_admin (literal, a string literal in example), org_owner (literal, a string literal in a comment in AUTHORING_RULES))
  • content/docs/permissions/record-view-auditing.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))
  • content/docs/permissions/sharing-rules.mdx (via org_admin (literal, a string literal in example), org_owner (literal, a string literal in a comment in AUTHORING_RULES))
  • content/docs/protocol/kernel/http-protocol.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))
  • content/docs/ui/audience-based-interfaces.mdx (via org_admin (literal, a string literal in example))
  • content/docs/ui/pages.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))
  • content/docs/releases/v17/17-4.mdx (via org_admin (literal, a string literal in example))
  • content/docs/releases/v17/17-5.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), org_admin (literal, a string literal in example), org_owner (literal, a string literal in a comment in AUTHORING_RULES), record_id (literal, a string literal in defaultMatches))
  • content/docs/releases/v17/17-6.mdx (via record_id (symbol, a field of const object EMPTY_VALUE; a field of const object TYPE_NOUN), record_id (literal, a string literal in defaultMatches))
  • content/docs/releases/v18/18-0.mdx (via org_owner (literal, a string literal in a comment in AUTHORING_RULES))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: defaultValue (literal, 41 pages)
  • 16 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d62d973734d1e0af3f183340a5674f320b7c0858 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9c56ff111a777edb73bceb1f01bdc38a9791609a — the merge of head bb33ace7a2c8a9d1443d088873c8d9b9bbb60889 into base d62d973734d1e0af3f183340a5674f320b7c0858, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9c56ff111a777edb73bceb1f01bdc38a9791609a && git checkout 9c56ff111a777edb73bceb1f01bdc38a9791609a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62d973734d1e0af3f183340a5674f320b7c0858 bb33ace7a2c8a9d1443d088873c8d9b9bbb60889 && git checkout -B drift-repro d62d973734d1e0af3f183340a5674f320b7c0858 && git merge --no-ff bb33ace7a2c8a9d1443d088873c8d9b9bbb60889

node scripts/docs-audit/affected-docs.mjs --json d62d973734d1e0af3f183340a5674f320b7c0858

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d62d973734d1e0af3f183340a5674f320b7c0858 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bb33ace7a2c8a9d1443d088873c8d9b9bbb60889
Local-runs: none
Scope: packages/lint/src/validate-visibility-predicates.ts, packages/lint/src/validate-page-variables.ts, packages/lint/src/authoring-rules.ts, packages/lint/src/index.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/runtime-gate.app-nav-visible-predicate-writes.test.ts, packages/lint/src/runtime-gate.page-variable-writes.test.ts, packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts, packages/lint/src/validate-predicate-path-refs.test.ts, packages/lint/src/validate-security-posture.runtime-surface.test.ts, packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts, packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts + packages/spec/src/ui/app.zod.ts, packages/spec/src/ui/page.zod.ts, packages/spec/src/shared/expression.zod.ts, packages/spec/src/shared/metadata-collection.zod.ts, packages/spec/src/ui/component.zod.ts, packages/lint/src/runtime-gate.ts, packages/lint/src/collection-entries.ts, packages/lint/src/authoring-rule-wiring.test.ts, packages/lint/src/rule-explanations.test.ts, packages/metadata-protocol/src/protocol-authoring-gate.ts, packages/metadata-protocol/src/runtime-authoring-gate.ts, packages/cli/src/commands/validate.ts, ui/packages/core/src/evaluator/ExpressionEvaluator.ts, ui/packages/app-shell/src/providers/ExpressionProvider.tsx, ui/packages/app-shell/src/hooks/useNavMemberGates.ts, ui/packages/react/src/hooks/usePageVariables.tsx, ui/packages/types/src/zod/app.zod.ts, examples/app-crm/src/apps/crm.app.ts, examples/app-showcase/src/ui/pages/page-variables.page.ts, examples/app-showcase/src/ui/pages/contact-form.page.ts. Not reviewed (prose faces): the changeset and the four content/docs transcripts, read for ② only.

Check-runs on the head at review time: 62 runs, 0 failed, 0 cancelled, 27 still in progress (Test Core 1-8, Test UI 1-16, Build Core, Build Docs, Live E2E UI, the five Type Check jobs, the six Lint jobs still running). Completed green: Check Changeset, PR-body guards, Governed Surface Queue Guard, Spec property liveness, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Dogfood Regression Gate, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Build UI Deps, Plan Test Core shards. The pending families are the queue guard's to wait on; nothing in this record re-runs or pre-judges them.

① Derived judgments

  1. App writes: a navigation[].visible, areas[].navigation[].visible or any children[].visible that is a bare string or a { dialect: 'cel', source } envelope and does not parse on formula's parseCelToAstWithReason is now refused with visibility-predicate-syntax at the key path. RIGHT. The spec declares the slot as EvaluatedExpressionInputSchema (app.zod.ts line 371), whose string arm is by definition { dialect: 'cel', source } (expression.zod.ts lines 269-274), and the console reads both shapes on the CEL engine (ExpressionEvaluator.ts evaluateCondition, the console: a bare-string CEL predicate (nav visible, page visibleWhen) runs on the legacy evaluator and fails, though the spec defines a bare string as CEL shorthand #27457 branch) and WITHHOLDS the entry on a fault (ExpressionProvider.tsx evaluateNavigationVisibility, throwOnError then withhold). The narrowing makes the spec's own refusal real at the author; nothing the console could ever render is taken away.
  2. App writes: a ${...} template bare string on a nav visible is now refused too, since it is not CEL. RIGHT, named as the one console-tolerated shape the door now closes: the console still routes a ${ string to its legacy path, but the spec never declared that reading (bare string = CEL), the page visibleWhen door has refused the same spelling since view/page 可见性谓词的 CEL **语法**在构建期无人校验 —— country === "USA" 这类写法零诊断、运行时静默 fail-open #6253 with the same celRefusal, and the corpus (examples, platform packages, docs, skills) carries no ${ nav visible. Parity, not a new refusal class.
  3. App writes: a boolean visible, an envelope of dialect template or cron, and an envelope without a string source are not judged. RIGHT. The spec admits every dialect envelope on this slot; the console evaluates a non-cel envelope on its legacy path; an envelope without source is already the spec parse's refusal (EVALUATED_EXPRESSION_SOURCE_REQUIRED). The rule narrows nothing the spec leaves open.
  4. App writes: only the syntax verdict runs on a nav entry; the family's root-binding verdicts (visibility-bare-identifier, visibility-root-mislayered, visibility-predicate-unknown-function, has() shape) do not. RIGHT by measurement: the shell binds current_user, user, ctx.user, os.user, features and data beside a dataSource (ExpressionProvider.tsx line 213; useNavMemberGates.ts navEntryVisible), not record or page, so the page-rooted checks would refuse features.cloudManaged != true and current_user.can('crm_opportunity', 'read'), both of which the console evaluates. The PR's control list pins exactly these.
  5. validatePredicatePathRefs declares app beside its sibling and judges nothing on an app write. RIGHT: authoring-rule-wiring.test.ts pins the family on one runtimeTypes set (lines 469-497), and the rule walks stack.views only; the pin in validate-predicate-path-refs.test.ts is updated with the reason.
  6. The app door's rulesRun disclosure grows from [validateSecurityRoleWord] to [validateVisibilityPredicates, validatePredicatePathRefs, validateSecurityRoleWord]; the two pins in validate-security-posture.runtime-surface.test.ts are updated. RIGHT, additive on a 422 or 2xx wire detail; no other test at head enumerates the app door's list (grepped).
  7. Page writes: page-variable-name-duplicate (located at the later entry, naming the first index), page-variable-name-empty (empty or blank), page-variable-default-type (a present, non-null defaultValue whose JS kind is not the declared type's: string for string and record_id, finite number, boolean, plain object, array; an absent type is judged as string). RIGHT against the spec and the consumer: PageVariableSchema (page.zod.ts lines 895-915) declares type with .default('string') and defaultValue: z.unknown(), so the untyped-number refusal follows the declared default, not the rule's invention; the console's initializeVariables (usePageVariables.tsx lines 40-69) seeds defaultValue as written when present (null included, so admitting null matches what the consumer does), falls to '' for string, record_id and anything else, and keeps the LAST of two same-named declarations. record_id as a string is consistent with the spec's own typing of record ids on UI surfaces (recordId: z.string(), component.zod.ts line 2524). Types outside the enum are the parse's refusal and are left alone. The two showcase pages (5 variables) are admitted by the control set.
  8. Public surface of @objectstack/lint: validatePageVariables, the three id constants and PageVariableFinding are exported from index.ts; three os explain entries and a nav paragraph on visibility-predicate-syntax are added. RIGHT; the explanation entries meet the registry's own pins (covers under 60 chars without a period, paragraphs naming no issue number).
  9. os validate / os build / os lint rule count 57 to 58 in the four CLI transcripts. RIGHT (the registry derives it; check:docs-transcript-drift is the gate).
  10. One implementation at every surface, verified in the live tree: the metadata save door (judgeRuntimeAuthoringRules to evaluateRuntimeAuthoringGate to runRuntimeAuthoringRules, which filters AUTHORING_RULES by runtimeTypes and builds the snapshot with TYPE_TO_STACK_KEY.app = 'apps', page = 'pages'), the draft _diagnostics (draftPublishVerdict calls the same judge with state: 'active'), the publish (assertRuntimeAuthoringRules is the same judge plus the throw) and the CLI (runAuthoringRules('validate') over normalizeStackInput, which coerces map collections to arrays and leaves nav visible and page variables as authored). No gate source in metadata-protocol moves; the protocol tests pin the draft read, the publish 422 and the active save. Nothing stored keeps its stored shape changed: a stored app or page keeps loading; only its next save is judged.

② Semver level

.changeset/28663-nav-visible-syntax-page-variables.md: @objectstack/lint minor, BREAKING banner, Clause-②: no (narrowing), Metadata-protocol: narrowed, Skills-docs: none with its reason, and one ADR-0087 marker not-required (no-migration-prescription) with the no-conversion argument. Matches what the diff publishes: a value the author could write is now refused at every door (narrowed), on the one package the diff moves (metadata-protocol changes are tests only, which publish nothing). The (narrowing) arm is the BREAKING one by AGENTS.md's declaration rule, and the kind takes at least minor. The PR body carries the same two lines. Check Changeset and the PR-body guards are green on the head. Clause-②: no (narrowing) is right, and the claim comment's bare no is the seat's own line to amend (③ item 1).

③ Boundary flags

open_questions: none declared.

Deviations, each answered:

  1. Claim line Clause-②: no versus PR body and changeset Clause-②: no (narrowing). Answered: the PR body and changeset are the records the gates read and they are right; check-changeset-no-major.mjs refuses narrowed beside a line without the arm. The seat amends its claim comment; no PR change.
  2. File surface widened to four content/docs/** CLI transcripts and four existing lint test pins. Answered: the transcripts are the mechanical rule-count line check:docs-transcript-drift enforces, not teaching prose; the four pins are the app and page doors' rule lists, which any registry change must re-pin. Both inside the ruling's intent; nothing under packages/spec/**, packages/formula/** or ui/** is touched.

Out-of-scope findings the dev reported, each escalated rather than fixed here:
3. A page variable source naming a component the page does not hold is still admitted. The card's item 2 lists it, its Expected line does not, and the ruling names uniqueness, name and default-versus-type only. Escalated: the seat files it (class a, dedupe words given in the report).
4. A visibleWhen reading an undeclared page.X is still admitted. Same disposition; the seat files it.
5. A nav visible that is flawless CEL over the parse budget is still admitted. See ④ S1; the seat files it.
6. The operator-table hint on a nav entry carries a record-rooted example. See ④ S2.

④ Design judgment

The narrowing, read against the published spec on main and the consumer that evaluates it: the spec already says a nav visible is CEL and an evaluated slot must be something the engine runs; the console's navigation gate falls closed on a fault. A predicate that cannot parse therefore never worked for any viewer, and refusing it at the author is the spec's own refusal made real, not a capability withdrawn. The page-variable rule judges a list the spec types entry-by-entry and never judged as a list; each verdict mirrors what usePageVariables does with the declaration (last wins, unreachable name, wrong-kind seed), so no declared variable shape that the console honours is refused. Attempts to break it: every shape the console evaluates on a nav entry (current_user.can(...), features.*, user.isPlatformAdmin == true, the in membership test, the cel envelope, a boolean, a template envelope) is admitted at both doors by the PR's controls and by reading celRefusal (blank source answers null, bounds answers a kind the nav walker drops, only syntax fires); the single nav visible in the shipped corpus (examples/app-crm) and the five showcase variables pass; map-shaped apps collections resolve through collectionEntries with honest paths; a non-record nav entry or a non-array navigation/variables is skipped, never thrown on. The differential at the door is clean because apps and pages are not context collections, so the written item's own findings are the only ones that can appear.

Findings:

  • S1 (should-fix, follow-up card): a nav visible that is valid CEL but over DEFAULT_LIMITS is admitted at every door, while the console's engine refuses it at parse under the same limits and the nav gate withholds the entry, which is the card's silent-disappearance shape by another route. celRefusal already returns kind: 'bounds'; walkNavPredicates drops it, so the fix is a second arm with the existing visibility-predicate-over-budget id and the nav consequence clause. Not a must-fix here: the ruling names the syntax judgment alone and the dev reported the gap for filing.
  • S2 (should-fix, cosmetic): when a nav entry uses an other-language operator (===), the hint's example comes from the shared operator table and is record-rooted; the SyntaxSurface.example is read only on the no-token branch. The operator correction itself is right; a per-surface example on the token branch would finish the job.
  • S3 (should-fix, by filing): the two page-variable faults the card observed and the ruling left out (unknown source component, undeclared page.X read) stay admitted; the seat files them as reported.
  • Must-fix: none.

Implemented-by: claude/project-thread-oqpu4s
Reviewed-by: session_01FEkQ5oc56xChD6Wrc4cGSt

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

席位核对

ACCEPT · domain:server seat 5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt · 2026-11-01T15:51Z · head bb33ace7 · #28663

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

席位意见:与 diff 相符。导航 visible 写成解析不了的 CEL 时,保存、草稿诊断、发布和 os validate 都会拒绝并指出位置(以前保存成功,导航项对所有人静默消失);页面变量重名、空名、默认值与类型不符同样在这四处被拒。现有示例全部通过。

  1. Shape: draft against main, Fixes #28663, @objectstack/lint minor changeset with the BREAKING banner, Clause-②: no (narrowing), Metadata-protocol: narrowed, Skills-docs: none with its reason, ADR-0087 marker. check-governed-merges --pr 28723: NOT governed, +978 / -28. git merge-tree against main: clean. Landing tier S.
  2. Against the order: one syntax verdict on nav visible (bare string and cel envelope) through the existing visibility-predicate-syntax rule; the page-rooted root checks deliberately not run on nav, since the shell binds current_user / features / data there; a new validate-page-variables.ts with three ids. One implementation at save, draft _diagnostics, publish and os validate; no gate source in metadata-protocol moves.
  3. Accepted deviations: four content/docs/** CLI transcripts (rule count 57 → 58, the line check:docs-transcript-drift enforces) and four existing lint test pins (the app and page doors' rule lists). The claim's Clause-②: no is the claim template's line; the PR body and changeset carry the (narrowing) arm the gates read.
  4. Gates: ## Contract review at CONTRACT_REVIEW_TIER on this head: PASS, comment 6560166888, no must-fix. Check-runs on this head: 57 success, 8 skipped, 0 failed (Test Core 1-8, Test UI 1-16, Type Check, Lint and Check Changeset green).
  5. Out of scope: filed authoring gate: a page variable whose source names a component the page does not hold, and a visibleWhen reading an undeclared page variable, both pass save and draft diagnostics #28725 (page-variable source naming no component, visibleWhen reading an undeclared page.X; review S3) and authoring gate: an app navigation visible over the CEL size budget passes save and draft diagnostics, where a page visibleWhen of the same size is refused #28726 (nav visible over the CEL parse budget; review S1), both pm:blocked on authoring gate: an app navigation visible with a CEL syntax error passes save, draft diagnostics and publish (a page visibleWhen with the same fault is refused), and page variables publish with duplicate, empty or wrong-typed declarations #28663 until this lands. Review S2 (the operator-table hint's record-rooted example on a nav entry) is cosmetic and not filed: the operator correction itself is right ⇒ Acceptance notes.
  6. Landing: pr_ready + automerge_enable through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 1, 2026 15:53
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 1, 2026 15:53
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit 256c86b Nov 1, 2026
66 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-oqpu4s branch November 1, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant