Skip to content

docs(skills): gate the usePermissions example on can(), a boolean, and mark its fence (objectui#9671) - #9994

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-9671-auth-permissions-can-boolean-gate
Sep 20, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-9671-auth-permissions-can-boolean-gate

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9671

Clause-②: yes

Draft against main. skills/** is a governed surface (Tier H): this PR stays draft until an authorized approval, and the seat lands it. Dev run under the domain:skills seat (objectstack#7623), session https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2.

What changed

One file: skills/objectui/guides/auth-permissions.md, the usePermissions hook example under the heading "usePermissions hook".

  • The two button gates rest on can('contacts', 'update') / can('contacts', 'delete') — a boolean, and the one spelling the guide's neighbouring paragraph ("Publish can(...)") already publishes. check(...) is gone from the example: it answers a PermissionCheckResult object, an object is truthy, and so gated on it both buttons rendered for a denied user.
  • Each gate is annotated : boolean. That annotation is the tripwire: a check(...) put back in that position no longer compiles (TS2322), so the next instance of this defect class goes red in the gate instead of shipping (ablation leg A below).
  • The fence carries the os:check HTML-comment marker on the line directly above it and is tagged tsx, so scripts/check-skill-examples.mjs compiles it against the built dist from now on. To compile it: Button is imported from @object-ui/components, and contact is typed inline as an object with an optional numeric salary. checkField(...) is untouched (it already answers a boolean).
  • The contact argument is dropped from the gates — see the API-shape decision.

API-shape decision: can(object, action), record dropped

can takes no record; the old example passed contact to check. Measured against the BUILT packages/permissions/dist/index.js, with a throwing Proxy handed in as record (any get / has / ownKeys / descriptor read on it throws and is counted), over the guide's own PermissionProvider config:

role action can() (no record) check(..., record).allowed agree
admin update true true yes
admin delete true true yes
viewer update false false yes
viewer delete false false yes
owner update true true yes
owner delete true true yes
  • record property accesses observed: 0 — evaluatePermission (packages/permissions/src/evaluator.ts) never reads a field of the record. Its only use of record is presence: when a record is passed AND the role carries rowPermissions, the role's grant additionally requires some row rule's actions to list the action. evaluateCondition in the same file has no caller on the check path (its callers are tests and other packages' own evaluators).
  • On the guide's own config the two spellings agree on every (role, action) above, so dropping the argument changes no verdict the example demonstrates.
  • Boundary, stated so the parameter is not read as inert: a role that grants delete whose row rules list only read answers true without a record and false with one — still with zero reads of the record. That is a per-role static toggle, not record-level gating; the guide's own section "Row filters are returned verbatim — nothing interpolates them" already says the package evaluates no row filter client-side. An example passing contact therefore taught record-level gating the package does not perform; the boolean spelling that keeps the example true is can(...) with no record.
  • Ruled out: check(...).allowed (a second spelling for the same question — the drift the card names), and any new helper or package API change (the repair is in the guide).

Line readings (the two the skills rule requires)

reading before (c255b38, unchanged at edbcf1e) after (48d3d2b) delta
skills/objectui/guides/auth-permissions.md 403 409 +6 (budget: net ≤ +6)
whole package, every skills/objectui/**/*.md summed 4,640 4,646 +6

(wc -l; origin/main moved from c255b38 to edbcf1e between dispatch and branch-out with both readings unchanged.)

Marked population

At 48d3d2b the gate prints Marked: 16 ts fence(s) (floor 13), 70 json fence(s) (floor 70); --list shows the new row skills/objectui/guides/auth-permissions.md:225 [tsx] marked pass. Before: the BASE tree carries 85 os:check markers under skills/ plus .claude/skills/ and this branch 86 (git grep -c on the BASE ref vs the worktree — the unbuilt BASE tree cannot print the gate's own line), so ts 15 → 16, json 70 → 70.

MARKED_FLOOR in scripts/check-skill-examples.mjs is left at ts: 13. Its header invites raising it in the PR that adds a marker; that file is outside this card's claimed file surface (the guide, and the eval JSON only if a gate demanded it), so it is not touched here — see Acceptance notes.

Gates (exit captured before any pipe; verdict lines quoted)

Closure build first, under objectstack's verification lock: pnpm exec turbo run build $(node scripts/check-skill-examples.mjs --build-filter) --concurrency=2 → Tasks: 29 successful, 29 total · lock VERDICT command-exit 0 · held the lock 217s.

command exit verdict line
pnpm check:skill-examples 0 Semantic phase: 16 of 16 ts fence(s) judged, 0 failed. · Every marked skill example holds up against the built types.
pnpm check:skill-eval-tokens 0 Every must_contain token is taught by its own skill bundle. (eval JSON untouched)
pnpm check:skills-paths 0 check-skills-paths: OK (88/89 stated path(s) resolve across 20 guide file(s); 1 baselined).
pnpm check:new-line-citations 0 VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only → exit 0
pnpm check:control-bytes 0 check-control-bytes: OK (scanned 8061 tracked text file(s); skipped 85 binary).
node scripts/check-governed-queue-guard.mjs --test skills/objectui/guides/auth-permissions.md 3 GOVERNED — 1 of 1 path(s) are on a governed surface: skills/** x1 (informational; the expected reading)
node scripts/check-changeset-presence.mjs 0 No source or published contract of a released package changed in this range, so no changeset is owed. — no changeset added
pnpm exec vitest run packages/permissions/src/__tests__/skill-guide-permission-config.test.tsx (the test markdown-test-inputs.mjs --changed names for this guide) 0 Test Files 1 passed (1) · Tests 8 passed (8)
pnpm exec vitest run scripts/__tests__/check-skill-examples.test.ts 0 Test Files 1 passed (1) · Tests 113 passed (113)

NOT MEASURED locally: pnpm lint (repo-level turbo run lint, CI's run). Reason it was not run, not a measurement: the diff is one .md file and eslint.config.js configures no markdown processor.

Ablation

From the committed state 48d3d2b, each leg through objectstack's scripts/ablation-replace.mjs: mutation proven by anchor counts and blob hash, restore proven by blob == HEAD blob (b9f4672) and an empty git diff HEAD, tree clean after each leg.

  • Leg A — defect back, marker kept: check re-added to the destructure and canEdit: boolean = check('contacts', 'update', contact). Gate exit 1: [semantic] skills/objectui/guides/auth-permissions.md:236:9 TS2322: Type 'PermissionCheckResult' is not assignable to type 'boolean'. · Semantic phase: 16 of 16 ts fence(s) judged, 1 failed. Direction: red, as predicted.
  • Leg B — same defect, marker removed: gate exit 0, Marked: 15 ts fence(s) (floor 13), Semantic phase: 15 of 15 ts fence(s) judged, 0 failed, Every marked skill example holds up. That is the blind spot the card names, reproduced: unmarked, the defect is invisible to the only instrument that can see it. It also shows the floor at 13 does not red on this fence being unmarked (16 → 15), which is why the floor note above exists.
  • A first attempt at leg B did not run: the tool refused a marker removal whose replacement text was a substring of its anchor (x1 → x1), restored, and exited 1 before the gate; the leg was re-run with a distinct replacement. Recorded so the count of runs is honest.

Acceptance notes

  • noted, not filed: MARKED_FLOOR ts stays 13 with 16 marked; the gate header's invited raise is outside this card's file surface. 承接者: the landing seat (a one-line follow-up in scripts/check-skill-examples.mjs), or none if the seat prefers the floor to move only with a census.
  • noted, not filed: the guide's "Permission evaluation pipeline" step 4 reads "If record provided + row permissions exist: evaluate row-level filter"; the evaluator consults the row rules' actions list and never the filter or the record (measured above). Different paragraph, outside this card's section; prose precision, not a copied-code defect. 承接者: none identified.
  • Board: 0 of 12 open PRs touched this guide at dispatch; PR docs(skills): move the three published guides off the retired dataSource expression root #9378 (objectui#9370) holds three other guides of the package and is not touched.
  • No label writes from this run; needs:contract-review is the seat's to hang on this PR.

维护者速读(草稿)

改了什么 — skills/objectui/guides/auth-permissions.md 里 usePermissions 那段示例:两个按钮的显隐改为看 can('contacts', 'update') / can('contacts', 'delete')(布尔值),不再看 check(...)(一个对象,恒为真);示例围栏加了 os:check 标记并改为 tsx,补上编译所需的 Button 导入与 contact 类型;去掉了传给 check 的 contact 参数。

为什么改 — 这段代码会被客户项目照抄。原写法下被拒绝的用户也能看到「编辑」「删除」按钮,权限门形同虚设;而该围栏此前未被任何门禁编译,所以没人发现。实测(对着已构建的 @object-ui/permissions)check 从不读取记录的任何字段,can 在指南自己的配置上与 check(..., record).allowed 逐项一致,所以去掉记录参数不改变示例演示的任何结论。

风险与代价(含回滚) — 只改一个已发布技能文件(净 +6 行,在 PM 预算内),不动任何包源码、不动 API,不需要 changeset。回滚 = revert 这一个 commit。留下的一处:门禁的 MARKED_FLOOR 仍是 13(现有 16 个标记围栏),要不要同步抬到 16 交席位决定。

席位意见 —

你要做的 — 受管面 PR,需要一条获授权账号的 APPROVED review;批准后由席位入队落地。无需其他动作。


Generated by Claude Code

…d mark its fence

The `usePermissions` hook example in `skills/objectui/guides/auth-permissions.md`
gated two buttons on `check('contacts', 'update', contact)`. `check` answers a
`PermissionCheckResult` object, an object is truthy, and so both buttons rendered
for every user, denied ones included. The fence was unmarked, so
`check-skill-examples` never compiled it.

The example now gates on `can('contacts', 'update')` / `can('contacts', 'delete')`
— the one boolean spelling the guide's neighbouring paragraph already publishes —
annotated `: boolean` so a `check(...)` put back in that position fails to compile.
The record argument is dropped: measured against the built `@object-ui/permissions`
dist with a throwing Proxy as the record, `evaluatePermission` performs zero
property reads on it, and `can()` agrees with `check(..., record).allowed` on every
(role, action) of the guide's own config.

The fence carries the `os:check` marker and is tagged `tsx`, imports `Button`
from `@object-ui/components`, and types `contact` inline, so the gate compiles
it against the built dist from now on (Semantic phase: 16 of 16 ts fences).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 48d3d2b6096664bc50cdbe56ff42a25e4af0f216

In-seat review by the domain:skills seat at the contract-review tier (served tier read at seating, objectstack#7623 marker 5738863635) — the lane's record; Clause-②: yes declared on the card claim (5739257848) and in the PR body, needs:contract-review on both carriers (--pair 9994 reads 0 on the carriers). Verified against GitHub (GET /pulls/9994/files: one file, skills/objectui/guides/auth-permissions.md, +11 / −5) and the fetched branch (origin/claude/issue-9671-auth-permissions-can-boolean-gate at 48d3d2b, base edbcf1e = origin/main), ⛔ not against the report: one commit, trailer pair model-free; the hunk is the usePermissions hook fence only (branch lines 224–246). Tier H (skills/**): this record is piece ① of the terminal; the PR stays draft for an authorized approval.

① Derived judgments

  • The public-surface claim the guide now makes: can(object, action) answers a boolean and is the gate for a rendered control; check(...) answers PermissionCheckResult. Read on origin/main: packages/permissions/src/usePermissions.ts:16 can: (object: string, action: PermissionAction) => boolean and :64 can: (object, action) => ctx.check(object, action).allowed; PermissionContext.ts:14 check: (object, action, record?) => PermissionCheckResult. The guide's own paragraph at :285 already publishes can(...) as the boolean spelling — one spelling, the card's ⛔ on check(...).allowed honoured. Correct.
  • The dropped record argument: evaluator.ts uses record for presence only (:77, if (record && roleConfig.rowPermissions?.length), then reads rp.actions, never a field of the record); evaluateCondition (:194) has no non-test caller in packages/permissions/src (git grep on origin/main). The dev's throwing-Proxy measurement (0 property reads; six (role, action) pairs agree) is consistent with the source. The boundary the PR body states — a role whose row rules omit the action answers differently with and without a record, still with zero reads — is real and stated, not hidden. Correct: no contract widens or narrows; the example teaches the member that exists.
  • The fence: <!-- os:check --> directly above it, tagged tsx, Button imported from @object-ui/components, contact typed inline, both gates annotated : boolean so a check(...) in that position is TS2322 (ablation leg A red; leg B reproduces the unmarked blind spot the card names). Gate line at the head: Semantic phase: 16 of 16 ts fence(s) judged, 0 failed. Nothing else in the guide moves; the pipeline section's step-4 prose (row filter 「evaluated」) is a pre-existing imprecision outside this card's section — noted by the dev, not a copied-code defect, no card (三类外; 承接者:无).
  • Value density, read from the loading agent's seat: this is the example a client agent copies for a gated control; it now compiles under the gate and gates on the boolean. Net +6 lines in the guide, package 4,640 → 4,646, at the claim's ≤ +6 budget.

② Semver level

None owed — skills/** publishes no package; scripts/check-changeset-presence.mjs exit 0 on the branch diff (no changeset is the correct form in this repository).

③ Boundary flags

  • Deviations (6) read: BASE moved c255b38 → edbcf1e with the guide byte-identical (accepted); MARKED_FLOOR ts left at 13 with 16 marked — the gate's header says MORE is fine and nothing reds, and scripts/check-skill-examples.mjs is outside the claimed surface (accepted; no follow-up card — not one of the three classes); pnpm lint NOT MEASURED locally with the reason (CI's Lint check reads success on the head); ablation leg B's refused first attempt (recorded); the report before CI convergence (per os-dev.md); the report comment's trailing-newline normalisation (the platform's).
  • open_questions: none. out_of_scope_findings (2): the floor and the pipeline prose, both above; neither filed.
  • CI on the head at this record's writing: 25 success, 3 skipped (Test (coverage), its shard matrix, dependabot — conditional jobs on this PR; this seat holds no expected-skip roster for this repository and re-reads them at landing), 4 Test (shard N/4) in progress. Landing waits on the authorized approval in any case and re-reads every check first.

Implemented-by: claude/issue-9671-auth-permissions-can-boolean-gate
Reviewed-by: session_01W5y9kRg1YtYaMQYExVLRc2

VERDICT: PASS


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 20, 2026 08:34
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit 3d72fb6 Sep 20, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9671-auth-permissions-can-boolean-gate branch September 20, 2026 08:54
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…sion roots (objectui#9672) (objectstack-ai#9997)

Fixes objectstack-ai#9672

Clause-②: yes

## What changed

`skills/objectui/guides/page-builder.md` only — one guide, seven hunks,
net +8 lines (331 → 339 against BASE `edbcf1e7a`).

1. Section 4 「Wire renderer and registry cleanly」 step 3 no longer says
「Provide `dataSource` and contextual data through renderer provider」. It
now names ONE channel: `PredicateScopeProvider` from `@object-ui/react`
— every key of the `scope` you hand it becomes an expression root (and
`bind` reads the same bag). It also states which roots the renderer adds
on its own (`record`, `page`), that nothing publishes `data`, and what
`SchemaRendererProvider`'s `dataSource` still is: the adapter the
object-bound blocks fetch through, not an expression root. The provider
fence is pointed at, not copied — `guides/auth-permissions.md` carries
it since PR objectstack-ai#9669.
2. The four `${data.…}` reads the page's own examples made (BASE :43,
:47, :179, :193) now read roots the surrounding prose says the host
published: `${userRole !== 'admin'}`, `${metrics.activeUsers}`,
`${metrics.growth}`. Two short sentences carry the publication claim
(section 3's lead-in and the 「Expression evaluation boundaries」
lead-in), the same shape as auth-permissions.md's 「a page whose host
published `userRole`」.

Residual `${data.` in this file after the change: 0.

## The measurement that decided the channel

`packages/react/src/SchemaRenderer.tsx`, the evaluator that `hidden` /
`content` / a `statistic`'s `value` are evaluated on (the `new
ExpressionEvaluator({ … })` block inside `evaluatedSchema`):

- spreads `usePredicateScope()` — the `PredicateScopeProvider` context
(`packages/react/src/hooks/useExpression.ts`: `PredicateScopeProvider` /
`usePredicateScope`, exported through `hooks/index.ts` and the package
index);
- adds `current_user` (alias of a published `user`), `record` (from
`RecordContextProvider`, only when a row is bound) and `page` (page
variables);
- binds NO `data`. The block's own comment: 「`data` is NOT here, and the
absence is the decision (objectui#9308, maintainer ruling 2026-09-13
option B)」.

`SchemaRendererContext.dataSource` is read by `useViewData` (the
object-bound blocks' adapter) and is no longer an expression root;
`useDataScope` — what `bind` reads — now walks `usePredicateScope()` too
(same ruling, per its docblock). So under the old step-3 wiring a
`${data.metrics.activeUsers}` has no root: `hidden` fails soft (hidden
for everyone), a `text` `content` / `statistic` `value` prints its own
source text — the card's silent failure, reproduced by reading rather
than asserted.

`useExpression` / `useCondition` (the widget tier) merge the same
published scope under the caller's local context, so the roots are the
same on both tiers.

## Class count, re-derived (⛔ not copied forward)

`git grep -c '\${data\.' -- 'skills/objectui/**/*.md'` at `edbcf1e7a`:
architecture.md 1 · data-integration.md 2 · page-builder.md 4 ·
schema-expressions.md 21 · testing.md 4 · rules/protocol.md 13 — six
files, 45 reads. Only page-builder.md is touched here:
data-integration.md / schema-expressions.md / rules/protocol.md are PR
objectstack-ai#9378's (objectui#9370); testing.md is objectui#9380's;
auth-permissions.md was repaired by PR objectstack-ai#9669 and reads 0.

## Line readings (the PM's budget: net ≤ +8 in the file)

| reading | before (`edbcf1e7a`) | after |
|---|---|---|
| `skills/objectui/guides/page-builder.md` | 331 | 339 (+8, at the cap)
|
| package `skills/objectui/**/*.md`, 16 files | 4,640 | 4,648 |

Package spelling: `find skills/objectui -name '*.md' -type f` piped
through `cat` and `wc -l` (a `git ls-files 'skills/objectui/**/*.md'`
glob skips the two top-level files and reads 4,439 — not the figure
used).

## Region fence — open PR objectstack-ai#9592 (objectui#7945)

PR objectstack-ai#9592's only hunk on this file is `@@ -105,18 +105,16 @@` (section 5,
the `events` bag → `action:button`). BASE :102–:122 — that hunk plus its
leading context — is byte-identical at :109–:129 of this branch (`diff`
empty). Nothing here touches section 5, so the two land in either order.

## The card's 「related」 item

The 「Provider composition pattern」 example that nests only
`SchemaRendererProvider` is NOT in page-builder.md (`composition` → 0
hits in this file at BASE; control `SchemaRenderer` → 5). It lives in
`skills/objectui/guides/auth-permissions.md` under the heading 「##
Provider composition pattern」, an unmarked `typescript` fence — a file
outside this card's surface (PR objectstack-ai#9994 / objectui#9671 holds it). Not
touched here; handed to the seat in the dev report.

## Gates (exit captured before any pipe; the gate's own verdict line
quoted)

