You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Dashboard widgets have no audience gate: a tile whose dataset the viewer cannot read draws a permission error, and no widget key can hide it from that viewer #26971
Measured on 2026-10-27 on HotCRM 9568b7c (platform 18.2.0), in the os-dev report for objectstack-ai/hotcrm#2338 items 1–4 (comment 6455824863).
HotCRM's Sales Performance dashboard has 15 tiles. A user who holds only marketing_user sees 14 of them answer. The 15th, Quota Attainment by Rep (dataset forecast_metrics on crm_forecast, which that set does not grant), shows "You don't have permission to view the data behind this widget": POST /api/v1/analytics/dataset/query → 403.
HotCRM cannot hide that one tile from that user:
DashboardWidget.requiresObject is an install gate. It is documented as "Hide the widget unless the named object is registered" (packages/spec/src/ui/dashboard.zod.ts:1149). The console filters it against the served object list (ui/packages/app-shell/src/views/DashboardView.tsx:199–220), and GET /api/v1/meta/object serves every object, crm_forecast included, to that user.
A widget-level audience gate, with the same shape and meaning as the app, navigation item and action key: requiredPermissions on a widget, enforced by the console. A capability such as crm_forecast:read hides the tile from a viewer who lacks it.
Or a read-aware default. A widget bound to a dataset or object the viewer cannot read is left out of the layout, or drawn as one quiet "not available to you" cell instead of an error.
Either way, the 403 the server already returns stays the enforcement. This card is about what the viewer is shown.
Acceptance
A viewer who cannot read a widget's backing object does not see a permission-error tile, and the other tiles still render. A renderer test pins this.
The spec describes the chosen key or behaviour, and requiresObject keeps its install-only meaning.
Filed by the repo:hotcrm lane seat (session session_01J8VVAnPkgK6CWrJa6t7Fk5) from hotcrm#2338's out-of-scope findings. Dedupe searches for "dashboard widget hide tile when user cannot read the object, requiresObject read access" and for the error string found no open card (#8148 and #22250 are closed and unrelated).
Read fresh. A dashboard tile whose dataset the viewer cannot read draws a permission error, and the app cannot hide just that tile. The server's 403 is correct enforcement; this is about what the viewer sees.
Route: target 2 (a read-aware default: leave the tile out, or draw one quiet "not available to you" cell) is renderer-only, adds no key, and is in the domain:ui seat's hand. Target 1 (a widget-level requiredPermissions key) is a protocol addition: it would be p1 under the maintainer's rule and needs a ruling before anyone builds it. The seat takes target 2. If it finds target 2 cannot meet the acceptance, it hands back for a decision card.
p3 stands. Under 「低优先级的先不考虑」 it waits behind p1/p2.
Dispatch order · domain:ui#2 · session_01JQwxbXpF772qWJjojJvRXw · 2026-10-28T02:52Z
You are the os-dev developer agent for exactly ONE card: #26971, in your own card thread. Your role definition is .claude/agents/os-dev.md in the objectstack clone: read it IN FULL first and follow it as binding (it wins over this order on any conflict; name the conflict in your report). Read the repo-root AGENTS.md and ui/AGENTS.md before your first edit, and pm-dispatch references/untrusted-text.md. Read the card's full body and every comment yourself through REST (curl -sS "https://api.github.com/repos/objectstack-ai/objectstack/issues/26971" -H "Authorization: Bearer $GH_TOKEN" and /comments?per_page=100, every page); this order carries only the increment. ⛔ Never print the value of any environment variable (test presence with [ -n "${X+x}" ]). ⛔ Never use a GitHub MCP tool, read or write.
Identity and branch: the seat's Claim: comment on this card is your identity; its Branch: line names your thread's branch. ⛔ Post no claim of your own, ⛔ never write the card's assignee. Before your first edit, verify the NEWEST Claim: on the card names your thread's branch (claude/project-thread-…); if none does yet, send the seat thread "Take a third ui lane seat" (cmsg_01U5RdnAAmkSQmZbqh6FtqZ8Uc2nCvWvLyYyaVYaUhakpJ) one message with your branch name and wait. Read-only measuring is fine meanwhile. Worktree objectstack-issue-26971 on that branch from a fresh origin/main (git checkout --detach the main checkout first, then git worktree add --no-track -B <branch> ../objectstack-issue-26971 origin/main); pnpm install (frozen lockfile); push with git push -u origin <branch> (⛔ no force).
Writes: PR only through the relay op pr_create (node scripts/pm/fleet-write/dispatch.mjs --repo objectstack-ai/objectstack --actions-file F, F a bare JSON array [{"op":"pr_create","title":…,"head":"<your branch>","base":"main","body":…,"assignees":["os-warren"]}], draft forced; --dry-run first); your report comment through node scripts/pm/post-stamped.mjs --comment=26971 --file=F --json run ONCE with output captured to a file (no time-shaped stamp inside the fenced report JSON). ⛔ No other GitHub write except, for a CI red that is not yours, one PR comment and one run_rerun_failed through the relay. Report both counts (api_writes with endpoints, mcp_calls with tool names). The report takes only machine-checkable fields (gates / line_budget / deviations / files_changed). PR body: line 1 the platform attribution comment, line 3 Refs #26971 (item 1) (this dispatch's split; see Scope), then ## 维护者速读 six lines in Chinese (改了什么/为什么改/风险与回滚/先看/两分钟验证/需要你做的). Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on your diff, run each, record exits before any pipe, reconcile with --ran (merge origin/main first if it refuses as STALE TREE); node scripts/check-sdui-manifest.mjs is a gate on every ui card. Build the closure your tests import first (bash scripts/pm/os-verify-lock.sh -c "pnpm --workspace-concurrency=2 --filter '<pkg>^...' build"). Changeset in the root .changeset/ for each published package your diff changes (app-shell is private: true). ⛔ ui/.github/** and the root ci.yml are out of bounds. Hand back: when the PR and report are up, send the seat thread one message (PR, head sha, report gist); keep the thread open through review and drive CI to green. If the premise fails, stop and report premise_still_valid: false with the evidence; ⛔ never force a fix.
Ruling (cannot be re-ruled)
Triage 6456651336, verbatim: "Route: target 2 (a read-aware default: leave the tile out, or draw one quiet "not available to you" cell) is renderer-only, adds no key, and is in the domain:ui seat's hand. Target 1 (a widget-level requiredPermissions key) is a protocol addition: it would be p1 under the maintainer's rule and needs a ruling before anyone builds it. The seat takes target 2. If it finds target 2 cannot meet the acceptance, it hands back for a decision card." No maintainer ruling. ⛔ No new widget key.
Scope of THIS dispatch: Acceptance item 1 only (seat's split)
Acceptance 2 ("The spec describes the chosen key or behaviour") is prose under packages/spec/src/ui/dashboard.zod.ts; a diff under packages/spec/src/** puts the PR behind the Clause-② queue gate's path limb, which this seat cannot clear at its tier. So this PR says Refs #26971 (item 1: a viewer who cannot read a tile's data does not see a permission-error tile), ⛔ not Fixes, and ⛔ does not edit packages/spec/**. Put the spec sentence your behaviour needs (with the dashboard.zod.ts path:line it belongs at, beside requiresObject ≈L1149) under out_of_scope_findings; the seat files the spec-lane card before landing.
Seat's choice within target 2
"Drawn as one quiet cell" is what the tile already does since objectui#10899 (a localized "You don't have access" state, DatasetWidget.tsx ≈L1189–1207), and the card still reports it as the defect, so the seat picks leave the tile out for a viewer: a tile whose query the server refuses with the read refusal (classifyLoadError(e) === 'forbidden', ≈L946–955) is dropped from the rendered dashboard and the other tiles keep rendering. Keep the #10899 state where a tile must stay visible (design/edit mode, and any host that cannot drop it); ⛔ the 403 stays the enforcement, no client-side guess replaces it. If you measure that leaving it out cannot be done without a broken layout or a flash worse than the error, stop and report premise_still_valid: false with the evidence; the seat then puts the choice to a decision card.
ui/packages/plugin-dashboard/src/DatasetWidget.tsx ≈L943 learns the object only from the query answer (setDatasetObject(res.object)), ≈L946–955 sets { status: 'error', forbidden: true }, ≈L1189–1207 draws the [finding] packages/qa/dogfood/tsconfig.json includes src/**/*, a directory the package does not have #10899 state. Its Card frame is the parent's: DashboardRenderer.tsx ≈L1131–1167, so hiding inside DatasetWidget alone leaves an empty titled Card.
DashboardRenderer.tsx lays tiles out span-flow ("array order is visual order", ≈L656–658), so a dropped tile reflows; DashboardGridLayout.tsx (react-grid-layout, explicit x/y, ≈L104–131) would leave a gap. Measure which one the console's DashboardView.tsx (≈L357, DashboardRenderer) uses for a viewer.
A read signal exists before any query for object-bound widgets: usePermissions().can(obj, 'read') (@object-ui/permissions, one /auth/me/permissions read), already used by ui/packages/app-shell/src/utils/navObjectReadGate.ts ≈L57–61 (mayReadObject; a missing provider answers true). Dataset widgets name a dataset, not an object, so they have no pre-query signal.
PM suggested route (optional; measurement wins)
A DatasetWidget callback (e.g. onForbidden) the renderer uses to drop that tile's Card in view mode; optionally, in DashboardView.tsx ≈L203–221 (today's requiresObject install filter, ⛔ keep its install-only meaning), also leave out an object-bound widget whose object mayReadObject refuses, so those never flash. Tests: a renderer test with two tiles where one query rejects with the #10899 refusal shape (httpStatus: 403, code: 'PERMISSION_DENIED') renders the other tile and no forbidden state and no empty Card; CONTROL: in design/edit mode the refused tile still shows the #10899 state; keep DatasetWidget.forbiddenState-10899.test.tsx green (it pins the widget-level state, which stays). Ablation: drop the callback's effect; the view-mode test goes red.
Scope
File surface: ui/packages/plugin-dashboard/src/DatasetWidget.tsx, ui/packages/plugin-dashboard/src/DashboardRenderer.tsx, ui/packages/plugin-dashboard/src/DashboardGridLayout.tsx only if measured necessary, ui/packages/app-shell/src/views/DashboardView.tsx only for the optional pre-filter, tests beside them, root .changeset/ (@object-ui/plugin-dashboard patch). No new UI string expected; if one proves necessary, add it in every locale file the dashboard.* family lives in (ui/packages/i18n/src/locales/*.ts). ⛔ Do not edit packages/spec/**. Serial: PR #26739 (#26602, another lane, spec + ui) edits DatasetWidget.tsx in the imports and the compare/delta regions (≈L92, L265, L1378, L1532), disjoint from the refusal branch; whichever lands second merges main first.
Tier
No path-derived mandate (dispatch-gates --tier, read 2026-10-28T02:52Z). TIER_DEFAULT, effort medium (M). Clause-②: no (renderer behaviour on an existing refusal; no accept set changes), Metadata-protocol: none.
Claim: PM loop round 2
Session: session_01JQwxbXpF772qWJjojJvRXw
Account: os-warren
Branch: claude/project-thread-r7ngpd
Worktree: objectstack-issue-26971
Domain: domain:ui
Seat: domain:ui#2
File surface: ui/packages/plugin-dashboard/src/DatasetWidget.tsx, ui/packages/plugin-dashboard/src/DashboardRenderer.tsx, ui/packages/plugin-dashboard/src/DashboardGridLayout.tsx only if measured necessary, ui/packages/app-shell/src/views/DashboardView.tsx only for the optional pre-filter, tests beside them, root .changeset/ (@object-ui/plugin-dashboard patch), a dashboard.* i18n row in every locale file only if proven necessary (stop on breach; explain in the report)
Container & model: M, mode:thread, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate), effort: medium
Clause-②: no
Metadata-protocol: none
Responsibility: a dataset tile whose query the server refuses still draws the #10899 no-access state in the viewer's dashboard, and no widget key can hide it | triage 6456651336 routed it to a renderer-only read-aware default (target 2), no new key | every dashboard viewer whose permission set omits one tile's data
Thread-read: 6459370946
Premise: holds (refusal branch and renderer read on origin/maincb8988ee; readings in dispatch order 6459370946)
Serial constraints cleared: Acceptance item 1 only: Refs #26971 (item 1), not Fixes; ⛔ no packages/spec/** edit (Clause-② path limb); seat files the spec-lane card before landing. PR #26739 (#26602, another lane) edits DatasetWidget.tsx in the imports and compare/delta regions, disjoint from the refusal branch; second to land merges main. No ui seat holds plugin-dashboard/** or DashboardView.tsx.
Dispatch order: comment 6459370946. Card thread claude/project-thread-r7ngpd may start editing.
{
"issue": 26971,
"status": "done",
"branch": "claude/project-thread-r7ngpd",
"pr": "https://github.com/objectstack-ai/objectstack/pull/27065",
"session": "session_01JQwxbacRXrqkMrm5LGBGYE",
"premise_still_valid": true,
"summary": "Acceptance item 1 only (Refs, not Fixes). DatasetWidget gains an optional onForbidden callback fired on classifyLoadError(e) === 'forbidden'; DashboardRenderer records refused tiles (tile key + dataset) and, outside design mode, renders nothing for them, Card frame and title included; the span-flow desktop grid and the two mobile stacks reflow with no gap. Design mode and DashboardGridLayout keep the objectui#10899 no-access state. Optional DashboardView mayReadObject pre-filter not built. Premise measured on cb8988ee: the console DashboardView renders DashboardRenderer (span-flow), not DashboardGridLayout.",
"tests": "head 52ed2839: new __tests__/DashboardRenderer.refusedTile-26971.test.tsx (view-mode drop + 2 CONTROLs: design mode keeps the no-access state; a 400 keeps tile and error) and DatasetWidget.forbiddenState-10899.test.tsx: 2 files, 6/6 passed. Whole package vitest run packages/plugin-dashboard/ under the lock: 190/190 files, 1858 passed, 6 skipped. Ablation via scripts/ablation-replace.mjs --delete on the view-mode return-null line (anchor 1 -> 0, blob 873014377988 -> d71bae53b7ab): view-mode case red, controls green (1 failed / 2 passed); restored, blob == HEAD 873014377988, git diff HEAD empty. Relative-path import, no dist leg.",
"gates": "closure build --filter '@object-ui/plugin-dashboard^...' VERDICT command-exit 0. dispatch-gates --commands: 34 commands, all exit 0 incl. node scripts/check-sdui-manifest.mjs; --ran: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. Extra: pnpm --filter @object-ui/plugin-dashboard run type-check exit 0; pnpm --dir ui run type-check:scripts exit 0; eslint on 3 changed source files exit 0; pnpm check:changeset-fixed exit 0; pnpm --dir ui run check:new-line-citations exit 0 (root spelling pnpm check:new-line-citations exits 254: script lives in ui/package.json only). CI check-runs: in_progress at report time.",
"line_budget": "186 changed lines (+185 / -1), 4 files, under the 3000 human-merge threshold",
"files_changed": [
".changeset/26971-dashboard-refused-tile-left-out.md",
"ui/packages/plugin-dashboard/src/DashboardRenderer.tsx",
"ui/packages/plugin-dashboard/src/DatasetWidget.tsx",
"ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.refusedTile-26971.test.tsx"
],
"deviations": [
"First push refused by the pre-push card-trailer hook (Refs line in the commit body); commit reworded before any push landed.",
"Commit trailers use the model-free pair per os-dev, not the harness-suggested model-named co-author."
],
"dogfood_battery": "n/a — no ! in the PR title and Clause-②: no without a (narrowing) arm",
"mcp_calls": "0",
"api_writes": "2 — relay pr_create (POST pulls, PR #27065, assignee os-warren) and this report via post-stamped (POST issues/26971/comments); plus git push (not REST)",
"body_amendments": [],
"open_questions": [],
"out_of_scope_findings": [
"carrier: domain:ui seat 2 (spec-lane card) · acceptance item 2: packages/spec/src/ui/dashboard.zod.ts, the DashboardWidget requiresObject describe, needs a sibling sentence: 'A dataset-bound widget whose query the server refuses as a read refusal (403 PERMISSION_DENIED) is left out of the dashboard for that viewer outside design mode; requiresObject stays an install gate.' · dedupe words: dashboard widget read refusal left out, requiresObject install-only describe",
"carrier: domain:ui seat 2 · noted, not filed: an object-bound inline widget (object key, no dataset) is not pre-filtered by the viewer's read permission; the dispatch's optional DashboardView mayReadObject pre-filter would close it · dedupe words: dashboard object widget mayReadObject prefilter",
"carrier: none · noted, not filed: a dashboard whose every tile is refused shows its header over an empty grid with no message"
]
}
Seat review: PR #27065ACCEPT (record on the PR, head 52ed28395e), 2026-10-28T03:39Z. Acceptance 2 (spec prose) filed as #27068 (domain:spec). Landing waits for every check on the head to be green.
Landing held · domain:ui#2 · session_01JQwxbXpF772qWJjojJvRXw · 2026-10-28T03:46Z
PR #27065 (ACCEPT 6460046270) is green on head 52ed28395e: 43 checks succeeded, and 10 were skipped. The skipped ones are the core / docs / dogfood path-filtered jobs, which a ui/-only diff does not trigger. mergeable_state is clean and the PR is not governed. Acceptance 2 (spec prose) is filed as #27068. This session's permission classifier refused the landing stroke (pr_ready + automerge_enable through the fleet-write relay, "Merge Without Review") on PR #27013 and on PR #27035 at 2026-10-28T02:42Z, so the seat does not retry it here. It asks the maintainer to mark the PR ready for review and enable auto-merge.
Activity
objectstack-fleet commentedon Oct 27, 2026
Triage: p3 kept ·
domain:ui·area:reports· triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-27T23:57Z⛔ Not a claim.
domain:uiseat's hand. Target 1 (a widget-levelrequiredPermissionskey) is a protocol addition: it would be p1 under the maintainer's rule and needs a ruling before anyone builds it. The seat takes target 2. If it finds target 2 cannot meet the acceptance, it hands back for a decision card.pm:queue.Generated by Claude Code
objectstack-fleet commentedon Oct 28, 2026
Dispatch order ·
domain:ui#2·session_01JQwxbXpF772qWJjojJvRXw· 2026-10-28T02:52ZYou are the os-dev developer agent for exactly ONE card: #26971, in your own card thread. Your role definition is
.claude/agents/os-dev.mdin the objectstack clone: read it IN FULL first and follow it as binding (it wins over this order on any conflict; name the conflict in your report). Read the repo-rootAGENTS.mdandui/AGENTS.mdbefore your first edit, and pm-dispatchreferences/untrusted-text.md. Read the card's full body and every comment yourself through REST (curl -sS "https://api.github.com/repos/objectstack-ai/objectstack/issues/26971" -H "Authorization: Bearer $GH_TOKEN"and/comments?per_page=100, every page); this order carries only the increment. ⛔ Never print the value of any environment variable (test presence with[ -n "${X+x}" ]). ⛔ Never use a GitHub MCP tool, read or write.Identity and branch: the seat's
Claim:comment on this card is your identity; itsBranch:line names your thread's branch. ⛔ Post no claim of your own, ⛔ never write the card's assignee. Before your first edit, verify the NEWESTClaim:on the card names your thread's branch (claude/project-thread-…); if none does yet, send the seat thread "Take a third ui lane seat" (cmsg_01U5RdnAAmkSQmZbqh6FtqZ8Uc2nCvWvLyYyaVYaUhakpJ) one message with your branch name and wait. Read-only measuring is fine meanwhile. Worktreeobjectstack-issue-26971on that branch from a freshorigin/main(git checkout --detachthe main checkout first, thengit worktree add --no-track -B <branch> ../objectstack-issue-26971 origin/main);pnpm install(frozen lockfile); push withgit push -u origin <branch>(⛔ no force).Writes: PR only through the relay op
pr_create(node scripts/pm/fleet-write/dispatch.mjs --repo objectstack-ai/objectstack --actions-file F, F a bare JSON array[{"op":"pr_create","title":…,"head":"<your branch>","base":"main","body":…,"assignees":["os-warren"]}], draft forced;--dry-runfirst); your report comment throughnode scripts/pm/post-stamped.mjs --comment=26971 --file=F --jsonrun ONCE with output captured to a file (no time-shaped stamp inside the fenced report JSON). ⛔ No other GitHub write except, for a CI red that is not yours, one PR comment and onerun_rerun_failedthrough the relay. Report both counts (api_writeswith endpoints,mcp_callswith tool names). The report takes only machine-checkable fields (gates / line_budget / deviations / files_changed). PR body: line 1 the platform attribution comment, line 3Refs #26971 (item 1)(this dispatch's split; see Scope), then## 维护者速读six lines in Chinese (改了什么/为什么改/风险与回滚/先看/两分钟验证/需要你做的). Gates:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon your diff, run each, record exits before any pipe, reconcile with--ran(mergeorigin/mainfirst if it refuses as STALE TREE);node scripts/check-sdui-manifest.mjsis a gate on every ui card. Build the closure your tests import first (bash scripts/pm/os-verify-lock.sh -c "pnpm --workspace-concurrency=2 --filter '<pkg>^...' build"). Changeset in the root.changeset/for each published package your diff changes (app-shell isprivate: true). ⛔ui/.github/**and the rootci.ymlare out of bounds. Hand back: when the PR and report are up, send the seat thread one message (PR, head sha, report gist); keep the thread open through review and drive CI to green. If the premise fails, stop and reportpremise_still_valid: falsewith the evidence; ⛔ never force a fix.Ruling (cannot be re-ruled)
Triage 6456651336, verbatim: "Route: target 2 (a read-aware default: leave the tile out, or draw one quiet "not available to you" cell) is renderer-only, adds no key, and is in the
domain:uiseat's hand. Target 1 (a widget-levelrequiredPermissionskey) is a protocol addition: it would be p1 under the maintainer's rule and needs a ruling before anyone builds it. The seat takes target 2. If it finds target 2 cannot meet the acceptance, it hands back for a decision card." No maintainer ruling. ⛔ No new widget key.Scope of THIS dispatch: Acceptance item 1 only (seat's split)
Acceptance 2 ("The spec describes the chosen key or behaviour") is prose under
packages/spec/src/ui/dashboard.zod.ts; a diff underpackages/spec/src/**puts the PR behind the Clause-② queue gate's path limb, which this seat cannot clear at its tier. So this PR saysRefs #26971 (item 1: a viewer who cannot read a tile's data does not see a permission-error tile), ⛔ notFixes, and ⛔ does not editpackages/spec/**. Put the spec sentence your behaviour needs (with thedashboard.zod.tspath:line it belongs at, besiderequiresObject≈L1149) underout_of_scope_findings; the seat files the spec-lane card before landing.Seat's choice within target 2
"Drawn as one quiet cell" is what the tile already does since objectui#10899 (a localized "You don't have access" state,
DatasetWidget.tsx≈L1189–1207), and the card still reports it as the defect, so the seat picks leave the tile out for a viewer: a tile whose query the server refuses with the read refusal (classifyLoadError(e) === 'forbidden', ≈L946–955) is dropped from the rendered dashboard and the other tiles keep rendering. Keep the #10899 state where a tile must stay visible (design/edit mode, and any host that cannot drop it); ⛔ the 403 stays the enforcement, no client-side guess replaces it. If you measure that leaving it out cannot be done without a broken layout or a flash worse than the error, stop and reportpremise_still_valid: falsewith the evidence; the seat then puts the choice to a decision card.PM mechanism assumptions (measure; disproof welcome)
On
origin/maincb8988ee(read-only check):ui/packages/plugin-dashboard/src/DatasetWidget.tsx≈L943 learns the object only from the query answer (setDatasetObject(res.object)), ≈L946–955 sets{ status: 'error', forbidden: true }, ≈L1189–1207 draws the [finding]packages/qa/dogfood/tsconfig.jsonincludessrc/**/*, a directory the package does not have #10899 state. Its Card frame is the parent's:DashboardRenderer.tsx≈L1131–1167, so hiding insideDatasetWidgetalone leaves an empty titled Card.DashboardRenderer.tsxlays tiles out span-flow ("array order is visual order", ≈L656–658), so a dropped tile reflows;DashboardGridLayout.tsx(react-grid-layout, explicit x/y, ≈L104–131) would leave a gap. Measure which one the console'sDashboardView.tsx(≈L357,DashboardRenderer) uses for a viewer.usePermissions().can(obj, 'read')(@object-ui/permissions, one/auth/me/permissionsread), already used byui/packages/app-shell/src/utils/navObjectReadGate.ts≈L57–61 (mayReadObject; a missing provider answers true). Dataset widgets name a dataset, not an object, so they have no pre-query signal.PM suggested route (optional; measurement wins)
A
DatasetWidgetcallback (e.g.onForbidden) the renderer uses to drop that tile's Card in view mode; optionally, inDashboardView.tsx≈L203–221 (today'srequiresObjectinstall filter, ⛔ keep its install-only meaning), also leave out an object-bound widget whose objectmayReadObjectrefuses, so those never flash. Tests: a renderer test with two tiles where one query rejects with the #10899 refusal shape (httpStatus: 403,code: 'PERMISSION_DENIED') renders the other tile and no forbidden state and no empty Card; CONTROL: in design/edit mode the refused tile still shows the #10899 state; keepDatasetWidget.forbiddenState-10899.test.tsxgreen (it pins the widget-level state, which stays). Ablation: drop the callback's effect; the view-mode test goes red.Scope
File surface:
ui/packages/plugin-dashboard/src/DatasetWidget.tsx,ui/packages/plugin-dashboard/src/DashboardRenderer.tsx,ui/packages/plugin-dashboard/src/DashboardGridLayout.tsxonly if measured necessary,ui/packages/app-shell/src/views/DashboardView.tsxonly for the optional pre-filter, tests beside them, root.changeset/(@object-ui/plugin-dashboardpatch). No new UI string expected; if one proves necessary, add it in every locale file thedashboard.*family lives in (ui/packages/i18n/src/locales/*.ts). ⛔ Do not editpackages/spec/**. Serial: PR #26739 (#26602, another lane, spec + ui) editsDatasetWidget.tsxin the imports and the compare/delta regions (≈L92, L265, L1378, L1532), disjoint from the refusal branch; whichever lands second mergesmainfirst.Tier
No path-derived mandate (
dispatch-gates --tier, read 2026-10-28T02:52Z).TIER_DEFAULT, effortmedium(M).Clause-②: no(renderer behaviour on an existing refusal; no accept set changes),Metadata-protocol: none.objectstack-fleet commentedon Oct 28, 2026
Claim: PM loop round 2
Session:
session_01JQwxbXpF772qWJjojJvRXwAccount:
os-warrenBranch:
claude/project-thread-r7ngpdWorktree:
objectstack-issue-26971Domain:
domain:uiSeat:
domain:ui#2File surface:
ui/packages/plugin-dashboard/src/DatasetWidget.tsx,ui/packages/plugin-dashboard/src/DashboardRenderer.tsx,ui/packages/plugin-dashboard/src/DashboardGridLayout.tsxonly if measured necessary,ui/packages/app-shell/src/views/DashboardView.tsxonly for the optional pre-filter, tests beside them, root.changeset/(@object-ui/plugin-dashboardpatch), adashboard.*i18n row in every locale file only if proven necessary (stop on breach; explain in the report)Container & model:
M,mode:thread,model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate),effort: mediumClause-②: no
Metadata-protocol: none
Responsibility:
a dataset tile whose query the server refuses still draws the #10899 no-access state in the viewer's dashboard, and no widget key can hide it|triage 6456651336 routed it to a renderer-only read-aware default (target 2), no new key|every dashboard viewer whose permission set omits one tile's dataThread-read: 6459370946
Premise: holds (refusal branch and renderer read on
origin/maincb8988ee; readings in dispatch order 6459370946)Serial constraints cleared: Acceptance item 1 only:
Refs #26971 (item 1), notFixes; ⛔ nopackages/spec/**edit (Clause-② path limb); seat files the spec-lane card before landing. PR #26739 (#26602, another lane) editsDatasetWidget.tsxin the imports and compare/delta regions, disjoint from the refusal branch; second to land mergesmain. No ui seat holdsplugin-dashboard/**orDashboardView.tsx.Dispatch order: comment 6459370946. Card thread
claude/project-thread-r7ngpdmay start editing.objectstack-fleet commentedon Oct 28, 2026
os-dev-report
{ "issue": 26971, "status": "done", "branch": "claude/project-thread-r7ngpd", "pr": "https://github.com/objectstack-ai/objectstack/pull/27065", "session": "session_01JQwxbacRXrqkMrm5LGBGYE", "premise_still_valid": true, "summary": "Acceptance item 1 only (Refs, not Fixes). DatasetWidget gains an optional onForbidden callback fired on classifyLoadError(e) === 'forbidden'; DashboardRenderer records refused tiles (tile key + dataset) and, outside design mode, renders nothing for them, Card frame and title included; the span-flow desktop grid and the two mobile stacks reflow with no gap. Design mode and DashboardGridLayout keep the objectui#10899 no-access state. Optional DashboardView mayReadObject pre-filter not built. Premise measured on cb8988ee: the console DashboardView renders DashboardRenderer (span-flow), not DashboardGridLayout.", "tests": "head 52ed2839: new __tests__/DashboardRenderer.refusedTile-26971.test.tsx (view-mode drop + 2 CONTROLs: design mode keeps the no-access state; a 400 keeps tile and error) and DatasetWidget.forbiddenState-10899.test.tsx: 2 files, 6/6 passed. Whole package vitest run packages/plugin-dashboard/ under the lock: 190/190 files, 1858 passed, 6 skipped. Ablation via scripts/ablation-replace.mjs --delete on the view-mode return-null line (anchor 1 -> 0, blob 873014377988 -> d71bae53b7ab): view-mode case red, controls green (1 failed / 2 passed); restored, blob == HEAD 873014377988, git diff HEAD empty. Relative-path import, no dist leg.", "gates": "closure build --filter '@object-ui/plugin-dashboard^...' VERDICT command-exit 0. dispatch-gates --commands: 34 commands, all exit 0 incl. node scripts/check-sdui-manifest.mjs; --ran: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. Extra: pnpm --filter @object-ui/plugin-dashboard run type-check exit 0; pnpm --dir ui run type-check:scripts exit 0; eslint on 3 changed source files exit 0; pnpm check:changeset-fixed exit 0; pnpm --dir ui run check:new-line-citations exit 0 (root spelling pnpm check:new-line-citations exits 254: script lives in ui/package.json only). CI check-runs: in_progress at report time.", "line_budget": "186 changed lines (+185 / -1), 4 files, under the 3000 human-merge threshold", "files_changed": [ ".changeset/26971-dashboard-refused-tile-left-out.md", "ui/packages/plugin-dashboard/src/DashboardRenderer.tsx", "ui/packages/plugin-dashboard/src/DatasetWidget.tsx", "ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.refusedTile-26971.test.tsx" ], "deviations": [ "First push refused by the pre-push card-trailer hook (Refs line in the commit body); commit reworded before any push landed.", "Commit trailers use the model-free pair per os-dev, not the harness-suggested model-named co-author." ], "dogfood_battery": "n/a — no ! in the PR title and Clause-②: no without a (narrowing) arm", "mcp_calls": "0", "api_writes": "2 — relay pr_create (POST pulls, PR #27065, assignee os-warren) and this report via post-stamped (POST issues/26971/comments); plus git push (not REST)", "body_amendments": [], "open_questions": [], "out_of_scope_findings": [ "carrier: domain:ui seat 2 (spec-lane card) · acceptance item 2: packages/spec/src/ui/dashboard.zod.ts, the DashboardWidget requiresObject describe, needs a sibling sentence: 'A dataset-bound widget whose query the server refuses as a read refusal (403 PERMISSION_DENIED) is left out of the dashboard for that viewer outside design mode; requiresObject stays an install gate.' · dedupe words: dashboard widget read refusal left out, requiresObject install-only describe", "carrier: domain:ui seat 2 · noted, not filed: an object-bound inline widget (object key, no dataset) is not pre-filtered by the viewer's read permission; the dispatch's optional DashboardView mayReadObject pre-filter would close it · dedupe words: dashboard object widget mayReadObject prefilter", "carrier: none · noted, not filed: a dashboard whose every tile is refused shows its header over an empty grid with no message" ] }objectstack-fleet commentedon Oct 28, 2026
Seat review: PR #27065 ACCEPT (record on the PR, head
52ed28395e), 2026-10-28T03:39Z. Acceptance 2 (spec prose) filed as #27068 (domain:spec). Landing waits for every check on the head to be green.objectstack-fleet commentedon Oct 28, 2026
Landing held ·
domain:ui#2·session_01JQwxbXpF772qWJjojJvRXw· 2026-10-28T03:46ZPR #27065 (ACCEPT 6460046270) is green on head
52ed28395e: 43 checks succeeded, and 10 were skipped. The skipped ones are the core / docs / dogfood path-filtered jobs, which aui/-only diff does not trigger.mergeable_stateis clean and the PR is not governed. Acceptance 2 (spec prose) is filed as #27068. This session's permission classifier refused the landing stroke (pr_ready+automerge_enablethrough the fleet-write relay, "Merge Without Review") on PR #27013 and on PR #27035 at 2026-10-28T02:42Z, so the seat does not retry it here. It asks the maintainer to mark the PR ready for review and enable auto-merge.objectstack-fleet commentedon Oct 28, 2026
Landed ·
domain:ui#2·session_01JQwxbXpF772qWJjojJvRXw· 2026-10-28T08:48ZRefs #26971, ui half)domain:spec), carries the describe sentenceClosing as completed: the ui half is delivered, and the spec half lives on #27068.
pm:dispatchedcleared.