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: a viewer whose analytics queries are all refused 403 sees a blank canvas under the title (CRM Pipeline Dashboard as a plain member), with no per-widget refusal #27491
Found in the 18.3.0 release-candidate browser walkthrough (wave anchor #27377, card W3-UX), at framework main @ c4c515ad, Console built in-repo from the same commit.
Repro (fresh boot)
Boot examples/app-crm with --seed-admin. As admin, invite member1@example.com with role member. Sign that address up and sign in as the member through the form. Viewport 1440×900.
The CRM landing says "Nothing in CRM is available to you yet". The sidebar still offers Analytics → Pipeline. Click it.
Expected
Each widget draws its refusal ("You don't have access to this data"), which is the behaviour #26971 describes for a tile whose dataset the viewer cannot read. Or the dashboard is not offered.
Actual
The title, the description and Refresh All render over an empty canvas. There is no widget frame, no message and no error. The network shows POST /api/v1/analytics/dataset/query answering 403, and the page swallows it. The same member gets a proper refusal state on the Opportunities list.
Read fresh. A dashboard viewer whose analytics queries are all refused 403 sees a blank canvas, with no per-widget refusal. The page swallows the 403, while the list sibling shows a proper refusal state. Wrong state shown: p2.
Unblocked · 2026-10-29T20:55Z: the blocker landed. PR #27560 (#27460) merged at 2026-10-29T20:03Z. Labels move from pm:blocked to pm:queue. Seat 2 dispatches this card next, and its Claim: follows.
Claim: PM loop round 1 · 2026-10-29T20:57Z
Session: session_01Utp6CY2n1JmZZKrHgtp9jC (seat; implementation by a card thread, mode:thread)
Account: os-bill
Branch: claude/project-thread-x22lgi (the card thread's harness push branch, self-reported — the measured platform exception to claude/issue-N-slug)
Worktree: objectstack-issue-27491
Domain: domain:ui
Seat: domain:ui#2
File surface: ui/packages/plugin-dashboard/src/DashboardRenderer.tsx, DatasetWidget.tsx, their tests, i18n keys if needed, a changeset; ⛔ packages/spec/**, every server file, examples/**, the console nav
Container & model: M, mode:thread, model: TIER_DEFAULT (dispatch-gates --tier: no path-derived mandate), effort: high
Clause-②: no (expected; the dev measures from the diff)
Metadata-protocol: none (expected; the dev measures from the diff)
Responsibility: the dashboard renderer's face for a viewer whose every dataset tile is refused 403 | the server's 403 is the enforcement and a fixed input | every member who opens a dashboard none of whose datasets they can read, today
Thread-read: 6508127342
Premise: questioned by the seat: #26971 made a refused tile drop out for a viewer (DashboardRenderer.tsxrefusedTiles), so the all-refused blank may be that rule, not a swallowed 403; step 1 of the order measures it and returns needs_decision if the triage ruling and #26971 collide
Serial constraints cleared: #27560 (#27460, same DatasetWidget.tsx) merged 2026-10-29T20:03Z; sibling card #27563 (seat 2) may touch DatasetWidget.tsx, so whichever PR lands second rebases
Dispatch order: /mnt/project-files/ui-seat2/dispatch-27491.md in the Claude Code project (not on GitHub); its ruling section quotes the triage text above verbatim.
{
"issue": 27491,
"status": "needs_decision",
"branch": "claude/project-thread-x22lgi",
"pr": null,
"session": "session_01Utp6CXTsLkvW2GpvDwNQre (card thread; seat session_01Utp6CY2n1JmZZKrHgtp9jC)",
"premise_still_valid": false,
"summary": "The blank canvas is the #26971 rule working as built, not a swallowed 403. On origin/main 525ea363 the CRM pipeline_dashboard has six widgets, all bound to dataset opportunity_metrics. For a member, POST /api/v1/analytics/dataset/query answers 403 PERMISSION_DENIED; the adapter throws AnalyticsForbiddenError (httpStatus 403, code PERMISSION_DENIED, data-objectstack index.ts ~L6965-6976); DatasetWidget classifies it forbidden and calls onForbidden (DatasetWidget.tsx ~L972-974); DashboardRenderer then returns null for that tile outside design mode (DashboardRenderer.tsx L1105). With all six refused nothing is left under the header. The spec now documents that hide: packages/spec/src/ui/dashboard.zod.ts L1095-1100 ('is left out of the dashboard for that viewer'), landed through #27068, and #26971's own report listed the all-refused blank as noted, not filed. The triage ruling 'Render each widget's refusal with the existing #26971 tile refusal state. No new dashboard-level error path.' cannot be met as written: the #26971 state for a viewer is the tile left out, and drawing each refusal contradicts the spec docblock. No code written; product choice below.",
"tests": "Scratch measurement only (file deleted, never committed): plugin-dashboard test rendering the real examples/app-crm pipeline_dashboard through the real ObjectStackAdapter, fetch stubbed to answer every dataset query 403 with code PERMISSION_DENIED. Viewer render: 6 queries for 6 widgets, 0 forbidden faces, 0 widget titles left, container text empty. CONTROL design mode: 6 forbidden faces. Ablation via scripts/ablation-replace.mjs --delete on DashboardRenderer.tsx L1105 (anchor 1 -> 0, blob 873014377988 -> 3fd7eb20f874): the viewer case goes red (the six titled frames stay), design control green; restored, blob == HEAD 873014377988, git diff HEAD empty. So the blank has no other source. Closure build --filter '@object-ui/plugin-dashboard^...' VERDICT command-exit 0; measurement run VERDICT command-exit 0.",
"gates": "none run: no diff (needs_decision). check-sdui-manifest not applicable without a change.",
"dogfood_battery": "n/a - no PR",
"line_budget": "0 changed lines",
"files_changed": [],
"deviations": [
"Measured with a unit equivalent (real dashboard definition + real adapter, stubbed fetch), not a live browser run on app-crm."
],
"mcp_calls": "5 GitHub MCP reads (issue_read get x2, get_comments x2, search_issues x1); no GitHub MCP write tool.",
"api_writes": "1 - this os-dev-report comment via post-stamped (POST issues/27491/comments). No push, no PR.",
"open_questions": [
{
"question": "A member opens a dashboard none of whose data they may read. What should they see?",
"governing_text": "triage 6495993447 ('Render each widget's refusal with the existing #26971 tile refusal state. No new dashboard-level error path.'); spec dashboard.zod.ts L1095-1100 and the requiresObject describe L1162 (a refused dataset tile 'is left out of the dashboard for that viewer'); #26971 dispatch 6459370946 (seat chose 'leave the tile out').",
"options": {
"A": "Keep the per-tile hide. When every tile has been left out for the viewer, DashboardRenderer draws one 'You don't have access' panel in the canvas, the same face DashboardView already shows for a refused dashboard (dashboard-access-denied). ui lane, 1 PR in plugin-dashboard plus one new message key in ten locales; precedent #27065 (+185/-1). Agrees with the spec; overrides the triage's 'no new dashboard-level path' wording.",
"B": "When every tile is refused, draw each tile's own refusal face (the #10899 state design mode shows); a partial refusal keeps the hide. Literal match to the triage and the card's Expected. Contradicts the spec docblock, so it needs a spec-lane docblock change too (precedent #27068), and makes a tile's face depend on its siblings: the last refused tile flips all of them back on.",
"C": "Do not offer the dashboard: the app declares the dashboard-level audience gate (requiredPermissions, #22611/#22639) on pipeline_dashboard and the nav item, so a member never reaches it. App lane (examples/app-crm), outside this card. Fixes CRM only; any other dashboard whose tiles are all refused still shows the blank."
},
"four_axis": {
"long_term": "A keeps one rule (a refused tile leaves) and adds the one canvas state a page with nothing left needs, in the words the console already uses for a refused dashboard; mainstream BI does the same (a report whose data you cannot read says so once). B adds a sibling-dependent special case and reopens a documented spec sentence. C is the right complement for app authors but does not cover the renderer case.",
"business_need": "Today every plain member who clicks Analytics > Pipeline in CRM sees a blank page (card repro). A and B both give them a statement; C removes the entry for CRM only.",
"ai_error_prevention": "A: an all-refused dashboard says so loudly, no metadata involved. B: a reader sees N identical refusals, still loud. C relies on each app author declaring the gate; a forgotten gate falls back to the silent blank.",
"startup_scope": "A: 1 PR, one new i18n key, no spec. B: 2 PRs across two lanes. C: 1 small app PR, no platform change."
},
"recommendation": "A. Only axis 1 picks A; axes 2-4 do not flip it. C can follow separately as app hygiene. Confidence gap: not run live on app-crm in a browser."
}
],
"out_of_scope_findings": []
}
Ruling: what a viewer sees on a dashboard whose every dataset tile the server refuses (project discussion thread 「决定仪表盘全部无权限时的显示」) · letter A · maintainer 「A」 2026-10-29T21:41Z
The maintainer's own ruling, given in the project thread at 2026-10-29T21:39Z on the review thread's six-section analysis (/mnt/project-files/decisions/27491-仪表盘全部无权限-呈批.md, recommendation A, which the card thread's report 6508422494 and domain:ui#2's brief also recommended). Marker: maintainer-ruled, presented by the review thread; not a proxy ruling. It settles the collision between triage 6495993447 and the #26971 hide rule the spec documents.
What was re-verified (code read at d93d9be5 on main)
The blank is the 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 rule, not a swallowed 403. DatasetWidget.tsx L972–974 classifies the refusal (classifyLoadError(e) === 'forbidden') and calls onForbidden; DashboardRenderer.tsx L393 records the tile in refusedTiles and L1105 returns null for it outside design mode. The CRM pipeline_dashboard (examples/app-crm/src/dashboards/pipeline.dashboard.ts) has six widgets, all on opportunity_metrics, so a member refused that dataset is left with the header over nothing. The card thread's unit equivalent (real definition, real adapter, fetch stubbed to 403) and its ablation of L1105 confirm there is no second source.
The face to reuse exists: DashboardView.tsx L238–243 draws dashboard-access-denied (lock icon, dashboard.widgetForbiddenTitle, the server's sentence) when the by-name read of a dashboard is refused (objectui#12112).
Spec dashboard.zod.ts L1095–1102 and L1160–1163: a refused dataset tile is left out for that viewer outside design mode; the server's refusal is the only verdict; the renderer never guesses a permission.
AGENTS.md Prime Directive 12 (a wrong state is refused loudly, with a prescription; never silently).
The ruling: A
The per-tile hide stays as the spec states it. When, outside design mode, the renderer has at least one refused tile and no widget left to draw, it draws one canvas-level no-access panel in the same face DashboardView already uses for a refused dashboard. A partial refusal keeps today's behaviour (the refused tiles leave, the rest reflow, no message).
Fixed here:
Trigger: !designMode && refusedTiles.size > 0 && <zero widgets rendered>. Not "every dataset tile refused": a dashboard that still has a non-dataset widget (text, iframe, object-bound inline) draws those and no panel; a dashboard with zero widgets to begin with draws no panel (refusedTiles is empty).
Face: the Empty composition of DashboardView's dashboard-access-denied (lock icon; title dashboard.widgetForbiddenTitle; one NEW message key dashboard.allWidgetsForbiddenMessage, en "You don't have permission to view the data behind this dashboard.", in all ten locales under ui/packages/i18n/src/locales/); data-testid="dashboard-all-widgets-forbidden". Header title, description and "Refresh All" stay as they are (a refused tile is not re-queried until remount, fix(plugin-dashboard): leave a tile out when the server refuses its data to the viewer #27065; unchanged).
Pins (one new test file beside DashboardRenderer.refusedTile-26971.test.tsx): all refused → panel, no tile title, no per-tile face; partial refusal → no panel, readable tile present; CONTROL design mode, all refused → no panel, every tile's objectui#10899 face present; CONTROL non-refusal failure (400) on every tile → no panel, error alerts present.
Surface: ui/packages/plugin-dashboard/src/DashboardRenderer.tsx, the new test, ten locale files, changesets @object-ui/plugin-dashboard patch and @object-ui/i18n patch. ⛔ No spec change, no DatasetWidget change, no DashboardView change, no examples/**.
C runs beside A, not instead: domain:ui#2 files one S card in the app lane: examples/app-crm declares requiredPermissions on pipeline_dashboard and on nav_pipeline_dashboard, with a capability one of the CRM sales permission sets actually grants; acceptance = a member's sidebar has no Pipeline entry and a direct link gets dashboard-access-denied. Not paired with this PR.
⛔ Not: per-tile faces when all are refused (B: contradicts the spec sentence and makes a tile's face depend on its siblings); any client-side permission guess that hides a nav entry or a dashboard before the server answers (D); any change to the partial-refusal behaviour or its 26971 pins.
② Measured business pull. Found by the 18.3.0 RC walkthrough ([sweep] v18.3.0 release test sweep #27377 W3-UX); every member without a permission set hits it on any dashboard whose tiles share one dataset, which the CRM example's Pipeline is.
③ AI-error prevention. A fails loud: one panel, no metadata read, no client guess; it appears only when the renderer has drawn nothing. B's state machine gains a dimension (the last refused tile flips its siblings). C depends on every app author remembering the gate; a forgotten gate falls back to today's blank.
domain:ui#2 files the C card (item 5) through the relay; its wording is that card's.
Spec-lane hygiene, not this card: the JSDoc above DashboardSchema.requiredPermissions (dashboard.zod.ts ≈L1779, "including why it says the gate is not enforced yet") is stale — the describe already says the server enforces it. One sentence, to ride along with the next spec card.
Not reopened: the per-tile hide; the 26971 pins; DatasetWidget's own face; "Refresh All" behaviour on refused tiles.
Ruling 6508997538 item 5 is done: the C card is filed as #27735 (app lane, examples/app-crm Pipeline audience gate), not paired with this card. It goes to triage unlabelled. Measured while filing: the CRM permission sets grant no capability today, so the gate needs one added to the sales set(s). The card says so.
{
"issue": 27491,
"status": "done",
"branch": "claude/project-thread-x22lgi",
"pr": "https://github.com/objectstack-ai/objectstack/pull/27777",
"head": "24bc0b979f6d04c7f2e683aecf7902e3b73e47d5",
"session": "session_01Utp6CXTsLkvW2GpvDwNQre (card thread; seat session_01Utp6CY2n1JmZZKrHgtp9jC)",
"premise_still_valid": true,
"summary": "Ruling A (Ruling-ref 6508997538), items 1-4. DashboardRenderer renders its tiles once per layout (desktopTiles; metricTiles/otherTiles on mobile) and, outside design mode, when at least one tile is refused and every rendered tile is null, draws one Empty panel (lock icon, dashboard.widgetForbiddenTitle, new dashboard.allWidgetsForbiddenMessage, data-testid dashboard-all-widgets-forbidden, col-span-full) after the tiles; header, description and Refresh All untouched. The refusal drop is the only null a tile renders, so the trigger is exactly nothing left. New key in all ten locales. No spec, DatasetWidget, DashboardView or examples change. The first report of this card (6508422494) carried the premise reading that led to the ruling.",
"tests": "New ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.allTilesRefused-27491.test.tsx, 5/5: all refused (panel, both tiles queried, no tile title, no per-tile face); same on mobile (2-column grid drawn); partial refusal (no panel, readable tile present); CONTROL design mode (no panel, 2 per-tile 10899 faces); CONTROL 400 on every tile (no panel, 2 error alerts). With the 26971 and 10899 files: 3 files, 11 tests passed. Ablation via scripts/ablation-replace.mjs on 76be3079: trigger condition replaced by false (anchor 1 -> 0, blob c9adcbb0354a -> eb6c069cbd78): 2 failed / 3 passed (the two all-refused cases red); restored, blob == HEAD c9adcbb0354a, git diff HEAD empty; relative import, no dist leg. At 24bc0b97 (after merging main): closure build --filter '@object-ui/plugin-dashboard^...' VERDICT command-exit 0; pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-dashboard/ packages/i18n/ under the lock: Test Files 286 passed (286), Tests 3266 passed, 19 skipped, VERDICT command-exit 0.",
"gates": "At 24bc0b97: dispatch-gates --commands --repo objectstack-ai/objectstack derived 34; all run, --ran: 34 derived, 34 run, 0 NOT-MEASURED. node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0; node scripts/check-adr-0087-registration.mjs --self-test :: exit 0; node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0; node scripts/check-closing-keyword-parity.mjs :: exit 0; node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0; node scripts/check-comment-mask-corpus.mjs :: exit 0; node scripts/check-empty-changeset.mjs --base origin/main :: exit 0; node scripts/check-empty-changeset.mjs --self-test :: exit 0; node scripts/check-sdui-manifest.mjs :: exit 0; node scripts/check-sdui-manifest.mjs --self-test :: exit 0; node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0; node scripts/release-pending-publish.mjs --self-test :: exit 0; pnpm --dir ui exec vitest run scripts/ :: exit 0; pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0; pnpm check:changeset-gate-self-tests :: exit 0; pnpm check:closed-ceremony-text :: exit 0; pnpm check:cross-package-test-inputs :: exit 0; pnpm check:dispatcher-error-vocabulary :: exit 0; pnpm check:driver-memory-census :: exit 0; pnpm check:engine-double-contract :: exit 0; pnpm check:gitlink-declared :: exit 0; pnpm check:nul-bytes :: exit 0; pnpm check:pm-changeset-deadline-census :: exit 0; pnpm check:published-files :: exit 0; pnpm check:query-options-erasure :: exit 0; pnpm check:refd-timer-probe :: exit 0; pnpm check:skill-identifier-liveness :: exit 0; pnpm check:test-source-alias :: exit 0; pnpm check:tier-file-adoption :: exit 0; pnpm check:type-check-coverage :: exit 0; pnpm check:type-check-debt :: exit 0; pnpm check:undeclared-dep-imports :: exit 0; pnpm check:watch-hint-literal :: exit 0; pnpm check:where-matcher :: exit 0. Outside the derivation: pnpm --filter @object-ui/plugin-dashboard run type-check :: exit 0; pnpm --filter @object-ui/i18n run type-check :: exit 0; eslint on DashboardRenderer.tsx and the new test :: exit 0; pnpm --dir ui run check:new-line-citations :: exit 0. CI check-runs: in progress at report time.",
"dogfood_battery": "n/a - no ! in the PR title and no narrowing arm",
"line_budget": "232 changed lines (+227 / -5) vs origin/main: DashboardRenderer.tsx +31/-5, new test +169, ten locale files +1 each, changeset +17; under the 3000 human-merge threshold",
"files_changed": [
".changeset/27491-dashboard-all-tiles-refused-panel.md",
"ui/packages/plugin-dashboard/src/DashboardRenderer.tsx",
"ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.allTilesRefused-27491.test.tsx",
"ui/packages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.ts"
],
"deviations": [
"en string uses the typographic apostrophe (You don’t ...) like the neighbouring dashboard.* strings; the ruling quoted it with a straight one.",
"Commit trailers use the model-free pair per AGENTS.md, not the harness-suggested model-named co-author.",
"The harness's post-PR assignee/reviewer step was skipped, per the maintainer's routing rule (no assignee, no reviewer before seat ACCEPT + review).",
"No docs page edited (ui/AGENTS.md #2): the dispatch's file surface excludes docs; the behaviour is the renderer's state, not a new authorable feature."
],
"mcp_calls": "6 GitHub MCP reads over the card's life (issue_read get x2, get_comments x2, search_issues x1, plus get_session which is not GitHub); no GitHub MCP write tool.",
"api_writes": "3 over the card's life - report 6508422494 (needs_decision) and this report via post-stamped (POST issues/27491/comments); relay pr_create (draft, no assignee, no reviewer) run 40969030266, body read back identical (6032 bytes). Plus git pushes of the branch (not REST).",
"open_questions": [],
"out_of_scope_findings": []
}
ACCEPT → PR #27777 comment 6510701042 (head 24bc0b97), under ruling A (6508997538). Contract review not owed (Clause-②: no, Metadata-protocol: none). Landing through the merge queue. Ruling item 5 is filed as #27735.
Amendment to ruling 6508997538 item 5 · 2026-10-30T01:37Z
Item 5's mechanism (a declared capability required on pipeline_dashboard and its nav entry) is replaced by the maintainer's ruling on #27735 (comment 6512784198, letter G, 「同意」 at 2026-10-30T01:34Z): the CRM hides the Pipeline nav entry with visible: "current_user.can('crm_opportunity', 'read')", declares no capability and gates no dashboard. Measured reason: a stack-level capability declaration makes the CRM refuse an unmanaged install (UNMANAGED_INSTALL_UNCOPYABLE, the dogfood specimen; 11 failures), and the gate closed the dashboard to the seeded administrator, in Studio included. Item 5's intent (the member is not offered the Pipeline) stands. Items 1–4 and 6 of 6508997538 (the all-refused panel, PR #27777) are unchanged; a direct link for the refused member lands on that panel.
Activity
objectstack-fleet commentedon Oct 29, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: 经营报表 | none | P②
Triage: p2 ·
domain:ui·area:reports· 2026-10-29T11:56Z⛔ Not a claim.
bug,pm:queue.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC
Blocked-by: #27560 · 2026-10-29T19:32Z
plugin-dashboard/src/DatasetWidget.tsx, where the per-widget refusal face belongs. A claim now would race it on the same file.pm:queue→pm:blocked. Not claimed; no work done.pm:queueand claims from the newmain. On the maintainer's word (2026-10-29T19:27Z), seat 2 does not take fix(plugin-charts): a two-dimension sankey draws every flow, not only the first group's #27560 over. Seat 2 stays seated (the wind-down was withdrawn) and resumes this card when fix(plugin-charts): a two-dimension sankey draws every flow, not only the first group's #27560 lands.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC
Unblocked · 2026-10-29T20:55Z: the blocker landed. PR #27560 (#27460) merged at 2026-10-29T20:03Z. Labels move from
pm:blockedtopm:queue. Seat 2 dispatches this card next, and itsClaim:follows.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC
Claim: PM loop round 1 · 2026-10-29T20:57Z
Session:
session_01Utp6CY2n1JmZZKrHgtp9jC(seat; implementation by a card thread,mode:thread)Account:
os-billBranch:
claude/project-thread-x22lgi(the card thread's harness push branch, self-reported — the measured platform exception toclaude/issue-N-slug)Worktree:
objectstack-issue-27491Domain:
domain:uiSeat:
domain:ui#2File surface:
ui/packages/plugin-dashboard/src/DashboardRenderer.tsx,DatasetWidget.tsx, their tests, i18n keys if needed, a changeset; ⛔packages/spec/**, every server file,examples/**, the console navContainer & model:
M,mode:thread,model: TIER_DEFAULT(dispatch-gates --tier: no path-derived mandate),effort: highClause-②: no (expected; the dev measures from the diff)
Metadata-protocol: none (expected; the dev measures from the diff)
Responsibility: the dashboard renderer's face for a viewer whose every dataset tile is refused 403 | the server's 403 is the enforcement and a fixed input | every member who opens a dashboard none of whose datasets they can read, today
Thread-read: 6508127342
Premise: questioned by the seat: #26971 made a refused tile drop out for a viewer (
DashboardRenderer.tsxrefusedTiles), so the all-refused blank may be that rule, not a swallowed 403; step 1 of the order measures it and returnsneeds_decisionif the triage ruling and #26971 collideSerial constraints cleared: #27560 (#27460, same
DatasetWidget.tsx) merged 2026-10-29T20:03Z; sibling card #27563 (seat 2) may touchDatasetWidget.tsx, so whichever PR lands second rebasesDispatch order:
/mnt/project-files/ui-seat2/dispatch-27491.mdin the Claude Code project (not on GitHub); its ruling section quotes the triage text above verbatim.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC
os-dev-report
{ "issue": 27491, "status": "needs_decision", "branch": "claude/project-thread-x22lgi", "pr": null, "session": "session_01Utp6CXTsLkvW2GpvDwNQre (card thread; seat session_01Utp6CY2n1JmZZKrHgtp9jC)", "premise_still_valid": false, "summary": "The blank canvas is the #26971 rule working as built, not a swallowed 403. On origin/main 525ea363 the CRM pipeline_dashboard has six widgets, all bound to dataset opportunity_metrics. For a member, POST /api/v1/analytics/dataset/query answers 403 PERMISSION_DENIED; the adapter throws AnalyticsForbiddenError (httpStatus 403, code PERMISSION_DENIED, data-objectstack index.ts ~L6965-6976); DatasetWidget classifies it forbidden and calls onForbidden (DatasetWidget.tsx ~L972-974); DashboardRenderer then returns null for that tile outside design mode (DashboardRenderer.tsx L1105). With all six refused nothing is left under the header. The spec now documents that hide: packages/spec/src/ui/dashboard.zod.ts L1095-1100 ('is left out of the dashboard for that viewer'), landed through #27068, and #26971's own report listed the all-refused blank as noted, not filed. The triage ruling 'Render each widget's refusal with the existing #26971 tile refusal state. No new dashboard-level error path.' cannot be met as written: the #26971 state for a viewer is the tile left out, and drawing each refusal contradicts the spec docblock. No code written; product choice below.", "tests": "Scratch measurement only (file deleted, never committed): plugin-dashboard test rendering the real examples/app-crm pipeline_dashboard through the real ObjectStackAdapter, fetch stubbed to answer every dataset query 403 with code PERMISSION_DENIED. Viewer render: 6 queries for 6 widgets, 0 forbidden faces, 0 widget titles left, container text empty. CONTROL design mode: 6 forbidden faces. Ablation via scripts/ablation-replace.mjs --delete on DashboardRenderer.tsx L1105 (anchor 1 -> 0, blob 873014377988 -> 3fd7eb20f874): the viewer case goes red (the six titled frames stay), design control green; restored, blob == HEAD 873014377988, git diff HEAD empty. So the blank has no other source. Closure build --filter '@object-ui/plugin-dashboard^...' VERDICT command-exit 0; measurement run VERDICT command-exit 0.", "gates": "none run: no diff (needs_decision). check-sdui-manifest not applicable without a change.", "dogfood_battery": "n/a - no PR", "line_budget": "0 changed lines", "files_changed": [], "deviations": [ "Measured with a unit equivalent (real dashboard definition + real adapter, stubbed fetch), not a live browser run on app-crm." ], "mcp_calls": "5 GitHub MCP reads (issue_read get x2, get_comments x2, search_issues x1); no GitHub MCP write tool.", "api_writes": "1 - this os-dev-report comment via post-stamped (POST issues/27491/comments). No push, no PR.", "open_questions": [ { "question": "A member opens a dashboard none of whose data they may read. What should they see?", "governing_text": "triage 6495993447 ('Render each widget's refusal with the existing #26971 tile refusal state. No new dashboard-level error path.'); spec dashboard.zod.ts L1095-1100 and the requiresObject describe L1162 (a refused dataset tile 'is left out of the dashboard for that viewer'); #26971 dispatch 6459370946 (seat chose 'leave the tile out').", "options": { "A": "Keep the per-tile hide. When every tile has been left out for the viewer, DashboardRenderer draws one 'You don't have access' panel in the canvas, the same face DashboardView already shows for a refused dashboard (dashboard-access-denied). ui lane, 1 PR in plugin-dashboard plus one new message key in ten locales; precedent #27065 (+185/-1). Agrees with the spec; overrides the triage's 'no new dashboard-level path' wording.", "B": "When every tile is refused, draw each tile's own refusal face (the #10899 state design mode shows); a partial refusal keeps the hide. Literal match to the triage and the card's Expected. Contradicts the spec docblock, so it needs a spec-lane docblock change too (precedent #27068), and makes a tile's face depend on its siblings: the last refused tile flips all of them back on.", "C": "Do not offer the dashboard: the app declares the dashboard-level audience gate (requiredPermissions, #22611/#22639) on pipeline_dashboard and the nav item, so a member never reaches it. App lane (examples/app-crm), outside this card. Fixes CRM only; any other dashboard whose tiles are all refused still shows the blank." }, "four_axis": { "long_term": "A keeps one rule (a refused tile leaves) and adds the one canvas state a page with nothing left needs, in the words the console already uses for a refused dashboard; mainstream BI does the same (a report whose data you cannot read says so once). B adds a sibling-dependent special case and reopens a documented spec sentence. C is the right complement for app authors but does not cover the renderer case.", "business_need": "Today every plain member who clicks Analytics > Pipeline in CRM sees a blank page (card repro). A and B both give them a statement; C removes the entry for CRM only.", "ai_error_prevention": "A: an all-refused dashboard says so loudly, no metadata involved. B: a reader sees N identical refusals, still loud. C relies on each app author declaring the gate; a forgotten gate falls back to the silent blank.", "startup_scope": "A: 1 PR, one new i18n key, no spec. B: 2 PRs across two lanes. C: 1 small app PR, no platform change." }, "recommendation": "A. Only axis 1 picks A; axes 2-4 do not flip it. C can follow separately as app hygiene. Confidence gap: not run live on app-crm in a browser." } ], "out_of_scope_findings": [] }Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
director#1 · os-bill · session_01Utp6CavdFhhfMVhuGjp7tP
Ruling: what a viewer sees on a dashboard whose every dataset tile the server refuses (project discussion thread 「决定仪表盘全部无权限时的显示」) · letter A · maintainer 「A」 2026-10-29T21:41Z
The maintainer's own ruling, given in the project thread at 2026-10-29T21:39Z on the review thread's six-section analysis (
/mnt/project-files/decisions/27491-仪表盘全部无权限-呈批.md, recommendation A, which the card thread's report6508422494anddomain:ui#2's brief also recommended). Marker:maintainer-ruled, presented by the review thread; not a proxy ruling. It settles the collision between triage6495993447and the #26971 hide rule the spec documents.What was re-verified (code read at
d93d9be5onmain)DatasetWidget.tsxL972–974 classifies the refusal (classifyLoadError(e) === 'forbidden') and callsonForbidden;DashboardRenderer.tsxL393 records the tile inrefusedTilesand L1105 returnsnullfor it outside design mode. The CRMpipeline_dashboard(examples/app-crm/src/dashboards/pipeline.dashboard.ts) has six widgets, all onopportunity_metrics, so a member refused that dataset is left with the header over nothing. The card thread's unit equivalent (real definition, real adapter, fetch stubbed to 403) and its ablation of L1105 confirm there is no second source.packages/spec/src/ui/dashboard.zod.tsL1095–1102 ("A viewer who may not read a tile's data does not see the tile … is left out of the dashboard for that viewer") and therequiresObjectdescribe at L1160–1163, landed by spec(ui): the dashboard widget describe does not say that a dataset tile the server refuses as a read refusal is left out for that viewer;requiresObjectkeeps its install-only meaning #27068 / PR fix(spec)!: an action's newTabUrl is a backend path starting with /; dashboard read-refusal and citation prose #27252.DashboardView.tsxL238–243 drawsdashboard-access-denied(lock icon,dashboard.widgetForbiddenTitle, the server's sentence) when the by-name read of a dashboard is refused (objectui#12112).requiredPermissions(ADR-0066, spec (17.7.0): a list view or dashboard cannot be limited to an audience, andrequiredPermissionshas no any-of form — an app cannot show each audience only its own views and boards #22611/rest(/meta read gate): enforce a list view's and a dashboard'srequiredPermissionson the list read and the by-name read, so each audience is served only its own views and boards (the rest half of #22611) #22639) IS enforced by the server (packages/rest/src/meta-item-read-gate.ts,audience-permissionarm: not listed, refused by name), and the nav item guard appliesrequiredPermissionstoo.examples/app-crmdeclares neither onpipeline_dashboardnor onnav_pipeline_dashboard(crm.app.tsL33).navObjectReadGate.tsprunes onlytype: 'object'nav entries, which is why the CRM landing says nothing is available while the sidebar still offers Pipeline.Governing text
dashboard.zod.tsL1095–1102 and L1160–1163: a refused dataset tile is left out for that viewer outside design mode; the server's refusal is the only verdict; the renderer never guesses a permission.6456651336(no new widget key); PR fix(plugin-dashboard): leave a tile out when the server refuses its data to the viewer #27065 Acceptance notes (all-refused = empty grid, noted).6495993447on this card, read as above.The ruling: A
The per-tile hide stays as the spec states it. When, outside design mode, the renderer has at least one refused tile and no widget left to draw, it draws one canvas-level no-access panel in the same face
DashboardViewalready uses for a refused dashboard. A partial refusal keeps today's behaviour (the refused tiles leave, the rest reflow, no message).Fixed here:
!designMode && refusedTiles.size > 0 && <zero widgets rendered>. Not "every dataset tile refused": a dashboard that still has a non-dataset widget (text, iframe, object-bound inline) draws those and no panel; a dashboard with zero widgets to begin with draws no panel (refusedTilesis empty).Emptycomposition ofDashboardView'sdashboard-access-denied(lock icon; titledashboard.widgetForbiddenTitle; one NEW message keydashboard.allWidgetsForbiddenMessage, en "You don't have permission to view the data behind this dashboard.", in all ten locales underui/packages/i18n/src/locales/);data-testid="dashboard-all-widgets-forbidden". Header title, description and "Refresh All" stay as they are (a refused tile is not re-queried until remount, fix(plugin-dashboard): leave a tile out when the server refuses its data to the viewer #27065; unchanged).DashboardRenderer.refusedTile-26971.test.tsx): all refused → panel, no tile title, no per-tile face; partial refusal → no panel, readable tile present; CONTROL design mode, all refused → no panel, every tile's objectui#10899 face present; CONTROL non-refusal failure (400) on every tile → no panel, error alerts present.ui/packages/plugin-dashboard/src/DashboardRenderer.tsx, the new test, ten locale files, changesets@object-ui/plugin-dashboardpatch and@object-ui/i18npatch. ⛔ No spec change, noDatasetWidgetchange, noDashboardViewchange, noexamples/**.domain:ui#2files one S card in the app lane:examples/app-crmdeclaresrequiredPermissionsonpipeline_dashboardand onnav_pipeline_dashboard, with a capability one of the CRM sales permission sets actually grants; acceptance = a member's sidebar has no Pipeline entry and a direct link getsdashboard-access-denied. Not paired with this PR.Why A, on the four axes (business standpoint)
Only-① pick: A. Do ②③④ flip it: no. Effort for the chosen letter: S.
① 长远:「读不到就拿掉」已经 #26971 → #27065 → #27252 三道进了 spec,A 不动它,只补它没覆盖的空集(一个都没剩时说一句);B 为边角重开刚合入的 spec 句子,且卡片的面取决于兄弟;C 是应用作者的事,盖不住渲染器。
② 拉动:18.3.0 RC 走查实测(#27377 W3-UX);每个没拿到权限集的成员,在任何「数据集全在同一对象」的仪表盘上都会撞到,CRM Pipeline 六块一个数据集就是。
③ 防错:A 响亮且只在渲染器确实一个都没画时出现,不读元数据、不猜权限;B 状态机多一维;C 靠作者记得声明门,忘了就回到空白。
④ 不扩散:A 一包一分支一键,S;B 两车道;C 应用车道另立卡。
Prior rulings read: #26971 triage
6456651336、PR #27065 Acceptance notes、#27068 / PR #27252、objectui#12112(dashboard-access-denied面)、objectui#10899(卡片自己的无权限面)、#22611 / #22639 / ADR-0066(仪表盘requiredPermissions,服务器强制);本卡:triage6495993447、Claim6508167825、报告6508422494。Execution after the ruling
pm:dispatchedondomain:ui#2; the card thread resumes onclaude/project-thread-x22lgiwithRuling-ref: <this comment id>, builds items 1–4 above, then gates, draft PR (Fixes Dashboard: a viewer whose analytics queries are all refused 403 sees a blank canvas under the title (CRM Pipeline Dashboard as a plain member), with no per-widget refusal #27491) and os-dev-report per the seat's flow; non-governed surface, lands after ACCEPT + PASS per the seat's flow.domain:ui#2files the C card (item 5) through the relay; its wording is that card's.DashboardSchema.requiredPermissions(dashboard.zod.ts≈L1779, "including why it says the gate is not enforced yet") is stale — the describe already says the server enforces it. One sentence, to ride along with the next spec card.DatasetWidget's own face; "Refresh All" behaviour on refused tiles.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC
Ruling 6508997538 item 5 is done: the C card is filed as #27735 (app lane,
examples/app-crmPipeline audience gate), not paired with this card. It goes to triage unlabelled. Measured while filing: the CRM permission sets grant no capability today, so the gate needs one added to the sales set(s). The card says so.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC
os-dev-report
{ "issue": 27491, "status": "done", "branch": "claude/project-thread-x22lgi", "pr": "https://github.com/objectstack-ai/objectstack/pull/27777", "head": "24bc0b979f6d04c7f2e683aecf7902e3b73e47d5", "session": "session_01Utp6CXTsLkvW2GpvDwNQre (card thread; seat session_01Utp6CY2n1JmZZKrHgtp9jC)", "premise_still_valid": true, "summary": "Ruling A (Ruling-ref 6508997538), items 1-4. DashboardRenderer renders its tiles once per layout (desktopTiles; metricTiles/otherTiles on mobile) and, outside design mode, when at least one tile is refused and every rendered tile is null, draws one Empty panel (lock icon, dashboard.widgetForbiddenTitle, new dashboard.allWidgetsForbiddenMessage, data-testid dashboard-all-widgets-forbidden, col-span-full) after the tiles; header, description and Refresh All untouched. The refusal drop is the only null a tile renders, so the trigger is exactly nothing left. New key in all ten locales. No spec, DatasetWidget, DashboardView or examples change. The first report of this card (6508422494) carried the premise reading that led to the ruling.", "tests": "New ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.allTilesRefused-27491.test.tsx, 5/5: all refused (panel, both tiles queried, no tile title, no per-tile face); same on mobile (2-column grid drawn); partial refusal (no panel, readable tile present); CONTROL design mode (no panel, 2 per-tile 10899 faces); CONTROL 400 on every tile (no panel, 2 error alerts). With the 26971 and 10899 files: 3 files, 11 tests passed. Ablation via scripts/ablation-replace.mjs on 76be3079: trigger condition replaced by false (anchor 1 -> 0, blob c9adcbb0354a -> eb6c069cbd78): 2 failed / 3 passed (the two all-refused cases red); restored, blob == HEAD c9adcbb0354a, git diff HEAD empty; relative import, no dist leg. At 24bc0b97 (after merging main): closure build --filter '@object-ui/plugin-dashboard^...' VERDICT command-exit 0; pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-dashboard/ packages/i18n/ under the lock: Test Files 286 passed (286), Tests 3266 passed, 19 skipped, VERDICT command-exit 0.", "gates": "At 24bc0b97: dispatch-gates --commands --repo objectstack-ai/objectstack derived 34; all run, --ran: 34 derived, 34 run, 0 NOT-MEASURED. node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0; node scripts/check-adr-0087-registration.mjs --self-test :: exit 0; node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0; node scripts/check-closing-keyword-parity.mjs :: exit 0; node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0; node scripts/check-comment-mask-corpus.mjs :: exit 0; node scripts/check-empty-changeset.mjs --base origin/main :: exit 0; node scripts/check-empty-changeset.mjs --self-test :: exit 0; node scripts/check-sdui-manifest.mjs :: exit 0; node scripts/check-sdui-manifest.mjs --self-test :: exit 0; node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0; node scripts/release-pending-publish.mjs --self-test :: exit 0; pnpm --dir ui exec vitest run scripts/ :: exit 0; pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0; pnpm check:changeset-gate-self-tests :: exit 0; pnpm check:closed-ceremony-text :: exit 0; pnpm check:cross-package-test-inputs :: exit 0; pnpm check:dispatcher-error-vocabulary :: exit 0; pnpm check:driver-memory-census :: exit 0; pnpm check:engine-double-contract :: exit 0; pnpm check:gitlink-declared :: exit 0; pnpm check:nul-bytes :: exit 0; pnpm check:pm-changeset-deadline-census :: exit 0; pnpm check:published-files :: exit 0; pnpm check:query-options-erasure :: exit 0; pnpm check:refd-timer-probe :: exit 0; pnpm check:skill-identifier-liveness :: exit 0; pnpm check:test-source-alias :: exit 0; pnpm check:tier-file-adoption :: exit 0; pnpm check:type-check-coverage :: exit 0; pnpm check:type-check-debt :: exit 0; pnpm check:undeclared-dep-imports :: exit 0; pnpm check:watch-hint-literal :: exit 0; pnpm check:where-matcher :: exit 0. Outside the derivation: pnpm --filter @object-ui/plugin-dashboard run type-check :: exit 0; pnpm --filter @object-ui/i18n run type-check :: exit 0; eslint on DashboardRenderer.tsx and the new test :: exit 0; pnpm --dir ui run check:new-line-citations :: exit 0. CI check-runs: in progress at report time.", "dogfood_battery": "n/a - no ! in the PR title and no narrowing arm", "line_budget": "232 changed lines (+227 / -5) vs origin/main: DashboardRenderer.tsx +31/-5, new test +169, ten locale files +1 each, changeset +17; under the 3000 human-merge threshold", "files_changed": [ ".changeset/27491-dashboard-all-tiles-refused-panel.md", "ui/packages/plugin-dashboard/src/DashboardRenderer.tsx", "ui/packages/plugin-dashboard/src/__tests__/DashboardRenderer.allTilesRefused-27491.test.tsx", "ui/packages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.ts" ], "deviations": [ "en string uses the typographic apostrophe (You don’t ...) like the neighbouring dashboard.* strings; the ruling quoted it with a straight one.", "Commit trailers use the model-free pair per AGENTS.md, not the harness-suggested model-named co-author.", "The harness's post-PR assignee/reviewer step was skipped, per the maintainer's routing rule (no assignee, no reviewer before seat ACCEPT + review).", "No docs page edited (ui/AGENTS.md #2): the dispatch's file surface excludes docs; the behaviour is the renderer's state, not a new authorable feature." ], "mcp_calls": "6 GitHub MCP reads over the card's life (issue_read get x2, get_comments x2, search_issues x1, plus get_session which is not GitHub); no GitHub MCP write tool.", "api_writes": "3 over the card's life - report 6508422494 (needs_decision) and this report via post-stamped (POST issues/27491/comments); relay pr_create (draft, no assignee, no reviewer) run 40969030266, body read back identical (6032 bytes). Plus git pushes of the branch (not REST).", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC
ACCEPT → PR #27777 comment 6510701042 (head
24bc0b97), under ruling A (6508997538). Contract review not owed (Clause-②: no,Metadata-protocol: none). Landing through the merge queue. Ruling item 5 is filed as #27735.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC
Landed · 2026-10-29T23:43Z
e7dda2cc2cee08d36f61a77e82daf854f55b56afcompletedby the PR'sFixeslineState:
pm:dispatchedremoved; the assignee stays as the record of who carried it.Generated by Claude Code
objectstack-fleet commentedon Oct 30, 2026
director#1 · os-bill · session_01Utp6CavdFhhfMVhuGjp7tP
Amendment to ruling
6508997538item 5 · 2026-10-30T01:37ZItem 5's mechanism (a declared capability required on
pipeline_dashboardand its nav entry) is replaced by the maintainer's ruling on #27735 (comment6512784198, letter G, 「同意」 at 2026-10-30T01:34Z): the CRM hides the Pipeline nav entry withvisible: "current_user.can('crm_opportunity', 'read')", declares no capability and gates no dashboard. Measured reason: a stack-level capability declaration makes the CRM refuse an unmanaged install (UNMANAGED_INSTALL_UNCOPYABLE, the dogfood specimen; 11 failures), and the gate closed the dashboard to the seeded administrator, in Studio included. Item 5's intent (the member is not offered the Pipeline) stands. Items 1–4 and 6 of6508997538(the all-refused panel, PR #27777) are unchanged; a direct link for the refused member lands on that panel.Generated by Claude Code