- build closure under the shared lock — `pnpm exec turbo run build
$(node scripts/check-skill-examples.mjs --build-filter) --concurrency=2`
→ `VERDICT command-exit 0` (29/29 cached, shared worktree cache)
- `node scripts/check-skill-examples.mjs --self-test` → exit 0, 「60
cases pass」
- `pnpm check:skill-examples` → exit 0: 「Semantic phase: 15 of 15 ts
fence(s) judged, 0 failed. JSON phase: 70 fence(s) parsed, 0 failed.」 —
all ten marked page-builder.md fences read `pass` in `--list`
- `pnpm check:skill-eval-tokens` → exit 0: 「Every must_contain token is
taught by its own skill bundle.」 (no eval touched)
- `pnpm check:skills-paths` → exit 0 (88/89 resolve, 1 baselined)
- `pnpm check:new-line-citations` → exit 0 on the committed head: 「0 new
citation(s)」
- `pnpm check:control-bytes` → exit 0 (8061 files)
- `node scripts/check-governed-queue-guard.mjs --test
skills/objectui/guides/page-builder.md` → exit 3, 「GOVERNED — 1 of 1
path(s)」 — expected: Tier H, this PR stays draft until an authorized
approval
- `node scripts/check-changeset-presence.mjs` → exit 0, no changeset
owed (`skills/**` is not published source)
- `pnpm exec vitest run
scripts/__tests__/check-skill-eval-tokens.test.ts` → exit 0, 36 passed
(the only test naming `page-builder.md`; it uses a fixture by that name)

