From e32db4656aa04c4063e3990e4e4aaf4a8d123c75 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:57:40 +0000 Subject: [PATCH 1/3] fix(app-shell): no drafts-ledger read for a member, no published read for a new draft Home's drafts row now reads GET /meta/_drafts only for a session holding a capability the ledger admits (studio.access, setup.access or manage_metadata), through the new useCanReadPendingDrafts(). The pending-changes sheet's drill-in waits for its new-or-update classification and does not ask a new draft for its published body, which could only answer 404. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_011fs46jjQVe98Y5YTfDTixa --- .../25894-no-drafts-request-without-grant.md | 13 ++ .../app-shell/src/console/home/HomePage.tsx | 10 +- .../HomePage.draftsReadGate-25894.test.tsx | 178 ++++++++++++++++++ .../HomePage.greetingAndApps-12601.test.tsx | 1 + .../__tests__/HomePage.myWork-12602.test.tsx | 5 +- ...ge.pendingDraftsPackageName-13136.test.tsx | 5 +- .../HomePage.recentlyAccessed-12604.test.tsx | 1 + .../src/hooks/useCanAuthorMetadata.ts | 23 +++ .../src/preview/DraftChangesPanel.tsx | 36 +++- ...nel.newEntryNoPublishedRead-25894.test.tsx | 120 ++++++++++++ 10 files changed, 385 insertions(+), 7 deletions(-) create mode 100644 .changeset/25894-no-drafts-request-without-grant.md create mode 100644 ui/packages/app-shell/src/console/home/__tests__/HomePage.draftsReadGate-25894.test.tsx create mode 100644 ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.newEntryNoPublishedRead-25894.test.tsx diff --git a/.changeset/25894-no-drafts-request-without-grant.md b/.changeset/25894-no-drafts-request-without-grant.md new file mode 100644 index 00000000000..6955ee77284 --- /dev/null +++ b/.changeset/25894-no-drafts-request-without-grant.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): the Console no longer sends two requests it knows will be refused, the drafts ledger for a member and the published body of a new draft + +Clause-②: no +Metadata-protocol: none + +- **Home's drafts row** read `GET /api/v1/meta/_drafts` for whoever opened Home. A member holding none of the capabilities the ledger admits (`studio.access`, `setup.access` or `manage_metadata`) got a 403, and the console logged it as a failed request on every visit. The row now reads the ledger only for a session that holds one of them, through the new `useCanReadPendingDrafts()`. An unreported capability set still reads it, and the server still enforces the gate. +- **The pending-changes sheet** (Studio's Publish and Changes, and the draft-preview bar's Changes) read each draft's published body beside its draft. Under Publish, where every row opens expanded, that read was sent before the sheet knew which drafts are new, so a draft-only object such as one just saved from New object got `GET /api/v1/meta/object/NAME?package=PKG` → 404. A row now waits for its new-or-update classification, and a new draft is not asked for a published body. A row whose classification read failed is read both ways, as before. + +What to do: nothing. The pages render exactly as before. diff --git a/ui/packages/app-shell/src/console/home/HomePage.tsx b/ui/packages/app-shell/src/console/home/HomePage.tsx index fcda97c9fd3..f78652f91f8 100644 --- a/ui/packages/app-shell/src/console/home/HomePage.tsx +++ b/ui/packages/app-shell/src/console/home/HomePage.tsx @@ -30,7 +30,7 @@ import { useAgents, isAskAgent, agentHasCapability } from '@object-ui/plugin-cha // May this session author metadata? Shared with the chat surfaces since the // maker convergence (cloud#1674) — the doctrine (objectstack#8270 posture, // unknown→fail-open) lives with the hook. -import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; +import { useCanAuthorMetadata, useCanReadPendingDrafts } from '../../hooks/useCanAuthorMetadata.js'; import { HomeAppsStrip } from './HomeAppsStrip.js'; import { appSegmentOf, lastOpenedByApp } from './appLastOpened.js'; import { recentItemObject, recentItemTypeLabel } from './recentItemTypeLabel.js'; @@ -292,10 +292,16 @@ function usePackageName(packageId: string | null): string | undefined { * publish. `usePublishAllDrafts` is the shared one-click publish, also run by * the ADR-0037 draft-preview bar. Returns the row, or `null` with nothing to * publish. + * + * Read only for a session the ledger admits (objectstack-ai/objectstack#25894): + * Home is every member's landing, and a member holding none of the ledger's + * capabilities was sent a `GET /meta/_drafts` that answers 403 on every visit. + * Held, the read is held too, so the row is `null`, as with nothing to publish. */ function usePendingDraftsRow(t: (key: string, opts?: any) => string, language: string): React.ReactNode { const { publishAll, publishing } = usePublishAllDrafts(t); - const { count, entries } = usePendingDrafts({}); + const canReadDrafts = useCanReadPendingDrafts(); + const { count, entries } = usePendingDrafts({ enabled: canReadDrafts }); const drafted = (count ?? 0) > 0; // Named only when every draft is in one package: a row naming one package // for drafts spread over several would misplace the rest. Named by the name diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.draftsReadGate-25894.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.draftsReadGate-25894.test.tsx new file mode 100644 index 00000000000..66e07efb403 --- /dev/null +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.draftsReadGate-25894.test.tsx @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack-ai/objectstack#25894 — Home reads the pending-drafts ledger only + * for a session the ledger admits. + * + * Measured on the 2026-10-25 browser pass: signed in as a member with no + * metadata capability, the console sent `GET /api/v1/meta/_drafts` and logged + * its 403 as a resource error. Home is every member's landing (the approvals + * inbox lives here), and its drafts row read the env-wide ledger for whoever + * opened it. The door admits `studio.access`, `setup.access` or + * `manage_metadata` (`mayReadPendingDrafts`); a session holding none of them + * is now sent no read. + * + * Drives the REAL `MePermissionsProvider` over a `/me/permissions` payload, as + * `HomePage.authoringCapabilityGate.test.tsx` does, so the gate consumes the + * server's answer through `hasCapabilities`. + * + * Pinned: + * - a reported set holding none of the three: no `_drafts` read, no row; + * - CONTROL: each of the three alone reads the ledger and renders the row; + * - CONTROL: an unreported set (no `systemPermissions`) fails OPEN and reads. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { act, render, screen, cleanup } from '@testing-library/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +vi.mock('react-router-dom', async () => { + const { createElement } = await import('react'); + return { + useNavigate: () => vi.fn(), + Link: ({ to, ...rest }: { to: string } & Record) => createElement('a', { ...rest, href: to }), + }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ t: (key: string) => `«${key}»`, language: 'en' }), + useObjectLabel: () => ({ appLabel: (app: any) => String(app?.label ?? app?.name ?? '') }), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Sales Manager', email: 'sales.manager@example.com' } }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + +vi.mock('@object-ui/plugin-chatbot/core', async (importOriginal) => ({ + ...(await importOriginal()), + useAgents: () => ({ agents: [] }), + isAskAgent: () => false, + agentHasCapability: () => false, +})); + +vi.mock('../../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ apps: [{ name: 'crm', label: 'CRM' }], loading: false }), +})); +vi.mock('../../../context/NavigationContext', () => ({ + useNavigationContext: () => ({ currentAppName: 'crm' }), +})); +vi.mock('../../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) })); +vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) })); +vi.mock('../../../hooks/useHomeInbox', () => ({ + useHomeInbox: () => ({ + pendingApprovalsCount: 0, + pendingApprovals: [], + pendingApprovalsStatus: 'ready', + pendingApprovalsReadable: true, + notifications: [], + unreadTopicCount: 0, + notificationsStatus: 'ready', + notificationsReadable: true, + activity: { value: [], status: 'ready', readable: true }, + }), +})); +vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' })); +vi.mock('../../../preview/usePublishAllDrafts', () => ({ + usePublishAllDrafts: () => ({ publishAll: async () => ({ ok: true }), publishing: false }), +})); +vi.mock('../../../runtime-config', () => ({ + getRuntimeConfig: () => ({ branding: { productName: 'ObjectStack' } }), + isMarketplaceEnabled: () => true, + isAiStudioEnabled: () => true, +})); + +import { HomePage } from '../HomePage'; + +const DRAFTS_ROUTE = '/api/v1/meta/_drafts'; +const routeOf = (url: string) => url.split('?')[0]; + +/** Two drafts in two packages: the row names no package, so no package list is read. */ +const DRAFTS = [ + { type: 'object', name: 'visit', label: 'Visit', packageId: 'com.a' }, + { type: 'object', name: 'invoice', label: 'Invoice', packageId: 'com.b' }, +]; + +let fetchCalls: string[] = []; + +beforeEach(() => { + fetchCalls = []; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String(input); + fetchCalls.push(url); + if (routeOf(url) === DRAFTS_ROUTE) return { ok: true, status: 200, json: async () => ({ drafts: DRAFTS }) }; + return { ok: false, status: 404, json: async () => ({}) }; + }), + ); +}); + +afterEach(() => { + expect(fetchCalls.filter((url) => routeOf(url) !== DRAFTS_ROUTE)).toEqual([]); + // Unmount before restoring the real `fetch` (objectui#7439). + cleanup(); + vi.unstubAllGlobals(); +}); + +const payload = (systemPermissions?: string[]): MePermissionsResponse => + ({ + authenticated: true, + userId: 'u1', + tenantId: 'acme', + roles: ['everyone'], + permissionSets: ['member_default'], + ...(systemPermissions ? { systemPermissions } : {}), + objects: {}, + fields: {}, + }) as MePermissionsResponse; + +async function renderHome(permissions: MePermissionsResponse) { + render( + + + , + ); + await act(async () => { + for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0)); + }); +} + +const draftsReads = () => fetchCalls.filter((url) => routeOf(url) === DRAFTS_ROUTE); + +describe('Home reads the drafts ledger only for a session it admits (objectstack-ai/objectstack#25894)', () => { + it('a member holding none of the ledger capabilities: no `_drafts` read and no row', async () => { + await renderHome(payload(['export_reports'])); + + expect(screen.getByRole('heading', { level: 1 }), 'Home never rendered: the harness is dead').toBeInTheDocument(); + expect(draftsReads()).toEqual([]); + expect(screen.queryByTestId('home-pending-drafts')).toBeNull(); + }); + + it('a REPORTED empty set holds none of them either: no read', async () => { + await renderHome(payload([])); + + expect(draftsReads()).toEqual([]); + }); + + it.each(['setup.access', 'studio.access', 'manage_metadata'])( + 'CONTROL: `%s` alone reads the ledger and shows the row', + async (capability) => { + await renderHome(payload([capability])); + + expect(draftsReads()).toEqual([DRAFTS_ROUTE]); + expect(await screen.findByTestId('home-pending-drafts')).toBeInTheDocument(); + }, + ); + + it('CONTROL: an unreported capability set fails OPEN and reads', async () => { + await renderHome(payload(undefined)); + + expect(draftsReads()).toEqual([DRAFTS_ROUTE]); + expect(await screen.findByTestId('home-pending-drafts')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx index 19c74c5e40f..32ca26916bb 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx @@ -107,6 +107,7 @@ vi.mock('../../../context/NavigationContext', () => ({ })); vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => state.canAuthorMetadata, + useCanReadPendingDrafts: () => true, })); // The page's one inbox seam: the approvals count AND whether it is an answer. vi.mock('../../../hooks/useHomeInbox', () => ({ diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx index 45afa04a3a0..6d56b21c618 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx @@ -105,7 +105,10 @@ vi.mock('../../../providers/MetadataProvider', () => ({ vi.mock('../../../context/NavigationContext', () => ({ useNavigationContext: () => ({ currentAppName: 'crm' }), })); -vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true })); +vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ + useCanAuthorMetadata: () => true, + useCanReadPendingDrafts: () => true, +})); // The page's one inbox seam: both sources, their answers and their read grants. vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.pendingDraftsPackageName-13136.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.pendingDraftsPackageName-13136.test.tsx index 44ad1739248..cf0cff76b8b 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.pendingDraftsPackageName-13136.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.pendingDraftsPackageName-13136.test.tsx @@ -86,7 +86,10 @@ vi.mock('../../../providers/MetadataProvider', () => ({ vi.mock('../../../context/NavigationContext', () => ({ useNavigationContext: () => ({ currentAppName: 'crm' }), })); -vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true })); +vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ + useCanAuthorMetadata: () => true, + useCanReadPendingDrafts: () => true, +})); vi.mock('../../../hooks/useHomeInbox', () => ({ useHomeInbox: () => ({ pendingApprovals: [], diff --git a/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentlyAccessed-12604.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentlyAccessed-12604.test.tsx index 0aa360061ea..bd08513e4c9 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentlyAccessed-12604.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/HomePage.recentlyAccessed-12604.test.tsx @@ -101,6 +101,7 @@ vi.mock('../../../context/NavigationContext', () => ({ })); vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => state.canAuthorMetadata, + useCanReadPendingDrafts: () => true, })); // The page's one inbox seam: the approvals count AND whether it is an answer. vi.mock('../../../hooks/useHomeInbox', () => ({ diff --git a/ui/packages/app-shell/src/hooks/useCanAuthorMetadata.ts b/ui/packages/app-shell/src/hooks/useCanAuthorMetadata.ts index a310f6a6620..10c49ce7215 100644 --- a/ui/packages/app-shell/src/hooks/useCanAuthorMetadata.ts +++ b/ui/packages/app-shell/src/hooks/useCanAuthorMetadata.ts @@ -73,3 +73,26 @@ export function useCanReadAuthoringTrail(): boolean { const { hasCapabilities } = usePermissions(); return AUTHORING_TRAIL_CAPABILITIES.some((capability) => hasCapabilities([capability])); } + +/** + * objectstack-ai/objectstack#25894 — the capabilities the pending-drafts ledger + * (`GET /meta/_drafts`) admits. The door asks `mayReadPendingDrafts`, the + * ADR-0106 D4 mask-exemption cohort (`OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES` + * in `@objectstack/metadata-core`): the write capability plus the two + * read-only exemptions. Any ONE of them opens it. `system` is a principal + * flag with no client half, as for the trail above. + */ +export const PENDING_DRAFTS_CAPABILITIES: readonly string[] = ['studio.access', 'setup.access', AUTHORING_CAPABILITY]; + +/** + * May this session read the pending-drafts ledger? + * + * Asked by a surface that reads the ledger for whoever opens it (Home's drafts + * row), so a member the door refuses is not sent a request that always answers + * 403. Unknown fails OPEN, as {@link useCanAuthorMetadata} does: the server + * enforces regardless. + */ +export function useCanReadPendingDrafts(): boolean { + const { hasCapabilities } = usePermissions(); + return PENDING_DRAFTS_CAPABILITIES.some((capability) => hasCapabilities([capability])); +} diff --git a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx index c8a0f12ced7..0a6d06a9050 100644 --- a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -580,13 +580,22 @@ function FieldRow({ * answer carries a numeric `total`: with none, the choice names no number * rather than a guessed one. What the publish actually wrote is the door's * receipt, which the host reports. + * + * `classifying` (objectstack-ai/objectstack#25894): the panel's new-vs-update + * reads are still in flight. A NEW entry has no published body, so its + * published read is not sent: it could only answer 404, logged as a failed + * request on the console for every draft-only item. The drill-in waits while + * the entry is unclassified and the reads are pending; one whose read failed + * (still unclassified once they settle) is read both ways, as before. */ function EntryDetail({ entry, + classifying = false, onCompared, backfill, }: { entry: DraftChangeEntry; + classifying?: boolean; onCompared?: (unchanged: boolean) => void; backfill?: EntryBackfillControl; }) { @@ -611,13 +620,21 @@ function EntryDetail({ onComparedRef.current = onCompared; }); + const isNew = entry.kind === 'new'; + const awaitingKind = entry.kind === undefined && classifying; useEffect(() => { + if (awaitingKind) return; let cancelled = false; (async () => { try { const [published, draft] = await Promise.all([ - // A NEW item 404s on the published read — that's data, not an error. - fetchItemBody(entry.type, entry.name, { packageId: entry.packageId, member: entry.member }), + // A NEW item has no published body: not asked + // (objectstack-ai/objectstack#25894). An item the + // classification could not answer still 404s here when it is new — + // that's data, not an error. + isNew + ? null + : fetchItemBody(entry.type, entry.name, { packageId: entry.packageId, member: entry.member }), fetchItemBody(entry.type, entry.name, { draft: true, packageId: entry.packageId, @@ -639,7 +656,7 @@ function EntryDetail({ return () => { cancelled = true; }; - }, [entry.type, entry.name, entry.packageId, entry.member]); + }, [entry.type, entry.name, entry.packageId, entry.member, isNew, awaitingKind]); // objectui#12929 — how many records the choice would reach: asked once per // object, and only where a choice is offered. @@ -817,6 +834,14 @@ export function DraftChangesPanel({ const { t, language } = useObjectTranslation(); const [entries, setEntries] = useState(null); const [error, setError] = useState(null); + /** + * The new-vs-update reads of the latest load are in flight + * (objectstack-ai/objectstack#25894): a drill-in holds its published read + * until its entry is classified or they settle. Keyed by load generation, so + * an earlier load settling late does not end a later one's. + */ + const [classifying, setClassifying] = useState(false); + const loadGenRef = useRef(0); /** * Author-time security findings that the publish door would REFUSE * (`7a90afdf9`). Surfaced here, next to the button, so the refusal is @@ -906,6 +931,8 @@ export function DraftChangesPanel({ }; const load = useCallback(async () => { + const gen = ++loadGenRef.current; + setClassifying(true); setEntries(null); setError(null); setProblems([]); @@ -983,6 +1010,8 @@ export function DraftChangesPanel({ ]); } catch (e) { setError((e as Error).message); + } finally { + if (loadGenRef.current === gen) setClassifying(false); } }, [packageId, include]); @@ -1183,6 +1212,7 @@ export function DraftChangesPanel({
markCompared(key, same)} backfill={backfillControl} /> diff --git a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.newEntryNoPublishedRead-25894.test.tsx b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.newEntryNoPublishedRead-25894.test.tsx new file mode 100644 index 00000000000..deca73f3b7c --- /dev/null +++ b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.newEntryNoPublishedRead-25894.test.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack-ai/objectstack#25894 — the pending-changes sheet does not ask + * for the published body of a NEW draft. + * + * Measured on the 2026-10-25 browser pass: after New object → Save as draft in + * Studio, the console logged `GET /api/v1/meta/object/NAME?package=PKG` → 404. + * Studio's Publish opens this sheet in the confirm posture, every row + * expanded, and each row's drill-in read the published body beside the draft + * before the new-vs-update reads had answered. A draft-only item has no + * published body, so that read could only answer 404. + * + * Pinned: + * - a NEW entry: no published item read, the draft read is sent, the detail + * renders; + * - CONTROL: an UPDATE entry still reads its published body; + * - while the classification is in flight, no drill-in sends a published read; + * - CONTROL: a type whose classification read fails stays unclassified, and + * its entry is read both ways, as before. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, + language: 'en', + }), +})); + +import { DraftChangesPanel } from '../DraftChangesPanel'; + +const PKG = 'com.x'; +// `st_ticket` is draft-only (NEW); `st_queue` overwrites a published object. +const DRAFT_ROWS = [ + { type: 'object', name: 'st_ticket', packageId: PKG, label: 'Ticket' }, + { type: 'object', name: 'st_queue', packageId: PKG, label: 'Queue' }, +]; + +const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); +const notFound = () => ({ ok: false, status: 404, json: async () => ({}) }); + +let publishedList: () => Promise = async () => ok([{ name: 'st_queue' }]); +const requests: string[] = []; + +const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + requests.push(url); + if (url.includes('/_drafts')) return ok({ drafts: DRAFT_ROWS }); + if (/\/meta\/object(\?|$)/.test(url)) return publishedList(); + const item = url.match(/\/meta\/object\/([^?]+)/)?.[1]; + if (url.includes('state=draft')) return ok({ name: item, label: 'Draft', fields: {} }); + if (item === 'st_queue') return ok({ name: item, label: 'Published', fields: {} }); + return notFound(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + fetchMock.mockClear(); + requests.length = 0; + publishedList = async () => ok([{ name: 'st_queue' }]); +}); + +/** The published item reads sent for `name` (no `state=draft`). */ +const publishedReads = (name: string) => + requests.filter((u) => u.includes(`/meta/object/${name}`) && !u.includes('state=draft')); +const draftReads = (name: string) => + requests.filter((u) => u.includes(`/meta/object/${name}`) && u.includes('state=draft')); + +function renderConfirm() { + vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch); + render( {}} packageId={PKG} onPublish={() => {}} posture="confirm" />); +} + +describe('objectstack-ai/objectstack#25894 — a NEW draft is not asked for its published body', () => { + it('a NEW entry sends its draft read only; an UPDATE entry (CONTROL) still reads its published body', async () => { + renderConfirm(); + await waitFor(() => expect(screen.getAllByTestId('draft-entry-detail')).toHaveLength(DRAFT_ROWS.length)); + await waitFor(() => expect(publishedReads('st_queue')).toHaveLength(1)); + + expect(draftReads('st_ticket'), 'the drill-in never ran: the harness is dead').toHaveLength(1); + expect(publishedReads('st_ticket')).toEqual([]); + expect(publishedReads('st_queue')[0]).toContain(`package=${PKG}`); + expect(screen.getByText('New')).toBeInTheDocument(); + expect(screen.getByText('Update')).toBeInTheDocument(); + }); + + it('while the new-vs-update read is in flight, no drill-in sends a published read', async () => { + let answer: () => void = () => {}; + publishedList = () => + new Promise((resolve) => { + answer = () => resolve(ok([{ name: 'st_queue' }])); + }); + renderConfirm(); + await waitFor(() => expect(requests.some((u) => /\/meta\/object(\?|$)/.test(u))).toBe(true)); + // The rows are expanded and mounted; their published reads wait. + await waitFor(() => expect(screen.getAllByTestId('draft-entry-toggle')).toHaveLength(DRAFT_ROWS.length)); + expect(publishedReads('st_ticket')).toEqual([]); + expect(publishedReads('st_queue')).toEqual([]); + + answer(); + await waitFor(() => expect(publishedReads('st_queue')).toHaveLength(1)); + await waitFor(() => expect(draftReads('st_ticket')).toHaveLength(1)); + expect(publishedReads('st_ticket')).toEqual([]); + }); + + it('CONTROL: a type whose classification fails is read both ways, as before', async () => { + publishedList = async () => ({ ok: false, status: 500, json: async () => ({}) }); + renderConfirm(); + await waitFor(() => expect(publishedReads('st_ticket')).toHaveLength(1)); + await waitFor(() => expect(publishedReads('st_queue')).toHaveLength(1)); + expect(screen.queryByText('New')).toBeNull(); + expect(screen.queryByText('Update')).toBeNull(); + }); +}); From c91002477a0bb0698bd27140e5ec5d06e66efd54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:44:58 +0000 Subject: [PATCH 2/3] test(app-shell): count the new entry's draft reads as at least one The sheet's security-posture lint also reads each object draft once its rule module loads, so the exact count is timing-dependent (red on CI Test UI 8/16). The pinned behaviour, no published read for a NEW entry, is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_011fs46jjQVe98Y5YTfDTixa --- ...ftChangesPanel.newEntryNoPublishedRead-25894.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.newEntryNoPublishedRead-25894.test.tsx b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.newEntryNoPublishedRead-25894.test.tsx index deca73f3b7c..c4f59354387 100644 --- a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.newEntryNoPublishedRead-25894.test.tsx +++ b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.newEntryNoPublishedRead-25894.test.tsx @@ -69,6 +69,11 @@ afterEach(() => { /** The published item reads sent for `name` (no `state=draft`). */ const publishedReads = (name: string) => requests.filter((u) => u.includes(`/meta/object/${name}`) && !u.includes('state=draft')); +/** + * The draft reads sent for `name`. Counted as "at least one", never exactly: + * the sheet's security-posture lint reads each object draft too, once its + * rule module has loaded, so the total depends on that load's timing. + */ const draftReads = (name: string) => requests.filter((u) => u.includes(`/meta/object/${name}`) && u.includes('state=draft')); @@ -83,7 +88,7 @@ describe('objectstack-ai/objectstack#25894 — a NEW draft is not asked for its await waitFor(() => expect(screen.getAllByTestId('draft-entry-detail')).toHaveLength(DRAFT_ROWS.length)); await waitFor(() => expect(publishedReads('st_queue')).toHaveLength(1)); - expect(draftReads('st_ticket'), 'the drill-in never ran: the harness is dead').toHaveLength(1); + expect(draftReads('st_ticket').length, 'the drill-in never ran: the harness is dead').toBeGreaterThan(0); expect(publishedReads('st_ticket')).toEqual([]); expect(publishedReads('st_queue')[0]).toContain(`package=${PKG}`); expect(screen.getByText('New')).toBeInTheDocument(); @@ -105,7 +110,7 @@ describe('objectstack-ai/objectstack#25894 — a NEW draft is not asked for its answer(); await waitFor(() => expect(publishedReads('st_queue')).toHaveLength(1)); - await waitFor(() => expect(draftReads('st_ticket')).toHaveLength(1)); + await waitFor(() => expect(draftReads('st_ticket').length).toBeGreaterThan(0)); expect(publishedReads('st_ticket')).toEqual([]); }); From d4b5ee12186b6f07541351ec0dbd730d3848180f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 12:10:13 +0000 Subject: [PATCH 3/3] fix(app-shell): name the drill-in's new-entry flag classifiedNew The pending-changes drill-in now also destructures `isNew` from its computed detail, so the classification flag the load effect reads takes its own name. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_011fs46jjQVe98Y5YTfDTixa --- ui/packages/app-shell/src/preview/DraftChangesPanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx index 72248db3f31..c4c728b99e2 100644 --- a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -696,7 +696,7 @@ function EntryDetail({ onComparedRef.current = onCompared; }); - const isNew = entry.kind === 'new'; + const classifiedNew = entry.kind === 'new'; const awaitingKind = entry.kind === undefined && classifying; useEffect(() => { if (awaitingKind) return; @@ -708,7 +708,7 @@ function EntryDetail({ // (objectstack-ai/objectstack#25894). An item the // classification could not answer still 404s here when it is new — // that's data, not an error. - isNew + classifiedNew ? null : fetchItemBody(entry.type, entry.name, { packageId: entry.packageId, member: entry.member }), fetchItemBody(entry.type, entry.name, { @@ -732,7 +732,7 @@ function EntryDetail({ return () => { cancelled = true; }; - }, [entry.type, entry.name, entry.packageId, entry.member, isNew, awaitingKind]); + }, [entry.type, entry.name, entry.packageId, entry.member, classifiedNew, awaitingKind]); // objectui#12929 — how many records the choice would reach: asked once per // object, and only where a choice is offered.