diff --git a/.changeset/27585-studio-canvas-draft-dataset.md b/.changeset/27585-studio-canvas-draft-dataset.md
new file mode 100644
index 00000000000..a965184b037
--- /dev/null
+++ b/.changeset/27585-studio-canvas-draft-dataset.md
@@ -0,0 +1,14 @@
+---
+'@object-ui/data-objectstack': patch
+'@object-ui/app-shell': patch
+---
+
+fix(app-shell): the Studio canvas runs a report or a dashboard widget over a draft dataset before the package publishes (#27585)
+
+Clause-②: yes (widening)
+Metadata-protocol: none
+
+A Studio author who created a dataset and bound a report or a chart to it saw "Dataset … is not defined in this environment" on the canvas until the package published, while the dataset pickers beside it already offered the draft. The canvas's dataset query went to the published world, where a draft-only dataset does not exist.
+
+- **`queryDataset(dataset, selection, options?)`** in `@object-ui/data-objectstack` takes an optional `{ previewDrafts?: boolean }`. `true` asks for the draft world on that one request (the server resolves draft-overlaid dataset definitions and the pending seed drafts' rows, as `?preview=draft` does), the way `listViews(objectName, { previewDrafts })` asks for draft views. When the option is absent, the page URL's `?preview=draft` decides, as before; when it is given, it wins over the URL. A caller that passes nothing sends exactly the request it sent before.
+- **The Studio's report and dashboard previews** hand the runtime renderers a data source whose dataset queries carry `previewDrafts`, keyed on the signal the Studio's dataset pickers read (the dataset-authoring context). The query still names the dataset and the server resolves it; there is no second lookup. Setup's editors and every runtime page are unchanged and query the published world, or the one the page URL asks for. The server answers the draft world only to a caller who may read drafts; anyone else gets the published dataset, as if the flag were absent.
diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx
index eae5909f5dd..a7f08cf3f9e 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/previews/DashboardPreview.tsx
@@ -7,7 +7,8 @@
*
* Uses the same DashboardRenderer the runtime DashboardView uses, with
* the adapter from app-shell's AdapterProvider so widgets can query
- * live data. `designMode` is ON whenever the host is editing —
+ * live data; in the Studio its dataset queries read the draft world
+ * (`useCanvasDataSource`, objectstack#27585). `designMode` is ON whenever the host is editing —
* read-only / view mode falls back to a plain runtime preview so the
* canvas looks identical to what end users see.
*
@@ -18,7 +19,7 @@
import * as React from 'react';
import { Loader2, Pencil, X, Check } from 'lucide-react';
import type { DashboardComponentSchema, DashboardWidgetSchema } from '@object-ui/types';
-import { useAdapter } from '../../../providers/AdapterProvider.js';
+import { useCanvasDataSource } from './canvasDataSource.js';
import type { MetadataPreviewProps } from '../preview-registry.js';
import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell.js';
import { uniqueId, appendArray } from '../inspectors/_shared.js';
@@ -42,7 +43,7 @@ export function DashboardPreview({
onSelectionChange,
locale,
}: MetadataPreviewProps) {
- const adapter = useAdapter();
+ const dataSource = useCanvasDataSource();
const widgets: DashboardWidgetSchema[] = Array.isArray((draft as any).widgets)
? (draft as any).widgets
: [];
@@ -194,7 +195,7 @@ export function DashboardPreview({
);
export function ReportPreview({ draft, locale }: MetadataPreviewProps) {
- const adapter = useAdapter();
+ const dataSource = useCanvasDataSource();
const d = draft as any;
// ADR-0021 single-form: a report binds a semantic-layer dataset; a `joined`
@@ -61,7 +63,7 @@ export function ReportPreview({ draft, locale }: MetadataPreviewProps) {
}
>
-
+
diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource-27585.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource-27585.test.tsx
new file mode 100644
index 00000000000..d2edbb9cc27
--- /dev/null
+++ b/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource-27585.test.tsx
@@ -0,0 +1,160 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#27585 — the Studio canvas runs a report or a dataset widget over
+ * a dataset that is still a draft.
+ *
+ * Through the REAL `ObjectStackAdapter` (its fetch stubbed) and the real
+ * runtime renderers: the stub server answers the dataset by name only when the
+ * request asks for the draft world, as the dataset door does for a draft-only
+ * dataset, and a published-world request gets the door's own 404. Pinned:
+ * - in the Studio (its dataset-authoring context present) the report preview
+ * and the dashboard preview send `previewDrafts: true` and draw the rows;
+ * - outside it (Setup's editors) the same previews send no flag;
+ * - the draft source is the adapter for every other member, one per adapter.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, render, screen, waitFor } from '@testing-library/react';
+// Module-scope imports of the lazily loaded renderers, so the previews'
+// `React.lazy` factories resolve at once instead of racing the wait window
+// (ui/AGENTS.md, flaky-test discipline). Specifiers identical to the previews'.
+import '@object-ui/plugin-report';
+import '@object-ui/plugin-dashboard';
+import { ObjectStackAdapter, clearSharedDiscoveryCache } from '@object-ui/data-objectstack';
+import { StudioDatasetAuthoringContext } from '../../studio-design/datasetCreate';
+
+const current = vi.hoisted(() => ({ adapter: null as unknown }));
+vi.mock('../../../providers/AdapterProvider', async (importOriginal) => {
+ const mod = await importOriginal();
+ return { ...mod, useAdapter: () => current.adapter };
+});
+
+import { ReportPreview } from './ReportPreview';
+import { DashboardPreview } from './DashboardPreview';
+import { datasetDraftsSource } from './canvasDataSource';
+
+/** The dataset-query bodies the adapter posted. */
+let posted: Array> = [];
+
+function draftOnlyServer() {
+ return vi.fn(async (url: unknown, init?: { body?: string }) => {
+ const u = String(url);
+ if (u.includes('/api/v1/discovery')) {
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) };
+ }
+ if (u.includes('/api/v1/analytics/dataset/query')) {
+ const body = JSON.parse(init?.body ?? '{}') as Record;
+ posted.push(body);
+ if (body.previewDrafts !== true) {
+ return {
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ json: async () => ({ code: 'NOT_FOUND', message: `Dataset "${String(body.datasetName)}" not found.` }),
+ };
+ }
+ return {
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ json: async () => ({ rows: [{ region: 'north_draft_row', revenue: 7 }], fields: [] }),
+ };
+ }
+ return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) };
+ });
+}
+
+beforeEach(() => {
+ clearSharedDiscoveryCache();
+ posted = [];
+ current.adapter = new ObjectStackAdapter({
+ baseUrl: 'http://localhost:3000',
+ autoReconnect: false,
+ fetch: draftOnlyServer() as never,
+ });
+});
+afterEach(cleanup);
+
+const authoring = { packageId: 'com.acme.sales' };
+const inStudio = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+const report = { name: 'by_region', label: 'By region', dataset: 'draft_sales', rows: ['region'], values: ['revenue'] };
+const dashboard = {
+ name: 'sales_board',
+ label: 'Sales',
+ widgets: [
+ { id: 'w1', type: 'table', title: 'By region', dataset: 'draft_sales', dimensions: ['region'], values: ['revenue'], layout: { x: 0, y: 0, w: 6, h: 4 } },
+ ],
+};
+
+function renderReport(wrapper?: typeof inStudio) {
+ return render(, wrapper ? { wrapper } : undefined);
+}
+
+function renderDashboard(wrapper?: typeof inStudio) {
+ return render(
+ ,
+ wrapper ? { wrapper } : undefined,
+ );
+}
+
+describe('Studio canvas — a draft-only dataset draws (objectstack#27585)', () => {
+ it('the report preview asks for the draft world and draws its rows', async () => {
+ renderReport(inStudio);
+ expect(await screen.findByText('north_draft_row')).toBeInTheDocument();
+ expect(posted).not.toHaveLength(0);
+ for (const body of posted) expect(body).toMatchObject({ datasetName: 'draft_sales', previewDrafts: true });
+ });
+
+ it('the dashboard preview asks for the draft world and draws its rows', async () => {
+ renderDashboard(inStudio);
+ expect(await screen.findByText('north_draft_row')).toBeInTheDocument();
+ expect(posted).not.toHaveLength(0);
+ for (const body of posted) expect(body).toMatchObject({ datasetName: 'draft_sales', previewDrafts: true });
+ });
+});
+
+describe('outside the Studio the previews query the published world', () => {
+ it('the report preview sends no draft flag', async () => {
+ renderReport();
+ await waitFor(() => expect(posted).not.toHaveLength(0));
+ for (const body of posted) expect(body).not.toHaveProperty('previewDrafts');
+ expect(screen.queryByText('north_draft_row')).not.toBeInTheDocument();
+ });
+
+ it('the dashboard preview sends no draft flag', async () => {
+ renderDashboard();
+ await waitFor(() => expect(posted).not.toHaveLength(0));
+ for (const body of posted) expect(body).not.toHaveProperty('previewDrafts');
+ expect(screen.queryByText('north_draft_row')).not.toBeInTheDocument();
+ });
+});
+
+describe('datasetDraftsSource', () => {
+ it('is one source per adapter, and the adapter itself for every other member', () => {
+ const calls: unknown[] = [];
+ const adapter = {
+ name: 'stub',
+ queryDataset(this: unknown, ...args: unknown[]) {
+ calls.push({ self: this, args });
+ return Promise.resolve({ rows: [], fields: [] });
+ },
+ whoAmI(this: unknown) {
+ return this;
+ },
+ };
+ const source = datasetDraftsSource(adapter);
+ expect(datasetDraftsSource(adapter)).toBe(source);
+ expect(source.name).toBe('stub');
+ expect(source.whoAmI()).toBe(adapter);
+ void source.queryDataset('draft_sales', { measures: ['revenue'] }, { limit: 5 } as never);
+ expect(calls).toEqual([
+ { self: adapter, args: ['draft_sales', { measures: ['revenue'] }, { limit: 5, previewDrafts: true }] },
+ ]);
+ });
+});
diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource.ts b/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource.ts
new file mode 100644
index 00000000000..a3c4bd40de4
--- /dev/null
+++ b/ui/packages/app-shell/src/views/metadata-admin/previews/canvasDataSource.ts
@@ -0,0 +1,65 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectstack#27585 — the data source a report or dashboard preview hands the
+ * runtime renderer, and the world its dataset queries read.
+ *
+ * In the Studio, a dataset the author binds is a draft until the package
+ * publishes (objectstack#27518), and the pickers beside the canvas read the
+ * draft-overlaid catalog. The canvas runs the same binding in the same world:
+ * every `queryDataset` the renderers make (a report, a dataset widget, a
+ * dataset-bound chart, a filter bar's options) carries `previewDrafts`, the
+ * request's draft flag the draft preview sends as `?preview=draft`, and the
+ * server resolves the named dataset from the draft overlay. The query still
+ * names the dataset; nothing here looks a draft up a second way. Every other
+ * member is the adapter's own.
+ *
+ * The signal is the one the pickers read: the Studio's
+ * {@link StudioDatasetAuthoringContext}. Without it (Setup's editors) the
+ * adapter is handed back as it is, and so is every runtime page, which never
+ * renders these previews: both query the published world, or the one the page
+ * URL asks for.
+ *
+ * Internal to app-shell: not exported from the package root.
+ */
+
+import { useAdapter } from '../../../providers/AdapterProvider.js';
+import { useStudioDatasetAuthoring } from '../../studio-design/datasetCreate.js';
+
+/**
+ * One draft source per adapter, kept outside React so its identity is the
+ * adapter's (ui/AGENTS.md #10): the renderers key their dataset fetches on the
+ * data source they are handed.
+ */
+const draftSources = new WeakMap