`pnpm lint` not run locally: repo-wide and CI-owned; the diff is one
`.md` outside every eslint population.

## Acceptance notes

- noted, not filed: the auth-permissions.md 「Provider composition
pattern」 fence above nests no `PredicateScopeProvider`, so a reader
copying it gets no expression scope — incompleteness, not a false
statement. 承接者: the seat, since PR objectstack-ai#9994 holds that file.

## 维护者速读(草稿)

**改了什么**:只改 `skills/objectui/guides/page-builder.md` 一个文件,净 +8 行。第 4 节第
3 步原来教读者「通过 renderer provider 提供 `dataSource` 和上下文数据」,而这条通道在
objectui#9308 之后已不再给表达式发布 `data.*`
根。现在这一步只点名一条能用的通道:`PredicateScopeProvider`(`@object-ui/react`),交给它的
`scope` 的每个键都成为表达式的根;同时写明渲染器自己只补 `record` 与 `page`、没有任何东西发布
`data`、`SchemaRendererProvider` 的 `dataSource`
是对象绑定块取数的适配器而不是表达式根。页面自己的四处示例从 `${data.userRole}` /
`${data.metrics.activeUsers}` 改为读宿主已发布的 `userRole` /
`metrics`,并用一句话写明「宿主发布了这两个键」。

**为什么改**:照旧文接线的读者,页面不报错但结果是错的:`hidden` 门对所有人都隐藏,`text` 的 `content` 与
`statistic` 的 `value` 把 `${data.metrics.activeUsers}` 这串字符原样打到页面上。这是
objectui#9379(auth-permissions.md,PR objectstack-ai#9669
已修)同一类问题的又一个成员;修法照抄那次的形状,不照抄字句。

**风险与代价(含回滚)**:纯文档改动,不动任何包源码、不发版、不欠 changeset;`check:skill-examples`
对本文件十个标记示例全部通过。与在途 PR objectstack-ai#9592(改同文件第 5 节)区域不相交,先后合并都干净。回滚 = revert 这一个
commit。

**席位意见**:(留空)

**你要做的**:`skills/**` 是受管面,本 PR 停在 draft;由 `os-zhuang` / `hotlong` 之一给一条
APPROVED review 后,认领席落地。

---
_Generated by [Claude
Code](https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2)_

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants